]> Lady’s Gitweb - Beorn/blob - build.js
Initial commit
[Beorn] / build.js
1 #!/usr/bin/env -S deno run --allow-read --allow-write
2 // 🧸📔 Bjørn ∷ build.js
3 // ====================================================================
4 //
5 // Copyright © 2022 Lady [@ Lady’s Computer].
6 //
7 // This Source Code Form is subject to the terms of the Mozilla Public
8 // License, v. 2.0. If a copy of the MPL was not distributed with this
9 // file, You can obtain one at <https://mozilla.org/MPL/2.0/>.
10
11 // As the shebang at the top of this file indicates, it must be run via
12 // Deno with read and write permissions. You can get Deno from
13 // <https://deno.land/>.
14 //
15 // This file generates a minimal, Atom‐supporting blog from a handful
16 // of R·D·F∕X·M·L files and a couple of templates. It will silently
17 // overwrite the files it generates. This is usually what you want.
18 //
19 // ※ The actual run script is at the very end of this file. It is
20 // preceded by a number of helper function definitions, of which the
21 // most important is `applyMetadata` (which is used to generate the
22 // HTML and Atom elements from the processed metadata).
23 //
24 // ※ The list of supported metadata properties and their R·D·F
25 // representations is provided by `context`. You can add support for
26 // new metadata fields simply by adding them to the `context` and then
27 // handling them appropriately in `applyMetadata`.
28 //
29 // This script is a bit of a mess and you shouldn’t bother trying to
30 // understand the whole thing before you start hacking on it. Just find
31 // the parts relevant to whatever you want to change, and assume that
32 // things will work in sensible (if cumbersome) browser‐like ways.
33
34 // This import polyfills a (mediocre but sufficient) D·O·M environment
35 // onto the global object.
36 import "https://git.ladys.computer/Lemon/blob_plain/0.2.2:/window/mod.js";
37
38 // Much of the H·T·M·L generation in this script uses the 🍋🏷 Lemon
39 // library for convenience (<https://git.ladys.computer/Lemon>).
40 //
41 // Frequently this will be bound to a different document than the
42 // global one and called as `LMN`.
43 import Lemon from "https://git.ladys.computer/Lemon/blob_plain/0.2.2:/mod.js";
44
45 // Markdown processing uses rusty_markdown, which uses Rust’s
46 // pulldown-cmark behind the scenes via WebAssembly.
47 import {
48 html as markdownTokensToHTML,
49 tokens as markdownTokens,
50 } from "https://deno.land/x/rusty_markdown@v0.4.1/mod.ts";
51
52 // Various namespaces.
53 const AWOL = "http://bblfish.net/work/atom-owl/2006-06-06/";
54 const DC11 = "http://purl.org/dc/elements/1.1/";
55 const FOAF = "http://xmlns.com/foaf/0.1/";
56 const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
57 const SIOC = "http://rdfs.org/sioc/ns#";
58 const XML = "http://www.w3.org/XML/1998/namespace";
59 const XHTML = "http://www.w3.org/1999/xhtml";
60
61 /**
62 * Adds the provided content to the provided element.
63 *
64 * Content may be either nullish (in which case nothing is added), a
65 * string (object or literal), a NodeList, or an array of zero or more
66 * of these values. Nodes need not belong to the same document as the
67 * provided element; they will be imported. During this process,
68 * special care is taken to ensure that the resulting content is
69 * correctly language‐tagged for its new context.
70 *
71 * ☡ If the provided element is not attached to anything, then it won’t
72 * be possible to walk its parent nodes in search of language
73 * information. Generally, it is best to attach elements to a document
74 * BEFORE calling this function.
75 */
76 const addContent = (element, content) => {
77 const { ownerDocument: document } = element;
78 const LMN = Lemon.bind({ document });
79 if (content == null) {
80 // The provided content is nullish.
81 /* do nothing */
82 } else if (Array.isArray(content)) {
83 // The provided content is an array.
84 content.forEach(addContent.bind(null, element));
85 } else if (Object(content) instanceof String) {
86 // The provided content is a string (object or literal).
87 const { lang } = content;
88 if (lang && lang != getLanguage(element)) {
89 const newChild = element.appendChild(LMN.span`${content}`);
90 setLanguage(newChild, lang);
91 } else {
92 element.appendChild(document.createTextNode(`${content}`));
93 }
94 } else {
95 // Assume the provided content is a NodeList.
96 if (element.hasAttribute("property")) {
97 // The provided element has an R·D·F∕A property; note that its
98 // datatype is `XMLLiteral`.
99 element.setAttribute("datatype", "XMLLiteral");
100 } else {
101 // The provided element does not have an R·D·F∕A property.
102 /* do nothing */
103 }
104 for (const child of Array.from(content)) {
105 // Iterate over the nodes in the provided NodeList and handle
106 // them appropriately.
107 const lang = getLanguage(child);
108 const newChild = (() => {
109 const imported = document.importNode(child, true);
110 if (lang && lang != getLanguage(element)) {
111 // The imported node’s language would change if it were just
112 // imported directly into the provided element.
113 if (imported.nodeType == 1) {
114 // The imported node is an element node.
115 setLanguage(imported, lang);
116 return imported;
117 } else if (imported.nodeType <= 4) {
118 // The imported node is a text node.
119 const result = LMN.span`${imported}`;
120 setLanguage(result, lang);
121 return result;
122 } else {
123 // The imported node is not an element or text.
124 return imported;
125 }
126 } else {
127 // The imported node’s language will not change if imported
128 // directly into the provided element.
129 return imported;
130 }
131 })();
132 element.appendChild(newChild);
133 }
134 }
135 return element;
136 };
137
138 /**
139 * Adds HTML for the provided people to the provided element, tagging
140 * them with the provided property.
141 *
142 * ☡ As with `addContent`, it is best to attach elements to a document
143 * PRIOR to providing them to this function, for language‐detection
144 * reasons.
145 */
146 const addPeople = (element, people, property) => {
147 const { ownerDocument: document } = element;
148 const LMN = Lemon.bind({ document });
149 const { length } = people;
150 for (const [index, { uri, name }] of people.entries()) {
151 const personElement = element.appendChild(
152 uri
153 ? LMN.a.rel(`${property}`).href(`${uri}`)``
154 : LMN.span.rel(`${property}`)``,
155 );
156 if (name == null) {
157 // The current person has no name; provide its `uri`.
158 personElement.appendChild(LMN.code`${uri}`);
159 } else {
160 // The current person has a name.
161 addContent(
162 personElement.appendChild(
163 LMN.span.property(`${FOAF}name`)``,
164 ),
165 name,
166 );
167 }
168 if (index < length - 2) {
169 // The current person is two or greater from the end.
170 addContent(element, ", ");
171 } else if (index < length - 1) {
172 // The current person is one from the end.
173 addContent(element, " & ");
174 } else {
175 // The current person is the last.
176 /* do nothing */
177 }
178 }
179 return element;
180 };
181
182 /**
183 * Applies the provided metadata to the provided node by creating and
184 * inserting the appropriate elements, then returns the provided node.
185 *
186 * If the provided node is a document, it is assumed to be an entry
187 * template, and full entry H·T·M·L is generated. If it is a document
188 * fragment, it is assumed to be a document fragment collecting entries
189 * for the H·T·M·L feed index page, and entry H·T·M·L links are
190 * generated. Otherwise, the provided node is assumed to be an Atom
191 * element, and Atom metadata elements are generated.
192 */
193 const applyMetadata = (node, metadata) => {
194 if (node.nodeType == 9) {
195 // The provided node is a document.
196 //
197 // Assume it is an entry template document and insert the full
198 // entry H·T·M·L accordingly.
199 const document = node;
200 const { documentElement } = document;
201 if (hasExpandedName(documentElement, XHTML, "html")) {
202 // This is an XHTML template.
203 const LMN = Lemon.bind({ document });
204 const head = Array.from(documentElement.childNodes).find(($) =>
205 hasExpandedName($, XHTML, "head")
206 ) ?? documentElement.insertBefore(
207 LMN.head``,
208 documentElement.childNodes.item(0),
209 );
210 const titleElement = Array.from(head.childNodes).find(($) =>
211 hasExpandedName($, XHTML, "title")
212 ) ?? head.appendChild(LMN.title``);
213 const {
214 id,
215 title,
216 author,
217 summary,
218 contributor,
219 published,
220 content,
221 rights,
222 updated,
223 } = metadata;
224 titleElement.textContent = Object(title) instanceof String
225 ? title
226 : Array.from(title ?? []).map(($) =>
227 $.textContent
228 ).join("");
229 for (const person of author) {
230 // Iterate over authors and add appropriate meta tags.
231 head.appendChild(
232 LMN.meta({ name: "author" })
233 .content(`${person.name ?? person.uri}`)``,
234 );
235 }
236 if (summary) {
237 // The entry has a summary.
238 head.appendChild(
239 LMN.meta({ name: "description" })
240 .content(
241 `${
242 Object(summary) instanceof String
243 ? summary
244 : Array.from(summary).map(($) => $.textContent).join(
245 "",
246 )
247 }`,
248 )``,
249 );
250 } else {
251 /* do nothing */
252 }
253 const contentPlaceholder = document.getElementsByTagNameNS(
254 XHTML,
255 "bjørn-content",
256 ).item(0);
257 if (contentPlaceholder != null) {
258 // The content placeholder exists; replace it with the content
259 // nodes.
260 const { parentNode: contentParent } = contentPlaceholder;
261 const contentElement = contentParent.insertBefore(
262 LMN.article.about(`${id}`)`${"\n"}`,
263 contentPlaceholder,
264 );
265
266 // Handle the entry content header.
267 contentElement.appendChild(
268 document.createComment(" BEGIN ENTRY HEADER "),
269 );
270 addContent(contentElement, "\n");
271 const contentHeader = contentElement.appendChild(
272 LMN.header.id("entry.header")`${"\n\t"}`,
273 );
274 addContent(
275 contentHeader.appendChild(
276 LMN.h1.id("entry.title").property(`${DC11}title`)``,
277 ),
278 title,
279 );
280 if (author.length > 0) {
281 // This entry has authors.
282 addContent(contentHeader, "\n\t");
283 addPeople(
284 contentHeader.appendChild(
285 LMN.p.id("entry.author")``,
286 ),
287 author,
288 `${DC11}creator`,
289 );
290 } else {
291 // This entry does not have authors.
292 /* do nothing */
293 }
294 if (contributor.length > 0) {
295 // This entry has contributors.
296 addContent(contentHeader, "\n\t");
297 addContent(
298 addPeople(
299 contentHeader.appendChild(
300 LMN.p.id(
301 "entry.contributor",
302 )`With contributions from `,
303 ),
304 contributor,
305 `${DC11}contributor`,
306 ),
307 ".",
308 );
309 } else {
310 // This entry does not have contributors.
311 /* do nothing */
312 }
313 if (published) {
314 // This entry has a publication date.
315 addContent(contentHeader, "\n\t");
316 contentHeader.appendChild(
317 LMN.p.id("entry.published")`Published: ${LMN.time.property(
318 `${DC11}date`,
319 )`${published}`}.`,
320 );
321 } else {
322 // This entry does not have a publication date.
323 /* do nothing */
324 }
325 addContent(contentHeader, "\n");
326 addContent(contentElement, "\n");
327 contentElement.appendChild(
328 document.createComment(" END ENTRY HEADER "),
329 );
330 addContent(contentElement, "\n");
331
332 // Handle the entry content.
333 contentElement.appendChild(
334 document.createComment(" BEGIN ENTRY CONTENT "),
335 );
336 addContent(contentElement, "\n");
337 addContent(
338 contentElement.appendChild(
339 LMN.div.id("entry.content").property(`${SIOC}content`)``,
340 ),
341 content,
342 );
343 addContent(contentElement, "\n");
344 contentElement.appendChild(
345 document.createComment(" END ENTRY CONTENT "),
346 );
347 addContent(contentElement, "\n");
348
349 // Handle the entry content footer.
350 contentElement.appendChild(
351 document.createComment(" BEGIN ENTRY FOOTER "),
352 );
353 addContent(contentElement, "\n");
354 const contentFooter = contentElement.appendChild(
355 LMN.footer.id("entry.footer")`${"\n\t"}`,
356 );
357 if (rights) {
358 addContent(
359 contentFooter.appendChild(
360 LMN.div.id("entry.rights").property(`${DC11}rights`)``,
361 ),
362 rights,
363 );
364 addContent(contentFooter, "\n\t");
365 }
366 contentFooter.appendChild(
367 LMN.p.id("entry.updated")`Last updated: ${LMN.time.property(
368 `${AWOL}updated`,
369 )`${updated}`}.`,
370 );
371 addContent(contentFooter, "\n");
372 addContent(contentElement, "\n");
373 contentElement.appendChild(
374 document.createComment(" END ENTRY FOOTER "),
375 );
376 addContent(contentElement, "\n");
377
378 // Remove the placeholder.
379 contentParent.removeChild(contentPlaceholder);
380 } else {
381 // There is no content placeholder.
382 /* do nothing */
383 }
384 } else {
385 // This is not an XHTML template.
386 /* do nothing */
387 }
388 } else if (node.nodeType == 11) {
389 // The provided node is a document fragment.
390 //
391 // Assume it is collecting H·T·M·L feed entry links and insert a
392 // new one for the provided metadata.
393 const { ownerDocument: document } = node;
394 const LMN = Lemon.bind({ document });
395 const {
396 id,
397 title,
398 author,
399 published,
400 summary,
401 } = metadata;
402 // The content placeholder exists; replace it with the content
403 // nodes.
404 node.appendChild(
405 document.createComment(` <${id}> `),
406 );
407 addContent(node, "\n");
408 const contentElement = node.appendChild(
409 LMN.li.resource(`${id}`)`${"\n"}`,
410 );
411 addContent(
412 contentElement.appendChild(
413 LMN.a.href(`${id}`)``,
414 ).appendChild(
415 LMN.h3.property(`${DC11}title`)``,
416 ),
417 title,
418 );
419 if (author.length > 0) {
420 // This entry has authors.
421 addContent(contentElement, "\n");
422 addPeople(
423 contentElement.appendChild(
424 LMN.p``,
425 ),
426 author,
427 `${DC11}creator`,
428 );
429 } else {
430 // This entry does not have authors.
431 /* do nothing */
432 }
433 if (published) {
434 // This entry has a publication date.
435 addContent(contentElement, "\n");
436 contentElement.appendChild(
437 LMN.time.property(`${DC11}date`)`${published}`,
438 );
439 } else {
440 // This entry does not have a publication date.
441 /* do nothing */
442 }
443 addContent(contentElement, "\n");
444 addContent(
445 contentElement.appendChild(
446 LMN.div.property(`${DC11}abstract`)``,
447 ),
448 summary,
449 );
450 addContent(contentElement, "\n");
451 addContent(node, "\n");
452 } else {
453 // The provided node is not a document or document fragment.
454 //
455 // Assume it is an Atom element of some sort and add the
456 // the appropriate metadata as child elements.
457 const { ownerDocument: document } = node;
458 for (const [property, values] of Object.entries(metadata)) {
459 for (const value of Array.isArray(values) ? values : [values]) {
460 const propertyNode = document.createElement(property);
461 switch (context[property]?.type) {
462 case "person": {
463 // The property describes a person.
464 const { name, uri } = value;
465 if (uri) {
466 // The person has a U·R·I.
467 const subnode = document.createElement("uri");
468 subnode.textContent = uri;
469 propertyNode.appendChild(subnode);
470 } else {
471 // The person does not have a U·R·I.
472 /* do nothing */
473 }
474 if (name != null) {
475 // The person has a name.
476 const subnode = document.createElement("name");
477 subnode.textContent = name;
478 propertyNode.appendChild(subnode);
479 } else {
480 // The person does not have a name.
481 /* do nothing */
482 }
483 if (propertyNode.childNodes.length == 0) {
484 // Neither a U·R·I nor a name was added; skip adding this
485 // property.
486 continue;
487 } else {
488 break;
489 }
490 }
491 default: {
492 // The property describes (potentially rich) text.
493 if (value == null) {
494 // The property has no value; skip appending it to the node.
495 continue;
496 } else if (Object(value) instanceof String) {
497 // The property has a string value.
498 propertyNode.textContent = value;
499 break;
500 } else {
501 // The property value is a list of nodes.
502 propertyNode.setAttribute("type", "xhtml");
503 const div = document.createElementNS(XHTML, "div");
504 for (const child of Array.from(value)) {
505 div.appendChild(document.importNode(child, true));
506 }
507 propertyNode.appendChild(div);
508 break;
509 }
510 }
511 }
512 node.appendChild(propertyNode);
513 }
514 }
515 }
516 return node;
517 };
518
519 /**
520 * The base path from which to pull files and generate resulting
521 * documents.
522 */
523 const basePath = `./${Deno.args[0] ?? ""}`;
524
525 /**
526 * Mappings from Atom concepts to R·D·F∕X·M·L ones.
527 *
528 * `namespace` and `localName` give the R·D·F representation for the
529 * concept. Three `type`s are supported :—
530 *
531 * - "person": Has a `name` (`foaf:name`) and an `iri` (`rdf:about`).
532 * Emails are NOT supported.
533 *
534 * - "text": Can be string, markdown, or X·M·L content.
535 *
536 * - "literal": This is a plaintext field.
537 */
538 const context = {
539 author: { namespace: DC11, localName: "creator", type: "person" },
540 // category is not supported at this time
541 content: { namespace: SIOC, localName: "content", type: "text" },
542 contributor: {
543 namespace: DC11,
544 localName: "contributor",
545 type: "person",
546 },
547 // generator is provided by the build script
548 icon: { namespace: AWOL, localName: "icon", type: "literal" },
549 // link is provided by the build script
550 logo: { namespace: AWOL, localName: "logo", type: "literal" },
551 published: {
552 namespace: DC11,
553 localName: "date",
554 type: "literal",
555 },
556 rights: { namespace: DC11, localName: "rights", type: "text" },
557 // source is not supported at this time
558 // subtitle is not supported at this time
559 summary: { namespace: DC11, localName: "abstract", type: "text" },
560 title: { namespace: DC11, localName: "title", type: "text" },
561 // updated is provided by the build script
562 };
563
564 const {
565 /**
566 * Returns a new document created from the source code of the named
567 * template.
568 */
569 documentFromTemplate,
570 } = (() => {
571 const cache = Object.create(null);
572 return {
573 documentFromTemplate: async (name) =>
574 parser.parseFromString(
575 name in cache ? cache[name] : (
576 cache[name] = await Deno.readTextFile(
577 `${basePath}/index#${name}.xhtml`,
578 )
579 ),
580 "application/xml",
581 ),
582 };
583 })();
584
585 /**
586 * Returns the language of the provided node, or null if it has no
587 * language.
588 *
589 * ※ This function returns null regardless of whether a node has no
590 * language implicitly (because no parent element had a language set)
591 * or explicitly (because the value of the @xml:lang attribute was "").
592 *
593 * ※ If no language is set, the language of the node is ambiguous.
594 * Because this typically occurs when a node is expected to inherit its
595 * language from some parent context, this lack of language information
596 * SHOULD NOT be preserved when inserting the node into a document
597 * where language information is present (instead allowing the node to
598 * inherit language information from the node it is being inserted
599 * into). There are explicit language codes which may be used if this
600 * behaviour is undesirable: If you want to signal that a node does not
601 * contain linguistic content, use the language code `zxx`. If you want
602 * to signal that a node’s language is undetermined in a way which will
603 * never inherit from a parent node, use the language code `und`.
604 */
605 const getLanguage = (node) => {
606 const { nodeType } = node;
607 if (nodeType == 1) {
608 // The provided node is an element.
609 const ownLanguage =
610 node.namespaceURI == XHTML && node.hasAttribute("lang") &&
611 node.getAttribute("lang") ||
612 node.hasAttributeNS(XML, "lang") &&
613 node.getAttributeNS(XML, "lang");
614 if (ownLanguage) {
615 // The element has a recognized language attribute set to a
616 // nonempty value.
617 return ownLanguage;
618 } else if (ownLanguage === "") {
619 // The element explicitly has no language.
620 return null;
621 } else {
622 // The element has no language attribute, but may inherit a
623 // language from its parent.
624 const { parentNode } = node;
625 if (parentNode != null && parentNode.nodeType != 9) {
626 // The provided node has a nondocument parent; get the language
627 // from there.
628 return getLanguage(parentNode);
629 } else {
630 // The provided node has no parent and consequently no language.
631 return null;
632 }
633 }
634 } else if (nodeType == 9) {
635 // The provided node is a document.
636 return getLanguage(node.documentElement);
637 } else if (nodeType == 11) {
638 // The provided node is a document fragment.
639 return getLanguage(node.ownerDocument.documentElement);
640 } else {
641 // The provided node may inherit a language from a parent node.
642 const { parentNode } = node;
643 if (parentNode != null) {
644 // The provided node has a parent; get the language from there.
645 return getLanguage(parentNode);
646 } else {
647 // The provided node has no parent and consequently no language.
648 return null;
649 }
650 }
651 };
652
653 /**
654 * Returns whether the provided node has the provided namespace and
655 * local name.
656 */
657 const hasExpandedName = (node, namespace, localName) =>
658 node.namespaceURI == namespace && node.localName == localName;
659
660 /**
661 * Processes an RDF document and returns an object of Atom metadata.
662 *
663 * See `context` for the metadata properties supported.
664 */
665 const metadataFromDocument = (
666 { documentElement: root, lastModified },
667 ) => {
668 const contextEntries = [...Object.entries(context)];
669 const documentType = hasExpandedName(root, AWOL, "Feed")
670 ? "feed"
671 : "entry";
672 const result = Object.assign(Object.create(null), {
673 id: root.getAttributeNS(RDF, "about"),
674 updated: documentType == "feed" || lastModified == null
675 ? new Date().toISOString()
676 : lastModified.toISOString(),
677 title: null,
678 author: [],
679 contributor: [],
680 rights: null,
681 ...(documentType == "feed"
682 ? { // additional feed properties
683 subtitle: null,
684 logo: null,
685 icon: null,
686 }
687 : { // additional entry properties
688 published: null,
689 summary: null,
690 content: null,
691 }),
692 });
693 for (
694 const node of [
695 ...Array.from(root.attributes),
696 ...Array.from(root.childNodes),
697 ]
698 ) {
699 // Iterate over all child nodes and attributes, finding ones which
700 // correspond to Atom properties and assigning the appropriate
701 // metadata.
702 const [name, { type }] = contextEntries.find(
703 ([_, value]) =>
704 hasExpandedName(node, value.namespace, value.localName),
705 ) ?? [, {}];
706 if (name != null && name in result) {
707 // The current node corresponds with an Atom property.
708 const { [name]: existing } = result;
709 const content = (() => {
710 switch (type) {
711 case "person": {
712 // The node describes a person.
713 const base =
714 node.getAttributeNS?.(RDF, "parseType") == "Resource"
715 ? node
716 : Array.from(node.childNodes).find(($) =>
717 $.nodeType == 1
718 );
719 const person = {
720 name: null,
721 uri: base.getAttributeNS?.(RDF, "about") || null,
722 };
723 for (
724 const subnode of [
725 ...Array.from(base.attributes),
726 ...Array.from(base.childNodes),
727 ]
728 ) {
729 // Process child nodes and attributes for the current
730 // person, looking for name metadata.
731 if (hasExpandedName(subnode, FOAF, "name")) {
732 // This is a name node.
733 if (person.name == null) {
734 // No name has been set yet.
735 person.name = subnode.textContent;
736 } else {
737 // A name has already been set.
738 throw new TypeError(
739 `Duplicate name found for person${
740 person.id != null ? ` <${person.id}>` : ""
741 } while processing <${result.id}>.`,
742 );
743 }
744 } else {
745 // This is not a name node
746 /* do nothing */
747 }
748 }
749 return person;
750 }
751 case "text": {
752 // The node describes (potentially rich) textual content.
753 //
754 // ☡ Don’t return an Array here or it will look like a list
755 // of multiple values. Return the NodeList of child nodes
756 // directly.
757 const parseType = node.getAttributeNS?.(RDF, "parseType");
758 if (parseType == "Markdown") {
759 // This is an element with Markdown content (which
760 // hopefully can be converted into X·M·L).
761 return parser.parseFromString(
762 `<福 xmlns="${XHTML}" lang="${
763 getLanguage(node) ?? ""
764 }">${
765 markdownTokensToHTML(
766 markdownTokens(node.textContent),
767 )
768 }</福>`,
769 "application/xml",
770 ).documentElement.childNodes;
771 } else if (parseType == "Literal") {
772 // This is an element with literal X·M·L contents.
773 return node.childNodes;
774 } else {
775 // This is an element without literal X·M·L contents.
776 /* do nothing */
777 }
778 } // falls through
779 default: {
780 // The node describes something in plaintext.
781 const text = new String(node.textContent);
782 const lang = getLanguage(node);
783 if (lang) {
784 text.lang = lang;
785 } else {
786 /* do nothing */
787 }
788 return text;
789 }
790 }
791 })();
792 if (existing == null) {
793 // The property takes at most one value, but none has been set.
794 result[name] = content;
795 } else if (Array.isArray(existing)) {
796 // The property takes multiple values.
797 existing.push(content);
798 } else {
799 // The property takes at most one value, and one has already
800 // been set.
801 throw new TypeError(
802 `Duplicate content found for ${name} while processing <${result.id}>.`,
803 );
804 }
805 } else {
806 // The current node does not correspond with an Atom property.
807 /* do nothing */
808 }
809 }
810 return validateMetadata(result, documentType);
811 };
812
813 /** The DOMParser used by this script. */
814 const parser = new DOMParser();
815
816 /** The XMLSerializer used by this script. */
817 const serializer = new XMLSerializer();
818
819 /**
820 * Sets the @xml:lang attribute of the provided element, and if it is
821 * an H·T·M·L element also sets the @lang.
822 */
823 const setLanguage = (element, lang) => {
824 element.setAttributeNS(XML, "xml:lang", lang ?? "");
825 if (element.namespaceURI == XHTML) {
826 element.setAttribute("lang", lang ?? "");
827 } else {
828 /* do nothing */
829 }
830 };
831
832 /**
833 * Throws if the provided metadata does not conform to expectations for
834 * the provided type, and otherwise returns it.
835 */
836 const validateMetadata = (metadata, type) => {
837 if (metadata.id == null) {
838 throw new TypeError("Missing id.");
839 } else if (metadata.title == null) {
840 throw new TypeError(`Missing title for item <${metadata.id}>.`);
841 } else if (type == "feed" && metadata.author == null) {
842 throw new TypeError(`Missing author for feed <${metadata.id}>.`);
843 } else if (type == "entry" && metadata.content == null) {
844 throw new TypeError(`Missing content for entry <${metadata.id}>.`);
845 } else {
846 return metadata;
847 }
848 };
849
850 await (async () => { // this is the run script
851 const writes = [];
852
853 // Set up the Atom feed.
854 const document = parser.parseFromString(
855 `<?xml version="1.0" encoding="utf-8"?>
856 <feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
857 "application/xml",
858 );
859 const { documentElement: feed } = document;
860 const feedMetadata = metadataFromDocument(
861 parser.parseFromString(
862 await Deno.readTextFile(`${basePath}/#feed.rdf`),
863 "application/xml",
864 ),
865 );
866 const feedURI = new URL(feedMetadata.id);
867 applyMetadata(feed, feedMetadata);
868
869 // Set up the index page.
870 const feedTemplate = await documentFromTemplate("feed");
871 const feedEntries = feedTemplate.createDocumentFragment();
872
873 // Process entries and save the resulting index files.
874 for await (
875 const { name: date, isDirectory } of Deno.readDir(
876 `${basePath}/`,
877 )
878 ) {
879 // Iterate over each directory and process the ones which are
880 // dates.
881 if (!isDirectory || !/[0-9]{4}-[0-9]{2}-[0-9]{2}/u.test(date)) {
882 // This isn’t a dated directory.
883 continue;
884 } else {
885 // This is a dated directory.
886 for await (
887 const { name: entryName, isDirectory } of Deno.readDir(
888 `${basePath}/${date}/`,
889 )
890 ) {
891 // Iterate over each directory and process the ones which look
892 // like entries.
893 if (
894 !isDirectory ||
895 //deno-lint-ignore no-control-regex
896 /[\x00-\x20\x22#%/<>?\\^\x60{|}\x7F]/u.test(entryName)
897 ) {
898 // This isn’t an entry directory.
899 continue;
900 } else {
901 // Process the entry.
902 const entry = document.createElement("entry");
903 const entryPath =
904 `${basePath}/${date}/${entryName}/#entry.rdf`;
905 const entryDocument = parser.parseFromString(
906 await Deno.readTextFile(entryPath),
907 "application/xml",
908 );
909 const { documentElement: entryRoot } = entryDocument;
910 entryDocument.lastModified =
911 (await Deno.lstat(entryPath)).mtime;
912 if (!entryRoot.hasAttributeNS(RDF, "about")) {
913 // The entry doesn’t have an identifier; let’s give it one.
914 entryRoot.setAttributeNS(
915 RDF,
916 "about",
917 new URL(`./${date}/${entryName}/`, feedURI),
918 );
919 } else {
920 // The entry already has an identifier.
921 /* do nothing */
922 }
923 const entryMetadata = metadataFromDocument(entryDocument);
924 if (entryMetadata.author.length == 0) {
925 // The entry metadata did not supply an author.
926 entryMetadata.author = feedMetadata.author;
927 } else {
928 // The entry metadata supplied its own author.
929 /* do nothing */
930 }
931 const entryTemplate = await documentFromTemplate("entry");
932 const { documentElement: templateRoot } = entryTemplate;
933 const lang = getLanguage(entryRoot);
934 if (lang && !getLanguage(templateRoot)) {
935 // The root element of the template does not have an
936 // assigned language, but the entry does.
937 setLanguage(templateRoot, lang);
938 } else {
939 // Either the template root already has a language, or the
940 // entry doesn’t either.
941 /* do nothing */
942 }
943 writes.push(
944 Deno.writeTextFile(
945 `${basePath}/${date}/${entryName}/index.xhtml`,
946 serializer.serializeToString(
947 applyMetadata(entryTemplate, entryMetadata),
948 ) + "\n",
949 ),
950 );
951 applyMetadata(entry, entryMetadata);
952 applyMetadata(feedEntries, entryMetadata);
953 feed.appendChild(entry);
954 }
955 }
956 }
957 }
958
959 // Apply the feed metadata to the feed template and save the
960 // resulting index file.
961 const { documentElement: feedRoot } = feedTemplate;
962 if (hasExpandedName(feedRoot, XHTML, "html")) {
963 // This is an XHTML template.
964 const LMN = Lemon.bind({ document: feedTemplate });
965 const head = Array.from(feedRoot.childNodes).find(($) =>
966 hasExpandedName($, XHTML, "head")
967 ) ?? feedRoot.insertBefore(
968 LMN.head``,
969 feedRoot.childNodes.item(0),
970 );
971 const titleElement = Array.from(head.childNodes).find(($) =>
972 hasExpandedName($, XHTML, "title")
973 ) ?? head.appendChild(LMN.title``);
974 const {
975 id,
976 title,
977 author,
978 rights,
979 updated,
980 } = feedMetadata;
981 titleElement.textContent = Object(title) instanceof String
982 ? title
983 : Array.from(title ?? []).map(($) =>
984 $.textContent
985 ).join("");
986 for (const person of author) {
987 // Iterate over authors and add appropriate meta tags.
988 head.appendChild(
989 LMN.meta({ name: "author" })
990 .content(`${person.name ?? person.uri}`)``,
991 );
992 }
993 const contentPlaceholder = feedTemplate.getElementsByTagNameNS(
994 XHTML,
995 "bjørn-content",
996 ).item(0);
997 if (contentPlaceholder != null) {
998 const { parentNode: contentParent } = contentPlaceholder;
999 const contentElement = contentParent.insertBefore(
1000 LMN.nav.about(`${id}`)`${"\n"}`,
1001 contentPlaceholder,
1002 );
1003 const contentHeader = contentElement.appendChild(
1004 LMN.header`${"\n\t"}`,
1005 );
1006 addContent(
1007 contentHeader.appendChild(LMN.h1.property(`${DC11}title`)``),
1008 title,
1009 );
1010 addContent(contentHeader, "\n");
1011 addContent(contentElement, "\n");
1012 const entriesElement = contentElement.appendChild(
1013 LMN.ul.rel(`${AWOL}entry`)`${"\n"}`,
1014 );
1015 entriesElement.appendChild(feedEntries);
1016 addContent(contentElement, "\n");
1017 const contentFooter = contentElement.appendChild(
1018 LMN.footer`${"\n\t"}`,
1019 );
1020 if (rights) {
1021 addContent(
1022 contentFooter.appendChild(
1023 LMN.div.property(`${DC11}rights`)``,
1024 ),
1025 rights,
1026 );
1027 addContent(contentFooter, "\n\t");
1028 }
1029 contentFooter.appendChild(
1030 LMN.p.id("entry.updated")`Last updated: ${LMN.time.property(
1031 `${AWOL}updated`,
1032 )`${updated}`}.`,
1033 );
1034 addContent(contentFooter, "\n");
1035 addContent(contentElement, "\n");
1036 contentParent.removeChild(contentPlaceholder);
1037 } else {
1038 /* do nothing */
1039 }
1040 }
1041 writes.push(
1042 Deno.writeTextFile(
1043 "index.xhtml",
1044 serializer.serializeToString(feedTemplate) + "\n",
1045 ),
1046 );
1047
1048 // Save the feed Atom file.
1049 writes.push(
1050 Deno.writeTextFile(
1051 "feed.atom",
1052 serializer.serializeToString(document) + "\n",
1053 ),
1054 );
1055
1056 // Await all writes.
1057 await Promise.all(writes);
1058 })();
This page took 0.179631 seconds and 5 git commands to generate.