]> Lady’s Gitweb - GitWikiWeb/blob - build.js
e8b700344c66acd85b81731b1f9324d68bdd3954
[GitWikiWeb] / build.js
1 // 🐙🕸️ GitWikiWeb ∷ build.js
2 // ====================================================================
3 //
4 // Copyright © 2023 Lady [@ Lady’s Computer].
5 //
6 // This Source Code Form is subject to the terms of the Mozilla Public
7 // License, v. 2.0. If a copy of the MPL was not distributed with this
8 // file, You can obtain one at <https://mozilla.org/MPL/2.0/>.
9 //
10 // --------------------------------------------------------------------
11 //
12 // A script for generating static wiki pages from a git repository.
13 //
14 // First, clone this repository to your machine in an accessible
15 // location (for example, `/srv/git/GitWikiWeb`). A bare repository is
16 // fine; customize the templates and stylesheets as you like. Then, use
17 // this file as the post‐receive hook for your wiki as follows :—
18 //
19 // #!/usr/bin/env -S sh
20 // export GITWIKIWEB=/srv/git/GitWikiWeb
21 // git archive --remote=$GITWIKIWEB HEAD build.js \
22 // | tar -xO \
23 // | deno run -A - ~/public/wiki $GITWIKIWEB
24 //
25 // The directory `~/public/wiki` (or whatever you specify as the first
26 // argument to `deno run -A -`) **will be deleted** and a new static
27 // wiki will be generated in its place. This script is not very smart
28 // (yet) and cannot selectively determine which pages will need
29 // updating. It just wipes and regenerates the whole thing.
30 //
31 // This script will make a number of requests to `$GITWIKIWEB` to
32 // download the latest templates, stylesheets, ⁊·c from this
33 // repository. Consequently, it is best that you set it to a repository
34 // you control and can ensure uptime for—ideally one local to the
35 // computer hosting the wiki.
36
37 import {
38 emptyDir,
39 ensureDir,
40 } from "https://deno.land/std@0.196.0/fs/mod.ts";
41 import {
42 JSON_SCHEMA,
43 parse as parseYaml,
44 } from "https://deno.land/std@0.196.0/yaml/mod.ts";
45 import djot from "npm:@djot/djot@0.2.3";
46 import { Parser } from "npm:htmlparser2@9.0.0";
47 import { DomHandler, Element, Text } from "npm:domhandler@5.0.3";
48 import * as domutils from "npm:domutils@3.1.0";
49 import domSerializer from "npm:dom-serializer@2.0.0";
50
51 const DESTINATION = Deno.args[0] ?? "~/public/wiki";
52 const REMOTE = Deno.args[1] ?? "/srv/git/GitWikiWeb";
53
54 const READ_ONLY = {
55 configurable: false,
56 enumerable: true,
57 writable: false,
58 };
59
60 const NIL = Object.preventExtensions(Object.create(null));
61
62 const rawBlock = (strings, ...substitutions) => ({
63 tag: "raw_block",
64 format: "html",
65 text: String.raw(strings, substitutions),
66 });
67 const rawInline = (strings, ...substitutions) => ({
68 tag: "raw_inline",
69 format: "html",
70 text: String.raw(strings, substitutions),
71 });
72 const str = (strings, ...substitutions) => ({
73 tag: "str",
74 text: String.raw(strings, substitutions),
75 });
76
77 const getDOM = (source) => {
78 let result;
79 const handler = new DomHandler((error, dom) => {
80 if (error) {
81 throw new Error("GitWikiWeb: Failed to process DOM.", {
82 cause: error,
83 });
84 } else {
85 result = dom;
86 }
87 });
88 const parser = new Parser(handler);
89 parser.write(source);
90 parser.end();
91 return result;
92 };
93
94 const getRemoteContent = async (pathName) => {
95 const getArchive = new Deno.Command("git", {
96 args: ["archive", `--remote=${REMOTE}`, "HEAD", pathName],
97 stdout: "piped",
98 stderr: "piped",
99 }).spawn();
100 const untar = new Deno.Command("tar", {
101 args: ["-xO"],
102 stdin: "piped",
103 stdout: "piped",
104 stderr: "piped",
105 }).spawn();
106 getArchive.stdout.pipeTo(untar.stdin);
107 const [
108 err1,
109 getArchiveStatus,
110 result,
111 err2,
112 untarStatus,
113 ] = await Promise.allSettled([
114 new Response(getArchive.stderr).text(),
115 getArchive.status,
116 new Response(untar.stdout).text(),
117 new Response(untar.stderr).text(),
118 untar.status,
119 ]).then(logErrorsAndCollectResults);
120 if (err1 || err2) {
121 console.error(err1 + err2);
122 } else {
123 /* do nothing */
124 }
125 if (!getArchiveStatus.success) {
126 throw new Error(
127 `GitWikiWeb: git archive returned nonzero exit code: ${getArchiveStatus.code}.`,
128 );
129 } else if (!untarStatus.success) {
130 throw new Error(
131 `GitWikiWeb: tar returned nonzero exit code: ${untarStatus.code}.`,
132 );
133 } else {
134 return result || "";
135 }
136 };
137
138 const logErrorsAndCollectResults = (results) =>
139 results.map(({ value, reason }) => {
140 if (reason) {
141 console.error(reason);
142 return undefined;
143 } else {
144 return value;
145 }
146 });
147
148 const getReferenceFromPath = (path) =>
149 /Sources\/([A-Z][0-9A-Za-z]*\/[A-Z][0-9A-Za-z]*)\.djot$/u.exec(path)
150 ?.[1]?.replace?.("/", ":");
151
152 const listOfInternalLinks = (references, wrapper = ($) => $) => ({
153 tag: "bullet_list",
154 tight: true,
155 style: "*",
156 children: Array.from(
157 references,
158 (reference) => {
159 const [namespace, pageName] = splitReference(reference);
160 return {
161 tag: "list_item",
162 children: [{
163 tag: "para",
164 children: [wrapper({
165 tag: "link",
166 attributes: {
167 "data-realm": "internal",
168 "data-pagename": pageName,
169 "data-namespace": namespace,
170 },
171 reference,
172 children: [],
173 })],
174 }],
175 };
176 },
177 ),
178 });
179
180 const diffReferences = async (hash, againstHead = false) => {
181 const diff = new Deno.Command("git", {
182 args: [
183 "diff-tree",
184 "-r",
185 "-z",
186 "--name-only",
187 "--no-renames",
188 "--diff-filter=AM",
189 ...(againstHead ? [hash, "HEAD"] : [hash]),
190 ],
191 stdout: "piped",
192 stderr: "piped",
193 }).spawn();
194 const [diffNames] = await Promise.allSettled([
195 new Response(diff.stdout).text(),
196 new Response(diff.stderr).text(),
197 ]).then(logErrorsAndCollectResults);
198 return references(diffNames.split("\0")); // returns an iterable
199 };
200
201 function* references(paths) {
202 for (const path of paths) {
203 const reference = getReferenceFromPath(path);
204 if (reference) {
205 yield reference;
206 } else {
207 /* do nothing */
208 }
209 }
210 }
211
212 const splitReference = (reference) => {
213 const colonIndex = reference.indexOf(":");
214 return [
215 reference.substring(0, colonIndex),
216 reference.substring(colonIndex + 1),
217 ];
218 };
219
220 class GitWikiWebPage {
221 #internalLinks = new Set();
222 #externalLinks = new Map();
223
224 constructor(namespace, name, ast, source, config) {
225 const internalLinks = this.#internalLinks;
226 const externalLinks = this.#externalLinks;
227 const sections = Object.create(null);
228 djot.applyFilter(ast, () => {
229 let titleSoFar = null; // used to collect strs from headings
230 return {
231 doc: {
232 enter: (_) => {},
233 exit: (e) => {
234 const links_section = [];
235 if (internalLinks.size || externalLinks.size) {
236 links_section.push(
237 rawBlock`<footer>`,
238 rawBlock`<nav id="links">`,
239 {
240 tag: "heading",
241 level: 2,
242 children: [str`this page contains links`],
243 },
244 );
245 if (internalLinks.size) {
246 links_section.push(
247 rawBlock`<details open="">`,
248 rawBlock`<summary>on this wiki</summary>`,
249 listOfInternalLinks(internalLinks),
250 rawBlock`</details>`,
251 );
252 } else {
253 /* do nothing */
254 }
255 if (externalLinks.size) {
256 links_section.push(
257 rawBlock`<details open="">`,
258 rawBlock`<summary>elsewhere on the Web</summary>`,
259 {
260 tag: "bullet_list",
261 tight: true,
262 style: "*",
263 children: Array.from(
264 externalLinks,
265 ([destination, text]) => ({
266 tag: "list_item",
267 children: [{
268 tag: "para",
269 children: [{
270 tag: "link",
271 attributes: { "data-realm": "external" },
272 destination,
273 children: text
274 ? [
275 rawInline`<cite>`,
276 str`${text}`,
277 rawInline`</cite>`,
278 ]
279 : [
280 rawInline`<code>`,
281 str`${destination}`,
282 rawInline`</code>`,
283 ],
284 }],
285 }],
286 }),
287 ),
288 },
289 rawBlock`</details>`,
290 );
291 } else {
292 /* do nothing */
293 }
294 links_section.push(
295 rawBlock`</nav>`,
296 rawBlock`</footer>`,
297 );
298 } else {
299 /* do nothing */
300 }
301 e.children.push(...links_section);
302 },
303 },
304 emph: {
305 enter: (_) => {},
306 exit: (e) => {
307 const attributes = e.attributes ?? NIL;
308 const { as } = attributes;
309 if (as) {
310 delete attributes.as;
311 if (
312 as == "b" || as == "cite" || as == "i" || as == "u"
313 ) {
314 return [
315 rawInline`<${as}>`,
316 ...e.children,
317 rawInline`</${as}>`,
318 ];
319 } else {
320 /* do nothing */
321 }
322 } else {
323 /* do nothing */
324 }
325 },
326 },
327 hard_break: {
328 enter: (_) => {
329 if (titleSoFar != null) {
330 titleSoFar += " ";
331 } else {
332 /* do nothing */
333 }
334 },
335 exit: (_) => {},
336 },
337 heading: {
338 enter: (_) => {
339 titleSoFar = "";
340 },
341 exit: (e) => {
342 e.attributes ??= {};
343 const { attributes } = e;
344 attributes.title ??= titleSoFar;
345 titleSoFar = null;
346 },
347 },
348 link: {
349 enter: (e) => {
350 e.attributes ??= {};
351 const { attributes, reference, destination } = e;
352 if (
353 /^(?:[A-Z][0-9A-Za-z]*|[@#])?:(?:[A-Z][0-9A-Za-z]*)?$/u
354 .test(reference ?? "")
355 ) {
356 const [namespacePrefix, pageName] = splitReference(
357 reference,
358 );
359 const expandedNamespace = {
360 "": "Page",
361 "@": "Editor",
362 "#": "Category",
363 }[namespacePrefix] ?? namespacePrefix;
364 const resolvedReference = pageName == ""
365 ? `Namespace:${expandedNamespace}`
366 : `${expandedNamespace}:${pageName}`;
367 this.#internalLinks.add(resolvedReference);
368 e.reference = resolvedReference;
369 attributes["data-realm"] = "internal";
370 attributes["data-pagename"] = pageName;
371 attributes["data-namespace"] = expandedNamespace;
372 } else {
373 attributes["data-realm"] = "external";
374 const remote = destination ??
375 ast.references[reference]?.destination;
376 if (remote) {
377 externalLinks.set(remote, attributes?.title);
378 } else {
379 /* do nothing */
380 }
381 }
382 },
383 },
384 non_breaking_space: {
385 enter: (_) => {
386 if (titleSoFar != null) {
387 titleSoFar += "\xA0";
388 } else {
389 /* do nothing */
390 }
391 },
392 exit: (_) => {},
393 },
394 section: {
395 enter: (_) => {},
396 exit: (e) => {
397 e.attributes ??= {};
398 const { attributes, children } = e;
399 const heading = children.find(({ tag }) =>
400 tag == "heading"
401 );
402 const title = (() => {
403 if (heading?.attributes?.title) {
404 const result = heading.attributes.title;
405 delete heading.attributes.title;
406 return result;
407 } else {
408 return heading.level == 1
409 ? `${namespace}:${name}`
410 : "untitled section";
411 }
412 })();
413 const variantTitles = Object.create(null);
414 for (const attr in attributes) {
415 if (attr.startsWith("v-")) {
416 Object.defineProperty(
417 variantTitles,
418 attr.substring(2),
419 { ...READ_ONLY, value: attributes[attr] },
420 );
421 delete attributes[attr];
422 } else {
423 continue;
424 }
425 }
426 const definition = Object.create(null, {
427 title: { ...READ_ONLY, value: title },
428 variantTitles: {
429 ...READ_ONLY,
430 value: Object.preventExtensions(variantTitles),
431 },
432 });
433 if (heading.level == 1 && !("main" in sections)) {
434 attributes.id = "main";
435 heading.attributes ??= {};
436 heading.attributes.class = "main";
437 } else {
438 /* do nothing */
439 }
440 try {
441 Object.defineProperty(
442 sections,
443 attributes.id,
444 {
445 ...READ_ONLY,
446 writable: true,
447 value: Object.preventExtensions(definition),
448 },
449 );
450 } catch (cause) {
451 throw new Error(
452 `GitWikiWeb: A section with the provided @id already exists: ${attributes.id}`,
453 { cause },
454 );
455 }
456 },
457 },
458 soft_break: {
459 enter: (_) => {
460 if (titleSoFar != null) {
461 titleSoFar += " ";
462 } else {
463 /* do nothing */
464 }
465 },
466 exit: (_) => {},
467 },
468 str: {
469 enter: ({ text }) => {
470 if (titleSoFar != null) {
471 titleSoFar += text;
472 } else {
473 /* do nothing */
474 }
475 },
476 exit: (_) => {},
477 },
478 symb: {
479 enter: (e) => {
480 const { alias } = e;
481 const codepoint = /^U\+([0-9A-Fa-f]+)$/u.exec(alias)?.[1];
482 if (codepoint) {
483 return str`${
484 String.fromCodePoint(parseInt(codepoint, 16))
485 }`;
486 } else {
487 const resolved = config.symbols?.[alias];
488 return resolved != null ? str`${resolved}` : e;
489 }
490 },
491 },
492 };
493 });
494 Object.defineProperties(this, {
495 ast: { ...READ_ONLY, value: ast },
496 namespace: { ...READ_ONLY, value: namespace },
497 name: { ...READ_ONLY, value: name },
498 sections: {
499 ...READ_ONLY,
500 value: Object.preventExtensions(sections),
501 },
502 source: { ...READ_ONLY, value: source },
503 });
504 }
505
506 *externalLinks() {
507 yield* this.#externalLinks;
508 }
509
510 *internalLinks() {
511 yield* this.#internalLinks;
512 }
513 }
514
515 {
516 const config = await getRemoteContent("config.yaml").then((yaml) =>
517 parseYaml(yaml, { schema: JSON_SCHEMA })
518 );
519 const ls = new Deno.Command("git", {
520 args: ["ls-tree", "-rz", "live"],
521 stdout: "piped",
522 stderr: "piped",
523 }).spawn();
524 const [
525 objects,
526 lserr,
527 lsstatus,
528 ] = await Promise.allSettled([
529 new Response(ls.stdout).text().then((lsout) =>
530 lsout
531 .split("\0")
532 .slice(0, -1) // drop the last entry; it is empty
533 .map(($) => $.split(/\s+/g))
534 ),
535 new Response(ls.stderr).text(),
536 ls.status,
537 ]).then(logErrorsAndCollectResults);
538 if (lserr) {
539 console.error(lserr);
540 } else {
541 /* do nothing */
542 }
543 if (!lsstatus.success) {
544 throw new Error(
545 `GitWikiWeb: git ls-tree returned nonzero exit code: ${lsstatus.code}.`,
546 );
547 } else {
548 const requiredButMissingPages = new Map([
549 ["Special:FrontPage", "front page"],
550 ["Special:NotFound", "not found"],
551 ["Special:RecentlyChanged", "recently changed"],
552 ]);
553 const pages = new Map();
554 const promises = [emptyDir(DESTINATION)];
555 for (const object of objects) {
556 const hash = object[2];
557 const path = object[3];
558 const reference = getReferenceFromPath(path);
559 if (reference == null) {
560 continue;
561 } else {
562 const [namespace, pageName] = splitReference(reference);
563 const cat = new Deno.Command("git", {
564 args: ["cat-file", "blob", hash],
565 stdout: "piped",
566 stderr: "piped",
567 }).spawn();
568 const promise = Promise.allSettled([
569 new Response(cat.stdout).text(),
570 new Response(cat.stderr).text(),
571 cat.status,
572 ]).then(logErrorsAndCollectResults).then(
573 ([source, caterr, catstatus]) => {
574 if (caterr) {
575 console.error(caterr);
576 } else {
577 /* do nothing */
578 }
579 if (!catstatus.success) {
580 throw new Error(
581 `GitWikiWeb: git cat-file returned nonzero exit code: ${catstatus.code}.`,
582 );
583 } else {
584 const page = new GitWikiWebPage(
585 namespace,
586 pageName,
587 djot.parse(source, {
588 warn: ($) =>
589 console.warn(`Djot(${reference}): ${$.render()}`),
590 }),
591 source,
592 config,
593 );
594 const reference = `${namespace}:${pageName}`;
595 pages.set(reference, page);
596 requiredButMissingPages.delete(reference);
597 }
598 },
599 );
600 promises.push(promise);
601 }
602 }
603 for (const [reference, defaultTitle] of requiredButMissingPages) {
604 const [namespace, pageName] = splitReference(reference);
605 const source = `# ${defaultTitle}\n`;
606 const page = new GitWikiWebPage(
607 namespace,
608 pageName,
609 djot.parse(source, {
610 warn: ($) =>
611 console.warn(`Djot(${reference}): ${$.render()}`),
612 }),
613 source,
614 config,
615 );
616 pages.set(reference, page);
617 }
618 await Promise.allSettled(promises).then(
619 logErrorsAndCollectResults,
620 );
621 const [template, recentlyChanged] = await Promise.allSettled([
622 getRemoteContent("template.html"),
623 (async () => {
624 const dateParse = new Deno.Command("git", {
625 args: ["rev-parse", "--after=1 week ago"],
626 stdout: "piped",
627 stderr: "piped",
628 }).spawn();
629 const [maxAge] = await Promise.allSettled([
630 new Response(dateParse.stdout).text(),
631 new Response(dateParse.stderr).text(),
632 ]).then(logErrorsAndCollectResults);
633 let commit;
634 if (!maxAge) {
635 /* do nothing */
636 } else {
637 const revList = new Deno.Command("git", {
638 args: ["rev-list", maxAge, "--reverse", "HEAD"],
639 stdout: "piped",
640 stderr: "piped",
641 }).spawn();
642 [commit] = await Promise.allSettled([
643 new Response(revList.stdout).text().then((list) =>
644 list.split("\n")[0]
645 ),
646 new Response(revList.stderr).text(),
647 ]).then(logErrorsAndCollectResults);
648 }
649 if (!commit) {
650 const revList2 = new Deno.Command("git", {
651 args: ["rev-list", "--max-count=1", "HEAD^"],
652 stdout: "piped",
653 stderr: "piped",
654 }).spawn();
655 [commit] = await Promise.allSettled([
656 new Response(revList2.stdout).text().then((list) =>
657 list.trim()
658 ),
659 new Response(revList2.stderr).text(),
660 ]).then(logErrorsAndCollectResults);
661 } else {
662 /* do nothing */
663 }
664 const results = new Array(6);
665 const seen = new Set();
666 let recency = 5;
667 let current;
668 do {
669 const show = new Deno.Command("git", {
670 args: [
671 "show",
672 "-s",
673 "--format=%H%x00%cI%x00%cD",
674 recency ? `HEAD~${5 - recency}` : commit,
675 ],
676 stdout: "piped",
677 stderr: "piped",
678 }).spawn();
679 const [
680 [hash, dateTime, humanReadable],
681 ] = await Promise.allSettled([
682 new Response(show.stdout).text().then((rev) =>
683 rev.trim().split("\0")
684 ),
685 new Response(show.stderr).text(),
686 ]).then(logErrorsAndCollectResults);
687 const refs = [];
688 current = hash;
689 for (
690 const ref of (await diffReferences(current, !recency))
691 ) {
692 if (seen.has(ref)) {
693 /* do nothing */
694 } else {
695 refs.push(ref);
696 seen.add(ref);
697 }
698 }
699 results[recency] = { dateTime, hash, humanReadable, refs };
700 } while (recency-- > 0 && current && current != commit);
701 return results;
702 })(),
703 ...Array.from(
704 pages.keys(),
705 (name) => ensureDir(`${DESTINATION}/${name}`),
706 ),
707 ["style.css"].map((dependency) =>
708 getRemoteContent(dependency).then((source) =>
709 Deno.writeTextFile(
710 `${DESTINATION}/${dependency}`,
711 source,
712 { createNew: true },
713 )
714 )
715 ),
716 ]).then(logErrorsAndCollectResults);
717 promises.length = 0;
718 const redLinks = (() => {
719 const result = new Set();
720 for (const page of pages.values()) {
721 for (const link of page.internalLinks()) {
722 if (pages.has(link)) {
723 continue;
724 } else {
725 result.add(link);
726 }
727 }
728 }
729 return result;
730 })();
731 for (
732 const [pageRef, { ast, namespace, sections, source }] of pages
733 ) {
734 const title = sections.main?.title ?? pageRef;
735 djot.applyFilter(ast, () => {
736 let isNavigationPage = true;
737 return {
738 doc: {
739 enter: (e) => {
740 const { content, navigation } = (() => {
741 const navigation = [];
742 if (pageRef == "Special:RecentlyChanged") {
743 navigation.push({
744 tag: "bullet_list",
745 attributes: { class: "recent-changes" },
746 tight: true,
747 style: "*",
748 children: Array.from(function* () {
749 for (
750 const [index, result] of recentlyChanged
751 .entries()
752 ) {
753 if (result != null) {
754 const {
755 dateTime,
756 humanReadable,
757 refs,
758 } = result;
759 yield* listOfInternalLinks(refs, (link) => ({
760 tag: index == 0 ? "span" : "strong",
761 attributes: { "data-recency": `${index}` },
762 children: [
763 link,
764 ...(index == 0 ? [] : [
765 str` `,
766 rawInline`<small>(<time dateTime="${dateTime}">`,
767 str`${humanReadable}`,
768 rawInline`</time>)</small>`,
769 ]),
770 ],
771 })).children;
772 } else {
773 /* do nothing */
774 }
775 }
776 }()).reverse(),
777 });
778 } else {
779 isNavigationPage = false;
780 return { content: e.children, navigation };
781 }
782 return {
783 content: [
784 {
785 tag: "heading",
786 attributes: {
787 class: "main",
788 generated: "", // will be removed later
789 },
790 level: 1,
791 children: [str`${title}`],
792 },
793 rawBlock`<details id="navigation-about" open="">`,
794 rawBlock`<summary>about this listing</summary>`,
795 rawBlock`<article>`,
796 ...e.children,
797 rawBlock`</article>`,
798 rawBlock`</details>`,
799 ],
800 navigation: [
801 rawBlock`<nav id="navigation">`,
802 ...navigation,
803 rawBlock`</nav>`,
804 ],
805 };
806 })();
807 e.children = [
808 rawBlock`<article>`,
809 ...content,
810 ...navigation,
811 rawBlock`</article>`,
812 ];
813 },
814 exit: (_) => {},
815 },
816 heading: {
817 enter: (e) => {
818 const attributes = e.attributes ?? NIL;
819 if (
820 isNavigationPage && e.level == 1 &&
821 attributes?.class == "main"
822 ) {
823 if ("generated" in attributes) {
824 delete attributes.generated;
825 } else {
826 return { stop: [] };
827 }
828 } else {
829 /* do nothing */
830 }
831 },
832 exit: (e) => {
833 if (e.level == 1 && e.attributes?.class == "main") {
834 return [
835 rawBlock`<header class="main">`,
836 e,
837 { tag: "verbatim", text: pageRef },
838 rawBlock`</header>`,
839 ];
840 } else {
841 /* do nothing */
842 }
843 },
844 },
845 link: {
846 enter: (_) => {},
847 exit: (e) => {
848 e.attributes ??= {};
849 const { attributes, children, reference } = e;
850 if (attributes["data-realm"] == "internal") {
851 delete e.reference;
852 if (redLinks.has(reference)) {
853 e.destination =
854 `/Special:NotFound?path=/${reference}`;
855 attributes["data-notfound"] = "";
856 } else {
857 e.destination = `/${reference}`;
858 }
859 if (children.length == 0) {
860 const section =
861 pages.get(reference)?.sections?.main ?? NIL;
862 const { v } = attributes;
863 if (v == null) {
864 children.push(
865 str`${section.title ?? reference}`,
866 );
867 } else {
868 delete attributes.v;
869 children.push(
870 str`${
871 section.variantTitles?.[v] ?? section.title ??
872 reference
873 }`,
874 );
875 }
876 }
877 } else {
878 if (children.length == 0 && "title" in attributes) {
879 children.push(
880 rawInline`<cite>`,
881 str`${attributes.title}`,
882 rawInline`</cite>`,
883 );
884 }
885 }
886 if (
887 (attributes.class ?? "").split(/\s/gu).includes("sig")
888 ) {
889 return {
890 tag: "span",
891 attributes: { class: "sig" },
892 children: [str`—${"\xA0"}`, e],
893 };
894 } else {
895 /* do nothing */
896 }
897 },
898 },
899 section: {
900 enter: (_) => {},
901 exit: (e) => {
902 if (e.children.length < 1) {
903 // The heading for this section was removed and it had
904 // no other children.
905 return [];
906 } else {
907 /* do nothing */
908 }
909 },
910 },
911 };
912 });
913 const doc = getDOM(template);
914 const result = getDOM(`${djot.renderHTML(ast)}`);
915 const headElement = domutils.findOne(
916 (node) => node.name == "head",
917 doc,
918 );
919 const titleElement = domutils.findOne(
920 (node) => node.name == "title",
921 headElement,
922 );
923 const contentElement = domutils.findOne(
924 (node) => node.name == "gitwikiweb-content",
925 doc,
926 );
927 if (headElement == null) {
928 throw new Error(
929 "GitWikiWeb: Template must explicitly include a <head> element.",
930 );
931 } else {
932 domutils.appendChild(
933 headElement,
934 new Element("link", {
935 rel: "source",
936 type: "text/x.djot",
937 href: `/${pageRef}/source.djot`,
938 }),
939 );
940 if (titleElement == null) {
941 domutils.prependChild(
942 headElement,
943 new Element("title", {}, [new Text(title)]),
944 );
945 } else {
946 domutils.prependChild(titleElement, new Text(`${title} | `));
947 }
948 }
949 if (contentElement == null) {
950 throw new Error(
951 "GitWikiWeb: Template did not include a <gitwikiweb-content> element.",
952 );
953 } else {
954 for (const node of result) {
955 domutils.prepend(contentElement, node);
956 }
957 domutils.removeElement(contentElement);
958 }
959 promises.push(
960 Deno.writeTextFile(
961 `${DESTINATION}/${pageRef}/index.html`,
962 domSerializer(doc, {
963 emptyAttrs: true,
964 encodeEntities: "utf8",
965 selfClosingTags: true,
966 xmlMode: false,
967 }),
968 { createNew: true },
969 ),
970 );
971 promises.push(
972 Deno.writeTextFile(
973 `${DESTINATION}/${pageRef}/source.djot`,
974 source,
975 { createNew: true },
976 ),
977 );
978 }
979 await Promise.allSettled(promises).then(
980 logErrorsAndCollectResults,
981 );
982 console.log(`GitWikiWeb: Wrote ${pages.size} page(s).`);
983 }
984 }
This page took 0.135371 seconds and 3 git commands to generate.