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