]> Lady’s Gitweb - Beorn/commitdiff
Initial commit
authorLady <redacted>
Sun, 7 Aug 2022 22:54:40 +0000 (15:54 -0700)
committerLady <redacted>
Sat, 29 Apr 2023 03:43:42 +0000 (20:43 -0700)
README.markdown [new file with mode: 0644]
build.js [new file with mode: 0755]
deno.json [new file with mode: 0644]
index#entry.xhtml [new file with mode: 0644]
index#feed.xhtml [new file with mode: 0644]

diff --git a/README.markdown b/README.markdown
new file mode 100644 (file)
index 0000000..6cce62f
--- /dev/null
@@ -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
+<awol:Feed
+  rdf:about="https://blog.homepage.example"
+  xml:lang="en"
+  xmlns:awol="http://bblfish.net/work/atom-owl/2006-06-06/"
+  xmlns:dc11="http://purl.org/dc/elements/1.1/"
+  xmlns:foaf="http://xmlns.com/foaf/0.1/"
+  xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+>
+  <dc11:title>My Cool Blog</dc11:title>
+  <dc11:creator>
+    <foaf:Person rdf:about="https://author.homepage.example" foaf:name="Me"/>
+  </dc11:creator>
+</awol:Feed>
+```
+
+Your `entry.rdf` will look similar, but it needs a `sioc:content`
+property :—
+
+```xml
+<awol:Entry
+  xml:lang="en"
+  xmlns:awol="http://bblfish.net/work/atom-owl/2006-06-06/"
+  xmlns:dc11="http://purl.org/dc/elements/1.1/"
+  xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+  xmlns:sioc="http://rdfs.org/sioc/ns#"
+>
+  <dc11:title>A Sample Post</dc11:title>
+  <sioc:content rdf:parseType="Markdown"><![CDATA[
+Here is some *amazing* content!
+
+I love it!
+]]></sioc:content>
+</awol:Entry>
+```
+
+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 `<![CDATA[` section.
+
+2. Your Markdown must process into valid X·M·L (not merely H·T·M·L),
+   due to 🧸📔 Bjørn not really understanding the latter.
+
+🧸📔 Bjørn uses [rusty_markdown][rusty_markdown] for its Markdown
+processing, which is [CommonMark][CommonMark]‐compliant.
+
+Alongside the metadata files, there are two template files,
+`index#feed.xhtml` (used to generate the index page) and
+`index#entry.xhtml` (used to generate individual blogposts). Generated
+content will replace the first `<bjørn-content>` element. Metadata will
+be inserted into the `<head>` for you automatically.
+
+To modify what content is generated, simply edit `build.js` :) .
+
+[CommonMark]: <https://commonmark.org>
+[Deno]: <https://deno.land/>
+[RDF11-XML]: <https://www.w3.org/TR/rdf-syntax-grammar/>
+[rusty_markdown]: <https://deno.land/x/rusty_markdown>
diff --git a/build.js b/build.js
new file mode 100755 (executable)
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 <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);
+})();
diff --git a/deno.json b/deno.json
new file mode 100644 (file)
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 (file)
index 0000000..9feb54e
--- /dev/null
@@ -0,0 +1,6 @@
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head></head>
+<body>
+<bjørn-content/>
+</body>
+</html>
diff --git a/index#feed.xhtml b/index#feed.xhtml
new file mode 100644 (file)
index 0000000..9feb54e
--- /dev/null
@@ -0,0 +1,6 @@
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head></head>
+<body>
+<bjørn-content/>
+</body>
+</html>
This page took 0.043045 seconds and 4 git commands to generate.