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