]> Lady’s Gitweb - Beorn/blob - build.js
36953290fd9f0b28a69ea25753ea18e0ffa7e98e
[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 {
205 id,
206 title,
207 author,
208 contributor,
209 published,
210 content,
211 rights,
212 updated,
213 } = metadata;
214 fillOutHead(document, metadata, "entry");
215 const contentPlaceholder = document.getElementsByTagNameNS(
216 XHTML,
217 "bjørn-content",
218 ).item(0);
219 if (contentPlaceholder != null) {
220 // The content placeholder exists; replace it with the content
221 // nodes.
222 const { parentNode: contentParent } = contentPlaceholder;
223 const contentElement = contentParent.insertBefore(
224 LMN.article.about(`${id}`)`${"\n"}`,
225 contentPlaceholder,
226 );
227
228 // Handle the entry content header.
229 contentElement.appendChild(
230 document.createComment(" BEGIN ENTRY HEADER "),
231 );
232 addContent(contentElement, "\n");
233 const contentHeader = contentElement.appendChild(
234 LMN.header.id("entry.header")`${"\n\t"}`,
235 );
236 addContent(
237 contentHeader.appendChild(
238 LMN.h1.id("entry.title").property(`${DC11}title`)``,
239 ),
240 title,
241 );
242 if (author.length > 0) {
243 // This entry has authors.
244 addContent(contentHeader, "\n\t");
245 addPeople(
246 contentHeader.appendChild(
247 LMN.p.id("entry.author")``,
248 ),
249 author,
250 `${DC11}creator`,
251 );
252 } else {
253 // This entry does not have authors.
254 /* do nothing */
255 }
256 if (contributor.length > 0) {
257 // This entry has contributors.
258 addContent(contentHeader, "\n\t");
259 addContent(
260 addPeople(
261 contentHeader.appendChild(
262 LMN.p.id(
263 "entry.contributor",
264 )`With contributions from `,
265 ),
266 contributor,
267 `${DC11}contributor`,
268 ),
269 ".",
270 );
271 } else {
272 // This entry does not have contributors.
273 /* do nothing */
274 }
275 if (published) {
276 // This entry has a publication date.
277 addContent(contentHeader, "\n\t");
278 contentHeader.appendChild(
279 LMN.p.id("entry.published")`Published: ${LMN.time.property(
280 `${DC11}date`,
281 )`${published}`}.`,
282 );
283 } else {
284 // This entry does not have a publication date.
285 /* do nothing */
286 }
287 addContent(contentHeader, "\n");
288 addContent(contentElement, "\n");
289 contentElement.appendChild(
290 document.createComment(" END ENTRY HEADER "),
291 );
292 addContent(contentElement, "\n");
293
294 // Handle the entry content.
295 contentElement.appendChild(
296 document.createComment(" BEGIN ENTRY CONTENT "),
297 );
298 addContent(contentElement, "\n");
299 addContent(
300 contentElement.appendChild(
301 LMN.div.id("entry.content").property(`${SIOC}content`)``,
302 ),
303 content,
304 );
305 addContent(contentElement, "\n");
306 contentElement.appendChild(
307 document.createComment(" END ENTRY CONTENT "),
308 );
309 addContent(contentElement, "\n");
310
311 // Handle the entry content footer.
312 contentElement.appendChild(
313 document.createComment(" BEGIN ENTRY FOOTER "),
314 );
315 addContent(contentElement, "\n");
316 const contentFooter = contentElement.appendChild(
317 LMN.footer.id("entry.footer")`${"\n\t"}`,
318 );
319 if (rights) {
320 addContent(
321 contentFooter.appendChild(
322 LMN.div.id("entry.rights").property(`${DC11}rights`)``,
323 ),
324 rights,
325 );
326 addContent(contentFooter, "\n\t");
327 }
328 contentFooter.appendChild(
329 LMN.p.id("entry.updated")`Last updated: ${LMN.time.property(
330 `${AWOL}updated`,
331 )`${updated}`}.`,
332 );
333 addContent(contentFooter, "\n");
334 addContent(contentElement, "\n");
335 contentElement.appendChild(
336 document.createComment(" END ENTRY FOOTER "),
337 );
338 addContent(contentElement, "\n");
339
340 // Remove the placeholder.
341 contentParent.removeChild(contentPlaceholder);
342 } else {
343 // There is no content placeholder.
344 /* do nothing */
345 }
346 } else {
347 // This is not an XHTML template.
348 /* do nothing */
349 }
350 } else if (node.nodeType == 11) {
351 // The provided node is a document fragment.
352 //
353 // Assume it is collecting H·T·M·L feed entry links and insert a
354 // new one for the provided metadata.
355 const { ownerDocument: document } = node;
356 const LMN = Lemon.bind({ document });
357 const {
358 id,
359 title,
360 author,
361 published,
362 summary,
363 } = metadata;
364 // The content placeholder exists; replace it with the content
365 // nodes.
366 node.appendChild(
367 document.createComment(` <${id}> `),
368 );
369 addContent(node, "\n");
370 const contentElement = node.appendChild(
371 LMN.li.resource(`${id}`)`${"\n"}`,
372 );
373 addContent(
374 contentElement.appendChild(
375 LMN.a.href(`${id}`)``,
376 ).appendChild(
377 LMN.h3.property(`${DC11}title`)``,
378 ),
379 title,
380 );
381 if (author.length > 0) {
382 // This entry has authors.
383 addContent(contentElement, "\n");
384 addPeople(
385 contentElement.appendChild(
386 LMN.p``,
387 ),
388 author,
389 `${DC11}creator`,
390 );
391 } else {
392 // This entry does not have authors.
393 /* do nothing */
394 }
395 if (published) {
396 // This entry has a publication date.
397 addContent(contentElement, "\n");
398 contentElement.appendChild(
399 LMN.time.property(`${DC11}date`)`${published}`,
400 );
401 } else {
402 // This entry does not have a publication date.
403 /* do nothing */
404 }
405 addContent(contentElement, "\n");
406 addContent(
407 contentElement.appendChild(
408 LMN.div.property(`${DC11}abstract`)``,
409 ),
410 summary,
411 );
412 addContent(contentElement, "\n");
413 addContent(node, "\n");
414 } else {
415 // The provided node is not a document or document fragment.
416 //
417 // Assume it is an Atom element of some sort and add the
418 // the appropriate metadata as child elements.
419 const { ownerDocument: document } = node;
420 const alternateLink = node.appendChild(
421 document.createElement("link"),
422 );
423 alternateLink.setAttribute("rel", "alternate");
424 alternateLink.setAttribute("type", "application/xhtml+xml");
425 alternateLink.setAttribute("href", metadata.id);
426 for (const [property, values] of Object.entries(metadata)) {
427 for (const value of Array.isArray(values) ? values : [values]) {
428 const propertyNode = document.createElement(property);
429 switch (context[property]?.type) {
430 case "person": {
431 // The property describes a person.
432 const { name, uri } = value;
433 if (uri) {
434 // The person has a U·R·I.
435 const subnode = document.createElement("uri");
436 subnode.textContent = uri;
437 propertyNode.appendChild(subnode);
438 } else {
439 // The person does not have a U·R·I.
440 /* do nothing */
441 }
442 if (name != null) {
443 // The person has a name.
444 const subnode = document.createElement("name");
445 subnode.textContent = name;
446 propertyNode.appendChild(subnode);
447 } else {
448 // The person does not have a name.
449 /* do nothing */
450 }
451 if (propertyNode.childNodes.length == 0) {
452 // Neither a U·R·I nor a name was added; skip adding this
453 // property.
454 continue;
455 } else {
456 break;
457 }
458 }
459 default: {
460 // The property describes (potentially rich) text.
461 if (value == null) {
462 // The property has no value; skip appending it to the node.
463 continue;
464 } else if (Object(value) instanceof String) {
465 // The property has a string value.
466 propertyNode.textContent = value;
467 break;
468 } else {
469 // The property value is a list of nodes.
470 propertyNode.setAttribute("type", "xhtml");
471 const div = document.createElementNS(XHTML, "div");
472 for (const child of Array.from(value)) {
473 div.appendChild(document.importNode(child, true));
474 }
475 propertyNode.appendChild(div);
476 break;
477 }
478 }
479 }
480 node.appendChild(propertyNode);
481 }
482 }
483 }
484 return node;
485 };
486
487 /**
488 * The base path from which to pull files and generate resulting
489 * documents.
490 */
491 const basePath = `./${Deno.args[0] ?? ""}`;
492
493 /**
494 * Mappings from Atom concepts to R·D·F∕X·M·L ones.
495 *
496 * `namespace` and `localName` give the R·D·F representation for the
497 * concept. Three `type`s are supported :—
498 *
499 * - "person": Has a `name` (`foaf:name`) and an `iri` (`rdf:about`).
500 * Emails are NOT supported.
501 *
502 * - "text": Can be string, markdown, or X·M·L content.
503 *
504 * - "literal": This is a plaintext field.
505 */
506 const context = {
507 author: { namespace: DC11, localName: "creator", type: "person" },
508 // category is not supported at this time
509 content: { namespace: SIOC, localName: "content", type: "text" },
510 contributor: {
511 namespace: DC11,
512 localName: "contributor",
513 type: "person",
514 },
515 // generator is provided by the build script
516 icon: { namespace: AWOL, localName: "icon", type: "literal" },
517 // link is provided by the build script
518 logo: { namespace: AWOL, localName: "logo", type: "literal" },
519 published: {
520 namespace: DC11,
521 localName: "date",
522 type: "literal",
523 },
524 rights: { namespace: DC11, localName: "rights", type: "text" },
525 // source is not supported at this time
526 // subtitle is not supported at this time
527 summary: { namespace: DC11, localName: "abstract", type: "text" },
528 title: { namespace: DC11, localName: "title", type: "text" },
529 // updated is provided by the build script
530 };
531
532 const {
533 /**
534 * Returns a new document created from the source code of the named
535 * template.
536 */
537 documentFromTemplate,
538 } = (() => {
539 const cache = Object.create(null);
540 return {
541 documentFromTemplate: async (name) =>
542 parser.parseFromString(
543 name in cache ? cache[name] : (
544 cache[name] = await Deno.readTextFile(
545 `${basePath}/index#${name}.xhtml`,
546 )
547 ),
548 "application/xml",
549 ),
550 };
551 })();
552
553 /**
554 * Fills out the `head` of the provided H·T·M·L document with the
555 * appropriate metadata.
556 */
557 const fillOutHead = (document, metadata, type) => {
558 const { documentElement } = document;
559 const LMN = Lemon.bind({ document });
560 const head =
561 Array.from(documentElement.childNodes).find(($) =>
562 hasExpandedName($, XHTML, "head")
563 ) ?? documentElement.insertBefore(
564 LMN.head``,
565 documentElement.childNodes.item(0),
566 );
567 const titleElement =
568 Array.from(head.childNodes).find(($) =>
569 hasExpandedName($, XHTML, "title")
570 ) ?? head.appendChild(LMN.title``);
571 const {
572 title,
573 author,
574 summary,
575 } = metadata;
576 titleElement.textContent = Object(title) instanceof String
577 ? title
578 : Array.from(title ?? []).map(($) => $.textContent).join("");
579 for (const person of author) {
580 // Iterate over authors and add appropriate meta tags.
581 head.appendChild(
582 LMN.meta({
583 name: "author",
584 content: person.name ?? person.uri,
585 })``,
586 );
587 }
588 head.appendChild(
589 LMN.meta({ name: "generator", content: "🧸📔 Bjørn" })``,
590 );
591 if (type == "entry") {
592 // The provided document is an entry document.
593 if (summary) {
594 // The entry has a summary.
595 head.appendChild(
596 LMN.meta({
597 name: "description",
598 content: Object(summary) instanceof String
599 ? summary
600 : Array.from(summary).map(($) => $.textContent).join(""),
601 })``,
602 );
603 } else {
604 /* do nothing */
605 }
606 head.appendChild(
607 LMN.link
608 .rel("alternate")
609 .type("application/atom+xml")
610 .href("../../feed.atom")``,
611 );
612 } else {
613 head.appendChild(
614 LMN.link
615 .rel("alternate")
616 .type("application/atom+xml")
617 .href("./feed.atom")``,
618 );
619 }
620 };
621
622 /**
623 * Returns the language of the provided node, or null if it has no
624 * language.
625 *
626 * ※ This function returns null regardless of whether a node has no
627 * language implicitly (because no parent element had a language set)
628 * or explicitly (because the value of the @xml:lang attribute was "").
629 *
630 * ※ If no language is set, the language of the node is ambiguous.
631 * Because this typically occurs when a node is expected to inherit its
632 * language from some parent context, this lack of language information
633 * SHOULD NOT be preserved when inserting the node into a document
634 * where language information is present (instead allowing the node to
635 * inherit language information from the node it is being inserted
636 * into). There are explicit language codes which may be used if this
637 * behaviour is undesirable: If you want to signal that a node does not
638 * contain linguistic content, use the language code `zxx`. If you want
639 * to signal that a node’s language is undetermined in a way which will
640 * never inherit from a parent node, use the language code `und`.
641 */
642 const getLanguage = (node) => {
643 const { nodeType } = node;
644 if (nodeType == 1) {
645 // The provided node is an element.
646 const ownLanguage =
647 node.namespaceURI == XHTML && node.hasAttribute("lang") &&
648 node.getAttribute("lang") ||
649 node.hasAttributeNS(XML, "lang") &&
650 node.getAttributeNS(XML, "lang");
651 if (ownLanguage) {
652 // The element has a recognized language attribute set to a
653 // nonempty value.
654 return ownLanguage;
655 } else if (ownLanguage === "") {
656 // The element explicitly has no language.
657 return null;
658 } else {
659 // The element has no language attribute, but may inherit a
660 // language from its parent.
661 const { parentNode } = node;
662 if (parentNode != null && parentNode.nodeType != 9) {
663 // The provided node has a nondocument parent; get the language
664 // from there.
665 return getLanguage(parentNode);
666 } else {
667 // The provided node has no parent and consequently no language.
668 return null;
669 }
670 }
671 } else if (nodeType == 9) {
672 // The provided node is a document.
673 return getLanguage(node.documentElement);
674 } else if (nodeType == 11) {
675 // The provided node is a document fragment.
676 return getLanguage(node.ownerDocument.documentElement);
677 } else {
678 // The provided node may inherit a language from a parent node.
679 const { parentNode } = node;
680 if (parentNode != null) {
681 // The provided node has a parent; get the language from there.
682 return getLanguage(parentNode);
683 } else {
684 // The provided node has no parent and consequently no language.
685 return null;
686 }
687 }
688 };
689
690 /**
691 * Returns whether the provided node has the provided namespace and
692 * local name.
693 */
694 const hasExpandedName = (node, namespace, localName) =>
695 node.namespaceURI == namespace && node.localName == localName;
696
697 /**
698 * Processes an RDF document and returns an object of Atom metadata.
699 *
700 * See `context` for the metadata properties supported.
701 */
702 const metadataFromDocument = (
703 { documentElement: root, lastModified },
704 ) => {
705 const contextEntries = [...Object.entries(context)];
706 const documentType = hasExpandedName(root, AWOL, "Feed")
707 ? "feed"
708 : "entry";
709 const result = Object.assign(Object.create(null), {
710 id: root.getAttributeNS(RDF, "about"),
711 updated: documentType == "feed" || lastModified == null
712 ? new Date().toISOString()
713 : lastModified.toISOString(),
714 title: null,
715 author: [],
716 contributor: [],
717 rights: null,
718 ...(documentType == "feed"
719 ? { // additional feed properties
720 subtitle: null,
721 logo: null,
722 icon: null,
723 }
724 : { // additional entry properties
725 published: null,
726 summary: null,
727 content: null,
728 }),
729 });
730 for (
731 const node of [
732 ...Array.from(root.attributes),
733 ...Array.from(root.childNodes),
734 ]
735 ) {
736 // Iterate over all child nodes and attributes, finding ones which
737 // correspond to Atom properties and assigning the appropriate
738 // metadata.
739 const [name, { type }] = contextEntries.find(
740 ([_, value]) =>
741 hasExpandedName(node, value.namespace, value.localName),
742 ) ?? [, {}];
743 if (name != null && name in result) {
744 // The current node corresponds with an Atom property.
745 const { [name]: existing } = result;
746 const content = (() => {
747 switch (type) {
748 case "person": {
749 // The node describes a person.
750 const base =
751 node.getAttributeNS?.(RDF, "parseType") == "Resource"
752 ? node
753 : Array.from(node.childNodes).find(($) =>
754 $.nodeType == 1
755 );
756 const person = {
757 name: null,
758 uri: base.getAttributeNS?.(RDF, "about") || null,
759 };
760 for (
761 const subnode of [
762 ...Array.from(base.attributes),
763 ...Array.from(base.childNodes),
764 ]
765 ) {
766 // Process child nodes and attributes for the current
767 // person, looking for name metadata.
768 if (hasExpandedName(subnode, FOAF, "name")) {
769 // This is a name node.
770 if (person.name == null) {
771 // No name has been set yet.
772 person.name = subnode.textContent;
773 } else {
774 // A name has already been set.
775 throw new TypeError(
776 `Duplicate name found for person${
777 person.id != null ? ` <${person.id}>` : ""
778 } while processing <${result.id}>.`,
779 );
780 }
781 } else {
782 // This is not a name node
783 /* do nothing */
784 }
785 }
786 return person;
787 }
788 case "text": {
789 // The node describes (potentially rich) textual content.
790 //
791 // ☡ Don’t return an Array here or it will look like a list
792 // of multiple values. Return the NodeList of child nodes
793 // directly.
794 const parseType = node.getAttributeNS?.(RDF, "parseType");
795 if (parseType == "Markdown") {
796 // This is an element with Markdown content (which
797 // hopefully can be converted into X·M·L).
798 return parser.parseFromString(
799 `<福 xmlns="${XHTML}" lang="${
800 getLanguage(node) ?? ""
801 }">${
802 markdownTokensToHTML(
803 markdownTokens(node.textContent),
804 )
805 }</福>`,
806 "application/xml",
807 ).documentElement.childNodes;
808 } else if (parseType == "Literal") {
809 // This is an element with literal X·M·L contents.
810 return node.childNodes;
811 } else {
812 // This is an element without literal X·M·L contents.
813 /* do nothing */
814 }
815 } // falls through
816 default: {
817 // The node describes something in plaintext.
818 const text = new String(node.textContent);
819 const lang = getLanguage(node);
820 if (lang) {
821 text.lang = lang;
822 } else {
823 /* do nothing */
824 }
825 return text;
826 }
827 }
828 })();
829 if (existing == null) {
830 // The property takes at most one value, but none has been set.
831 result[name] = content;
832 } else if (Array.isArray(existing)) {
833 // The property takes multiple values.
834 existing.push(content);
835 } else {
836 // The property takes at most one value, and one has already
837 // been set.
838 throw new TypeError(
839 `Duplicate content found for ${name} while processing <${result.id}>.`,
840 );
841 }
842 } else {
843 // The current node does not correspond with an Atom property.
844 /* do nothing */
845 }
846 }
847 return validateMetadata(result, documentType);
848 };
849
850 /** The DOMParser used by this script. */
851 const parser = new DOMParser();
852
853 /** The XMLSerializer used by this script. */
854 const serializer = new XMLSerializer();
855
856 /**
857 * Sets the @xml:lang attribute of the provided element, and if it is
858 * an H·T·M·L element also sets the @lang.
859 */
860 const setLanguage = (element, lang) => {
861 element.setAttributeNS(XML, "xml:lang", lang ?? "");
862 if (element.namespaceURI == XHTML) {
863 element.setAttribute("lang", lang ?? "");
864 } else {
865 /* do nothing */
866 }
867 };
868
869 /**
870 * Throws if the provided metadata does not conform to expectations for
871 * the provided type, and otherwise returns it.
872 */
873 const validateMetadata = (metadata, type) => {
874 if (metadata.id == null) {
875 throw new TypeError("Missing id.");
876 } else if (metadata.title == null) {
877 throw new TypeError(`Missing title for item <${metadata.id}>.`);
878 } else if (type == "feed" && metadata.author == null) {
879 throw new TypeError(`Missing author for feed <${metadata.id}>.`);
880 } else if (type == "entry" && metadata.content == null) {
881 throw new TypeError(`Missing content for entry <${metadata.id}>.`);
882 } else {
883 return metadata;
884 }
885 };
886
887 await (async () => { // this is the run script
888 const writes = [];
889
890 // Set up the feed metadata and Atom feed document.
891 const feedMetadata = metadataFromDocument(
892 parser.parseFromString(
893 await Deno.readTextFile(`${basePath}/#feed.rdf`),
894 "application/xml",
895 ),
896 );
897 const feedURI = new URL(feedMetadata.id);
898 const document = parser.parseFromString(
899 `<?xml version="1.0" encoding="utf-8"?>
900 <feed xmlns="http://www.w3.org/2005/Atom"><generator>🧸📔 Bjørn</generator><link rel="self" type="application/atom+xml" href="${new URL(
901 "./feed.atom",
902 feedURI,
903 )}"/></feed>`,
904 "application/xml",
905 );
906 const { documentElement: feed } = document;
907 applyMetadata(feed, feedMetadata);
908
909 // Set up the index page.
910 const feedTemplate = await documentFromTemplate("feed");
911 const feedEntries = feedTemplate.createDocumentFragment();
912
913 // Process entries and save the resulting index files.
914 for await (
915 const { name: date, isDirectory } of Deno.readDir(
916 `${basePath}/`,
917 )
918 ) {
919 // Iterate over each directory and process the ones which are
920 // dates.
921 if (!isDirectory || !/[0-9]{4}-[0-9]{2}-[0-9]{2}/u.test(date)) {
922 // This isn’t a dated directory.
923 continue;
924 } else {
925 // This is a dated directory.
926 for (
927 const { name: entryName, isDirectory } of Array.from(
928 Deno.readDirSync(`${basePath}/${date}/`),
929 ).sort(({ name: a }, { name: b }) =>
930 a < b ? -1 : a > b ? 1 : 0
931 )
932 ) {
933 // Iterate over each directory and process the ones which look
934 // like entries.
935 if (
936 !isDirectory ||
937 //deno-lint-ignore no-control-regex
938 /[\x00-\x20\x22#%/<>?\\^\x60{|}\x7F]/u.test(entryName)
939 ) {
940 // This isn’t an entry directory.
941 continue;
942 } else {
943 // Process the entry.
944 const entry = document.createElement("entry");
945 const entryPath =
946 `${basePath}/${date}/${entryName}/#entry.rdf`;
947 const entryDocument = parser.parseFromString(
948 await Deno.readTextFile(entryPath),
949 "application/xml",
950 );
951 const { documentElement: entryRoot } = entryDocument;
952 entryDocument.lastModified =
953 (await Deno.lstat(entryPath)).mtime;
954 if (!entryRoot.hasAttributeNS(RDF, "about")) {
955 // The entry doesn’t have an identifier; let’s give it one.
956 entryRoot.setAttributeNS(
957 RDF,
958 "about",
959 new URL(`./${date}/${entryName}/`, feedURI),
960 );
961 } else {
962 // The entry already has an identifier.
963 /* do nothing */
964 }
965 const entryMetadata = metadataFromDocument(entryDocument);
966 if (entryMetadata.author.length == 0) {
967 // The entry metadata did not supply an author.
968 entryMetadata.author = feedMetadata.author;
969 } else {
970 // The entry metadata supplied its own author.
971 /* do nothing */
972 }
973 const entryTemplate = await documentFromTemplate("entry");
974 const { documentElement: templateRoot } = entryTemplate;
975 const lang = getLanguage(entryRoot);
976 if (lang && !getLanguage(templateRoot)) {
977 // The root element of the template does not have an
978 // assigned language, but the entry does.
979 setLanguage(templateRoot, lang);
980 } else {
981 // Either the template root already has a language, or the
982 // entry doesn’t either.
983 /* do nothing */
984 }
985 writes.push(
986 Deno.writeTextFile(
987 `${basePath}/${date}/${entryName}/index.xhtml`,
988 serializer.serializeToString(
989 applyMetadata(entryTemplate, entryMetadata),
990 ) + "\n",
991 ),
992 );
993 applyMetadata(entry, entryMetadata);
994 applyMetadata(feedEntries, entryMetadata);
995 feed.appendChild(entry);
996 }
997 }
998 }
999 }
1000
1001 // Apply the feed metadata to the feed template and save the
1002 // resulting index file.
1003 const { documentElement: feedRoot } = feedTemplate;
1004 if (hasExpandedName(feedRoot, XHTML, "html")) {
1005 // This is an XHTML template.
1006 const LMN = Lemon.bind({ document: feedTemplate });
1007 const {
1008 id,
1009 title,
1010 rights,
1011 updated,
1012 } = feedMetadata;
1013 fillOutHead(feedTemplate, feedMetadata, "feed");
1014 const contentPlaceholder = feedTemplate.getElementsByTagNameNS(
1015 XHTML,
1016 "bjørn-content",
1017 ).item(0);
1018 if (contentPlaceholder != null) {
1019 const { parentNode: contentParent } = contentPlaceholder;
1020 const contentElement = contentParent.insertBefore(
1021 LMN.nav.about(`${id}`)`${"\n"}`,
1022 contentPlaceholder,
1023 );
1024 const contentHeader = contentElement.appendChild(
1025 LMN.header`${"\n\t"}`,
1026 );
1027 addContent(
1028 contentHeader.appendChild(LMN.h1.property(`${DC11}title`)``),
1029 title,
1030 );
1031 addContent(contentHeader, "\n");
1032 addContent(contentElement, "\n");
1033 const entriesElement = contentElement.appendChild(
1034 LMN.ul.rel(`${AWOL}entry`)`${"\n"}`,
1035 );
1036 entriesElement.appendChild(feedEntries);
1037 addContent(contentElement, "\n");
1038 const contentFooter = contentElement.appendChild(
1039 LMN.footer`${"\n\t"}`,
1040 );
1041 if (rights) {
1042 addContent(
1043 contentFooter.appendChild(
1044 LMN.div.property(`${DC11}rights`)``,
1045 ),
1046 rights,
1047 );
1048 addContent(contentFooter, "\n\t");
1049 }
1050 contentFooter.appendChild(
1051 LMN.p.id("entry.updated")`Last updated: ${LMN.time.property(
1052 `${AWOL}updated`,
1053 )`${updated}`}.`,
1054 );
1055 addContent(contentFooter, "\n");
1056 addContent(contentElement, "\n");
1057 contentParent.removeChild(contentPlaceholder);
1058 } else {
1059 /* do nothing */
1060 }
1061 }
1062 writes.push(
1063 Deno.writeTextFile(
1064 "index.xhtml",
1065 serializer.serializeToString(feedTemplate) + "\n",
1066 ),
1067 );
1068
1069 // Save the feed Atom file.
1070 writes.push(
1071 Deno.writeTextFile(
1072 "feed.atom",
1073 serializer.serializeToString(document) + "\n",
1074 ),
1075 );
1076
1077 // Await all writes.
1078 await Promise.all(writes);
1079 })();
This page took 0.199748 seconds and 3 git commands to generate.