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