X-Git-Url: https://git.ladys.computer/Beorn/blobdiff_plain/87e79e5a942a55f386f349242b8b554d6a50db4d..7946ec4dd08a94bc530b3b31ea54d6c892ac9ccc:/build.js diff --git a/build.js b/build.js index 743de64..76f2083 100755 --- a/build.js +++ b/build.js @@ -2,7 +2,7 @@ // 🧸📔 Bjørn ∷ build.js // ==================================================================== // -// Copyright © 2022 Lady [@ Lady’s Computer]. +// Copyright © 2022‐2023 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 @@ -54,6 +54,7 @@ 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 RDFS = "http://www.w3.org/2000/01/rdf-schema#"; const SIOC = "http://rdfs.org/sioc/ns#"; const XML = "http://www.w3.org/XML/1998/namespace"; const XHTML = "http://www.w3.org/1999/xhtml"; @@ -201,55 +202,17 @@ const applyMetadata = (node, metadata) => { 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 */ - } + fillOutHead(document, metadata, "entry"); const contentPlaceholder = document.getElementsByTagNameNS( XHTML, "bjørn-content", @@ -381,6 +344,7 @@ const applyMetadata = (node, metadata) => { // There is no content placeholder. /* do nothing */ } + globalThis.bjørnTransformEntryHTML?.(document, metadata); } else { // This is not an XHTML template. /* do nothing */ @@ -440,13 +404,19 @@ const applyMetadata = (node, metadata) => { // This entry does not have a publication date. /* do nothing */ } - addContent(contentElement, "\n"); - addContent( - contentElement.appendChild( - LMN.div.property(`${DC11}abstract`)``, - ), - summary, - ); + if (summary) { + // This entry has a summary. + addContent(contentElement, "\n"); + addContent( + contentElement.appendChild( + LMN.div.property(`${DC11}abstract`)``, + ), + summary, + ); + } else { + // This entry does not have a summary. + /* do nothing */ + } addContent(contentElement, "\n"); addContent(node, "\n"); } else { @@ -455,6 +425,12 @@ const applyMetadata = (node, metadata) => { // Assume it is an Atom element of some sort and add the // the appropriate metadata as child elements. const { ownerDocument: document } = node; + const alternateLink = node.appendChild( + document.createElement("link"), + ); + alternateLink.setAttribute("rel", "alternate"); + alternateLink.setAttribute("type", "application/xhtml+xml"); + alternateLink.setAttribute("href", metadata.id); for (const [property, values] of Object.entries(metadata)) { for (const value of Array.isArray(values) ? values : [values]) { const propertyNode = document.createElement(property); @@ -535,31 +511,63 @@ const basePath = `./${Deno.args[0] ?? ""}`; * * - "literal": This is a plaintext field. */ -const context = { - author: { namespace: DC11, localName: "creator", type: "person" }, +const context = Object.freeze({ + author: Object.freeze({ + namespace: DC11, + localName: "creator", + type: "person", + }), // category is not supported at this time - content: { namespace: SIOC, localName: "content", type: "text" }, - contributor: { + content: Object.freeze({ + namespace: SIOC, + localName: "content", + type: "text", + }), + contributor: Object.freeze({ namespace: DC11, localName: "contributor", type: "person", - }, + }), // generator is provided by the build script - icon: { namespace: AWOL, localName: "icon", type: "literal" }, + icon: Object.freeze({ + namespace: AWOL, + localName: "icon", + type: "literal", + }), // link is provided by the build script - logo: { namespace: AWOL, localName: "logo", type: "literal" }, - published: { + logo: Object.freeze({ + namespace: AWOL, + localName: "logo", + type: "literal", + }), + published: Object.freeze({ namespace: DC11, localName: "date", type: "literal", - }, - rights: { namespace: DC11, localName: "rights", type: "text" }, + }), + rights: Object.freeze({ + 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" }, + subtitle: Object.freeze({ + namespace: RDFS, + localName: "label", + type: "text", + }), + summary: Object.freeze({ + namespace: DC11, + localName: "abstract", + type: "text", + }), + title: Object.freeze({ + namespace: DC11, + localName: "title", + type: "text", + }), // updated is provided by the build script -}; +}); const { /** @@ -582,6 +590,91 @@ const { }; })(); +/** + * Fills out the `head` of the provided H·T·M·L document with the + * appropriate metadata. + */ +const fillOutHead = (document, metadata, type) => { + const { documentElement } = document; + 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 { + title, + author, + subtitle, + summary, + } = 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, + })``, + ); + } + head.appendChild( + LMN.meta({ name: "generator", content: "🧸📔 Bjørn" })``, + ); + if (type == "entry") { + // The provided document is an entry document. + 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 */ + } + head.appendChild( + LMN.link + .rel("alternate") + .type("application/atom+xml") + .href("../../feed.atom")``, + ); + } else { + // The provided document is not an entry document. + if (subtitle) { + // The entry has a subtitle. + head.appendChild( + LMN.meta({ + name: "description", + content: Object(subtitle) instanceof String + ? summary + : Array.from(subtitle).map(($) => $.textContent).join(""), + })``, + ); + } else { + /* do nothing */ + } + head.appendChild( + LMN.link + .rel("alternate") + .type("application/atom+xml") + .href("./feed.atom")``, + ); + } + globalThis.bjørnTransformHead?.(head, metadata, type); +}; + /** * Returns the language of the provided node, or null if it has no * language. @@ -807,6 +900,7 @@ const metadataFromDocument = ( /* do nothing */ } } + globalThis.bjørnTransformMetadata?.(result, documentType); return validateMetadata(result, documentType); }; @@ -847,154 +941,174 @@ const validateMetadata = (metadata, type) => { } }; +{ // Set up global variables for use in hooks. + // + // Bjørn is principally built to be run from the command line (as a + // shell script) rather than conforming to typical Ecmascript module + // patterns. However, it recognizes hooks through various + // specially‐named properties on `globalThis`. After defining these + // hooks, a script can use a *dynamic* `import("./path/to/build.js")` + // to run the Bjørn build steps. + // + // To make writing scripts which make use of these hooks easier, + // infrastructural dependencies and useful functions are provided on + // `globalThis` so that wrapping scripts don’t have to attempt to + // manage the dependencies themselves. + // + // Note that the `Lemon/window` polyfill will already have + // established some D·O·M‐related global properties by the time this + // runs, so they don’t need to be redeclared here. + globalThis.Lemon = Lemon; + globalThis.Bjørn = { + addContent, + context, + getLanguage, + setLanguage, + }; +} + await (async () => { // this is the run script const writes = []; - // Set up the Atom feed. + // Set up the feed metadata and Atom feed document. + const feedDocument = parser.parseFromString( + await Deno.readTextFile(`${basePath}/#feed.rdf`), + "application/xml", + ); + const feedMetadata = metadataFromDocument(feedDocument); + const feedURI = new URL(feedMetadata.id); const document = parser.parseFromString( ` -`, +🧸📔 Bjørn`, "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); + const feedLanguage = getLanguage(feedDocument); + if (feedLanguage) { + // The feed element has a language. + setLanguage(feed, feedLanguage); + } else { + // There is no language for the feed. + /* do nothing */ + } applyMetadata(feed, feedMetadata); // Set up the index page. const feedTemplate = await documentFromTemplate("feed"); + const { documentElement: feedTemplateRoot } = feedTemplate; + if (feedLanguage && !getLanguage(feedTemplateRoot)) { + // The root element of the template does not have an + // assigned language, but the feed does. + setLanguage(feedTemplateRoot, feedLanguage); + } else { + // Either the template root already has a language, or the + // entry doesn’t either. + /* do nothing */ + } const feedEntries = feedTemplate.createDocumentFragment(); // Process entries and save the resulting index files. - for await ( - const { name: date, isDirectory } of Deno.readDir( - `${basePath}/`, + for ( + const { name: date } of Array.from( + Deno.readDirSync(`${basePath}/`), + ).filter(({ name: date, isDirectory }) => + // Exclude non‐dated directories. + isDirectory && /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/u.test(date) + ).sort(({ name: a }, { name: b }) => + // Sort the directories. + a < b ? 1 : a > b ? -1 : 0 ) ) { - // 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); - } + // Iterate over each dated directory and process its entries. + for ( + const { name: entryName } of Array.from( + Deno.readDirSync(`${basePath}/${date}/`), + ).filter(({ name: entryName, isDirectory }) => + // Exclude non‐entry directories. + isDirectory && + //deno-lint-ignore no-control-regex + !/[\x00-\x20\x22#%/<>?\\^\x60{|}\x7F]/u.test(entryName) + ).sort(({ name: a }, { name: b }) => + // Sort the directories. + a < b ? 1 : a > b ? -1 : 0 + ) + ) { + // Iterate over each entry directory and process its contents. + 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")) { + if (hasExpandedName(feedTemplateRoot, 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, + subtitle, 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}`)``, - ); - } + fillOutHead(feedTemplate, feedMetadata, "feed"); const contentPlaceholder = feedTemplate.getElementsByTagNameNS( XHTML, "bjørn-content", ).item(0); if (contentPlaceholder != null) { + // There is a content placeholder. const { parentNode: contentParent } = contentPlaceholder; const contentElement = contentParent.insertBefore( LMN.nav.about(`${id}`)`${"\n"}`, @@ -1008,6 +1122,17 @@ await (async () => { // this is the run script title, ); addContent(contentHeader, "\n"); + if (subtitle) { + // The feed has a subtitle. + addContent( + contentHeader.appendChild(LMN.p.property(`${RDFS}label`)``), + subtitle, + ); + addContent(contentHeader, "\n"); + } else { + // The feed has no subtitle. + /* do nothing */ + } addContent(contentElement, "\n"); const entriesElement = contentElement.appendChild( LMN.ul.rel(`${AWOL}entry`)`${"\n"}`, @@ -1018,6 +1143,7 @@ await (async () => { // this is the run script LMN.footer`${"\n\t"}`, ); if (rights) { + // The feed has a rights statement. addContent( contentFooter.appendChild( LMN.div.property(`${DC11}rights`)``, @@ -1025,6 +1151,9 @@ await (async () => { // this is the run script rights, ); addContent(contentFooter, "\n\t"); + } else { + // The feed has no rights statement. + /* do nothing */ } contentFooter.appendChild( LMN.p.id("entry.updated")`Last updated: ${LMN.time.property( @@ -1038,6 +1167,7 @@ await (async () => { // this is the run script /* do nothing */ } } + globalThis.bjørnTransformFeedHTML?.(feedTemplate, feedMetadata); writes.push( Deno.writeTextFile( "index.xhtml", @@ -1046,6 +1176,7 @@ await (async () => { // this is the run script ); // Save the feed Atom file. + globalThis.bjørnTransformFeedAtom?.(document, feedMetadata); writes.push( Deno.writeTextFile( "feed.atom",