]> Lady’s Gitweb - GitWikiWeb/blob - build.js
Use Elstob as the font for the wiki
[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.3.1";
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
151 .exec(path)?.[1]?.replace?.("/", ":"); // only replaces first slash
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 definition_list: {
233 enter: (e) => {
234 const attributes = e.attributes ?? {};
235 if (
236 (attributes.class ?? "").split(/\s/gu).includes(
237 "timeline",
238 )
239 ) {
240 const years = new Map();
241 for (
242 const { children: [{ children: termChildren }, defn] }
243 of e.children
244 ) {
245 const { label, year } = (() => {
246 if (termChildren.length != 1) {
247 return { label: termChildren, year: termChildren };
248 } else {
249 const str = termChildren[0];
250 if (str.tag != "str") {
251 return {
252 label: termChildren,
253 year: termChildren,
254 };
255 } else {
256 const { text } = str;
257 return {
258 label: text,
259 year:
260 /^(-?[0-9X]{4})(?:-[0-9X]{2}(?:-[0-9X]{2})?)?$/u
261 .exec(text)?.[1] ?? text,
262 };
263 }
264 }
265 })();
266 const yearList = (() => {
267 const result = {
268 tag: "bullet_list",
269 tight: false,
270 style: "-",
271 children: [],
272 };
273 if (years.has(year)) {
274 const yearMap = years.get(year);
275 if (yearMap.has(label)) {
276 return yearMap.get(label);
277 } else {
278 yearMap.set(label, result);
279 return result;
280 }
281 } else {
282 years.set(year, new Map([[label, result]]));
283 return result;
284 }
285 })();
286 const misc = { tag: "list_item", children: [] };
287 for (const child of defn.children) {
288 if (child.tag == "bullet_list") {
289 yearList.children.push(...child.children);
290 } else {
291 misc.children.push(child);
292 }
293 }
294 if (misc.children.length > 0) {
295 yearList.children.unshift(misc);
296 } else {
297 /* do nothing */
298 }
299 }
300 const sorted = [...years].sort(([a], [b]) =>
301 typeof a != "string" || isNaN(a) ||
302 typeof b != "string" || isNaN(b) || +a == +b
303 ? 0
304 : 1 - 2 * (+a < b)
305 );
306 sorted.forEach((pair) =>
307 pair[1] = [...pair[1]].sort(([a], [b]) =>
308 1 - 2 * (a < b)
309 )
310 );
311 return {
312 tag: "div",
313 attributes,
314 children: sorted.flatMap(([year, yearDef]) => [
315 rawBlock`<details open="">`,
316 rawBlock`<summary>`,
317 ...(Array.isArray(year) ? year : [str`${year}`]),
318 rawBlock`</summary>`,
319 rawBlock`<dl>`,
320 ...yearDef.map(([label, list]) => ({
321 tag: "div",
322 children: [
323 rawBlock`<dt>`,
324 ...(Array.isArray(label) ? label : [str`${label}`]),
325 rawBlock`</dt>`,
326 rawBlock`<dd>`,
327 list,
328 rawBlock`</dd>`,
329 ],
330 })),
331 rawBlock`</dl>`,
332 rawBlock`</details>`,
333 ]),
334 };
335 } else {
336 /* do nothing */
337 }
338 },
339 exit: (_) => {},
340 },
341 hard_break: {
342 enter: (_) => {
343 if (titleSoFar != null) {
344 titleSoFar += " ";
345 } else {
346 /* do nothing */
347 }
348 },
349 exit: (_) => {},
350 },
351 heading: {
352 enter: (_) => {
353 titleSoFar = "";
354 },
355 exit: (e) => {
356 e.attributes ??= {};
357 const { attributes } = e;
358 attributes.title ??= titleSoFar;
359 titleSoFar = null;
360 },
361 },
362 link: {
363 enter: (e) => {
364 e.attributes ??= {};
365 const { attributes, reference, destination } = e;
366 if (
367 /^(?:[A-Z][0-9A-Za-z]*|[@#])?:(?:[A-Z][0-9A-Za-z]*(?:\/[A-Z][0-9A-Za-z]*)*)?$/u
368 .test(reference ?? "")
369 ) {
370 const [namespacePrefix, pageName] = splitReference(
371 reference,
372 );
373 const expandedNamespace = {
374 "": "Page",
375 "@": "Editor",
376 "#": "Category",
377 }[namespacePrefix] ?? namespacePrefix;
378 const resolvedReference = pageName == ""
379 ? `Namespace:${expandedNamespace}`
380 : `${expandedNamespace}:${pageName}`;
381 e.reference = resolvedReference;
382 attributes["data-realm"] = "internal";
383 attributes["data-pagename"] = pageName;
384 attributes["data-namespace"] = expandedNamespace;
385 if (
386 resolvedReference.startsWith("Editor:") &&
387 (attributes.class ?? "").split(/\s/gu).includes("sig")
388 ) {
389 // This is a special internal link; do not record it.
390 /* do nothing */
391 } else {
392 // This is a non‐special internal link; record it.
393 internalLinks.add(resolvedReference);
394 }
395 } else {
396 attributes["data-realm"] = "external";
397 const remote = destination ??
398 ast.references[reference]?.destination;
399 if (remote) {
400 externalLinks.set(remote, attributes?.title);
401 } else {
402 /* do nothing */
403 }
404 }
405 },
406 },
407 non_breaking_space: {
408 enter: (_) => {
409 if (titleSoFar != null) {
410 titleSoFar += "\xA0";
411 } else {
412 /* do nothing */
413 }
414 },
415 exit: (_) => {},
416 },
417 section: {
418 enter: (_) => {},
419 exit: (e) => {
420 e.attributes ??= {};
421 const { attributes, children } = e;
422 const heading = children.find(({ tag }) =>
423 tag == "heading"
424 );
425 const title = (() => {
426 if (heading?.attributes?.title) {
427 const result = heading.attributes.title;
428 delete heading.attributes.title;
429 return result;
430 } else {
431 return heading.level == 1
432 ? `${namespace}:${name}`
433 : "untitled section";
434 }
435 })();
436 const variantTitles = Object.create(null);
437 for (const attr in attributes) {
438 if (attr.startsWith("v-")) {
439 Object.defineProperty(
440 variantTitles,
441 attr.substring(2),
442 { ...READ_ONLY, value: attributes[attr] },
443 );
444 delete attributes[attr];
445 } else {
446 continue;
447 }
448 }
449 const definition = Object.create(null, {
450 title: { ...READ_ONLY, value: title },
451 variantTitles: {
452 ...READ_ONLY,
453 value: Object.preventExtensions(variantTitles),
454 },
455 });
456 if (heading.level == 1 && !("main" in sections)) {
457 attributes.id = "main";
458 heading.attributes ??= {};
459 heading.attributes.class = "main";
460 } else {
461 /* do nothing */
462 }
463 try {
464 Object.defineProperty(
465 sections,
466 attributes.id,
467 {
468 ...READ_ONLY,
469 writable: true,
470 value: Object.preventExtensions(definition),
471 },
472 );
473 } catch (cause) {
474 throw new Error(
475 `GitWikiWeb: A section with the provided @id already exists: ${attributes.id}`,
476 { cause },
477 );
478 }
479 },
480 },
481 soft_break: {
482 enter: (_) => {
483 if (titleSoFar != null) {
484 titleSoFar += " ";
485 } else {
486 /* do nothing */
487 }
488 },
489 exit: (_) => {},
490 },
491 str: {
492 enter: ({ text }) => {
493 if (titleSoFar != null) {
494 titleSoFar += text;
495 } else {
496 /* do nothing */
497 }
498 },
499 exit: (_) => {},
500 },
501 symb: {
502 enter: (e) => {
503 const { alias } = e;
504 const codepoint = /^U\+([0-9A-Fa-f]+)$/u.exec(alias)?.[1];
505 if (codepoint) {
506 return str`${
507 String.fromCodePoint(parseInt(codepoint, 16))
508 }`;
509 } else {
510 const resolved = config.symbols?.[alias];
511 return resolved != null ? str`${resolved}` : e;
512 }
513 },
514 },
515 };
516 });
517 Object.defineProperties(this, {
518 ast: { ...READ_ONLY, value: ast },
519 namespace: { ...READ_ONLY, value: namespace },
520 name: { ...READ_ONLY, value: name },
521 sections: {
522 ...READ_ONLY,
523 value: Object.preventExtensions(sections),
524 },
525 source: { ...READ_ONLY, value: source },
526 });
527 }
528
529 *externalLinks() {
530 yield* this.#externalLinks;
531 }
532
533 *internalLinks() {
534 yield* this.#internalLinks;
535 }
536 }
537
538 {
539 // Patches for Djot HTML renderer.
540 const { HTMLRenderer: { prototype: htmlRendererPrototype } } = djot;
541 const { inTags: upstreamInTags } = htmlRendererPrototype;
542 htmlRendererPrototype.inTags = function (
543 tag,
544 node,
545 newlines,
546 extraAttrs = undefined,
547 ) {
548 const attributes = node.attributes ?? NIL;
549 if ("as" in attributes) {
550 const newTag = attributes.as;
551 delete attributes.as;
552 return upstreamInTags.call(
553 this,
554 newTag,
555 node,
556 newlines,
557 extraAttrs,
558 );
559 } else {
560 return upstreamInTags.call(
561 this,
562 tag,
563 node,
564 newlines,
565 extraAttrs,
566 );
567 }
568 };
569 }
570 {
571 const config = await getRemoteContent("config.yaml").then((yaml) =>
572 parseYaml(yaml, { schema: JSON_SCHEMA })
573 );
574 const ls = new Deno.Command("git", {
575 args: ["ls-tree", "-rz", "HEAD"],
576 stdout: "piped",
577 stderr: "piped",
578 }).spawn();
579 const [
580 objects,
581 lserr,
582 lsstatus,
583 ] = await Promise.allSettled([
584 new Response(ls.stdout).text().then((lsout) =>
585 lsout
586 .split("\0")
587 .slice(0, -1) // drop the last entry; it is empty
588 .map(($) => $.split(/\s+/g))
589 ),
590 new Response(ls.stderr).text(),
591 ls.status,
592 ]).then(logErrorsAndCollectResults);
593 if (lserr) {
594 console.error(lserr);
595 } else {
596 /* do nothing */
597 }
598 if (!lsstatus.success) {
599 throw new Error(
600 `GitWikiWeb: git ls-tree returned nonzero exit code: ${lsstatus.code}.`,
601 );
602 } else {
603 const requiredButMissingPages = new Map([
604 ["Special:FrontPage", "front page"],
605 ["Special:NotFound", "not found"],
606 ["Special:RecentlyChanged", "recently changed"],
607 ]);
608 const pages = new Map();
609 const promises = [emptyDir(DESTINATION)];
610 for (const object of objects) {
611 const hash = object[2];
612 const path = object[3];
613 const reference = getReferenceFromPath(path);
614 if (reference == null) {
615 continue;
616 } else {
617 const [namespace, pageName] = splitReference(reference);
618 const cat = new Deno.Command("git", {
619 args: ["cat-file", "blob", hash],
620 stdout: "piped",
621 stderr: "piped",
622 }).spawn();
623 const promise = Promise.allSettled([
624 new Response(cat.stdout).text(),
625 new Response(cat.stderr).text(),
626 cat.status,
627 ]).then(logErrorsAndCollectResults).then(
628 ([source, caterr, catstatus]) => {
629 if (caterr) {
630 console.error(caterr);
631 } else {
632 /* do nothing */
633 }
634 if (!catstatus.success) {
635 throw new Error(
636 `GitWikiWeb: git cat-file returned nonzero exit code: ${catstatus.code}.`,
637 );
638 } else {
639 const reference = `${namespace}:${pageName}`;
640 const page = new GitWikiWebPage(
641 namespace,
642 pageName,
643 djot.parse(source, {
644 warn: ($) =>
645 console.warn(`Djot(${reference}): ${$.render()}`),
646 }),
647 source,
648 config,
649 );
650 pages.set(reference, page);
651 requiredButMissingPages.delete(reference);
652 }
653 },
654 );
655 promises.push(promise);
656 }
657 }
658 for (const [reference, defaultTitle] of requiredButMissingPages) {
659 const [namespace, pageName] = splitReference(reference);
660 const source = `# ${defaultTitle}\n`;
661 const page = new GitWikiWebPage(
662 namespace,
663 pageName,
664 djot.parse(source, {
665 warn: ($) =>
666 console.warn(`Djot(${reference}): ${$.render()}`),
667 }),
668 source,
669 config,
670 );
671 pages.set(reference, page);
672 }
673 await Promise.allSettled(promises).then(
674 logErrorsAndCollectResults,
675 );
676 const [template, recentlyChanged] = await Promise.allSettled([
677 getRemoteContent("template.html"),
678 (async () => {
679 const dateParse = new Deno.Command("git", {
680 args: ["rev-parse", "--after=1 week ago"],
681 stdout: "piped",
682 stderr: "piped",
683 }).spawn();
684 const [maxAge] = await Promise.allSettled([
685 new Response(dateParse.stdout).text(),
686 new Response(dateParse.stderr).text(),
687 ]).then(logErrorsAndCollectResults);
688 let commit;
689 if (!maxAge) {
690 /* do nothing */
691 } else {
692 const revList = new Deno.Command("git", {
693 args: ["rev-list", maxAge, "--reverse", "HEAD"],
694 stdout: "piped",
695 stderr: "piped",
696 }).spawn();
697 [commit] = await Promise.allSettled([
698 new Response(revList.stdout).text().then((list) =>
699 list.split("\n")[0]
700 ),
701 new Response(revList.stderr).text(),
702 ]).then(logErrorsAndCollectResults);
703 }
704 if (!commit) {
705 const revList2 = new Deno.Command("git", {
706 args: ["rev-list", "--max-count=1", "HEAD^"],
707 stdout: "piped",
708 stderr: "piped",
709 }).spawn();
710 [commit] = await Promise.allSettled([
711 new Response(revList2.stdout).text().then((list) =>
712 list.trim()
713 ),
714 new Response(revList2.stderr).text(),
715 ]).then(logErrorsAndCollectResults);
716 } else {
717 /* do nothing */
718 }
719 const results = new Array(6);
720 const seen = new Set();
721 const maxRecency = Math.max(config.max_recency | 0, 0);
722 let recency = maxRecency;
723 let current;
724 do {
725 const show = new Deno.Command("git", {
726 args: [
727 "show",
728 "-s",
729 "--format=%H%x00%cI%x00%cD",
730 recency ? `HEAD~${maxRecency - recency}` : commit,
731 ],
732 stdout: "piped",
733 stderr: "piped",
734 }).spawn();
735 const [
736 [hash, dateTime, humanReadable],
737 ] = await Promise.allSettled([
738 new Response(show.stdout).text().then((rev) =>
739 rev.trim().split("\0")
740 ),
741 new Response(show.stderr).text(),
742 ]).then(logErrorsAndCollectResults);
743 const refs = [];
744 current = hash;
745 for (
746 const ref of (await diffReferences(current, !recency))
747 ) {
748 if (seen.has(ref)) {
749 /* do nothing */
750 } else {
751 refs.push(ref);
752 seen.add(ref);
753 }
754 }
755 results[recency] = { dateTime, hash, humanReadable, refs };
756 } while (recency-- > 0 && current && current != commit);
757 return results;
758 })(),
759 ...Array.from(
760 pages.keys(),
761 (name) => ensureDir(`${DESTINATION}/${name}`),
762 ),
763 ["style.css"].map((dependency) =>
764 getRemoteContent(dependency).then((source) =>
765 Deno.writeTextFile(
766 `${DESTINATION}/${dependency}`,
767 source,
768 { createNew: true },
769 )
770 )
771 ),
772 ]).then(logErrorsAndCollectResults);
773 promises.length = 0;
774 const { redLinks, subpages } = (() => {
775 const redLinks = new Set();
776 const subpages = new Map();
777 for (const [pageRef, page] of pages) {
778 let superRef = pageRef;
779 while (
780 (superRef = superRef.substring(0, superRef.indexOf("/")))
781 ) {
782 // Iterate over potential superpages and record them if they
783 // actually exist.
784 if (pages.has(superRef)) {
785 // There is a superpage for the current page; record it.
786 if (subpages.has(superRef)) {
787 // The identified superpage already has other subpages.
788 subpages.get(superRef).add(pageRef);
789 } else {
790 // The identified superpage does not already have other
791 // subpages.
792 subpages.set(superRef, new Set([pageRef]));
793 }
794 break;
795 } else {
796 // The superpage for the current page has not been found
797 // yet.
798 /* do nothing */
799 }
800 }
801 for (const link of page.internalLinks()) {
802 // Iterate over the internal links of the current page and
803 // ensure they are all defined.
804 if (pages.has(link)) {
805 // The link was defined.
806 continue;
807 } else {
808 // The link was not defined; it is a redlink.
809 redLinks.add(link);
810 }
811 }
812 }
813 return { redLinks, subpages };
814 })();
815 for (const [pageRef, page] of pages) {
816 const { ast, sections, source } = page;
817 const title = sections.main?.title ?? pageRef;
818 const internalLinks = new Set(page.internalLinks());
819 const externalLinks = new Map(page.externalLinks());
820 const subpageRefs = subpages.get(pageRef) ?? new Set();
821 djot.applyFilter(ast, () => {
822 let isNavigationPage = true;
823 return {
824 doc: {
825 enter: (e) => {
826 const seeAlsoSection = [];
827 const linksSection = [];
828 if (subpageRefs.size) {
829 seeAlsoSection.push(
830 rawBlock`<nav id="seealso">`,
831 {
832 tag: "heading",
833 level: 2,
834 children: [
835 str`see also`,
836 ],
837 },
838 rawBlock`<section id="subpages">`,
839 {
840 tag: "heading",
841 level: 3,
842 children: [
843 str`subpages`,
844 ],
845 },
846 listOfInternalLinks(subpageRefs),
847 rawBlock`</section>`,
848 rawBlock`</nav>`,
849 );
850 } else {
851 /* do nothing */
852 }
853 if (internalLinks.size || externalLinks.size) {
854 linksSection.push(
855 rawBlock`<nav id="links">`,
856 {
857 tag: "heading",
858 level: 2,
859 children: [str`this page contains links`],
860 },
861 );
862 if (internalLinks.size) {
863 linksSection.push(
864 rawBlock`<details open="">`,
865 rawBlock`<summary>on this wiki</summary>`,
866 listOfInternalLinks(internalLinks),
867 rawBlock`</details>`,
868 );
869 } else {
870 /* do nothing */
871 }
872 if (externalLinks.size) {
873 linksSection.push(
874 rawBlock`<details open="">`,
875 rawBlock`<summary>elsewhere on the Web</summary>`,
876 {
877 tag: "bullet_list",
878 tight: true,
879 style: "*",
880 children: Array.from(
881 externalLinks,
882 ([destination, text]) => ({
883 tag: "list_item",
884 children: [{
885 tag: "para",
886 children: [{
887 tag: "link",
888 attributes: { "data-realm": "external" },
889 destination,
890 children: text
891 ? [
892 rawInline`<cite>`,
893 str`${text}`,
894 rawInline`</cite>`,
895 ]
896 : [
897 rawInline`<code>`,
898 str`${destination}`,
899 rawInline`</code>`,
900 ],
901 }],
902 }],
903 }),
904 ),
905 },
906 rawBlock`</details>`,
907 );
908 } else {
909 /* do nothing */
910 }
911 linksSection.push(
912 rawBlock`</nav>`,
913 );
914 } else {
915 /* do nothing */
916 }
917 const childrenAndLinks = [
918 ...e.children,
919 ...seeAlsoSection,
920 rawBlock`<footer>`,
921 rawBlock`${"\uFFFF"}`, // footnote placeholder
922 ...linksSection,
923 rawBlock`</footer>`,
924 ];
925 const { content, navigation } = (() => {
926 const navigation = [];
927 if (pageRef == "Special:RecentlyChanged") {
928 navigation.push({
929 tag: "bullet_list",
930 attributes: { class: "recent-changes" },
931 tight: true,
932 style: "*",
933 children: Array.from(function* () {
934 for (
935 const [index, result] of recentlyChanged
936 .entries()
937 ) {
938 if (result != null) {
939 const {
940 dateTime,
941 humanReadable,
942 refs,
943 } = result;
944 yield* listOfInternalLinks(refs, (link) => ({
945 tag: index == 0 ? "span" : "strong",
946 attributes: { "data-recency": `${index}` },
947 children: [
948 link,
949 ...(index == 0 ? [] : [
950 str` `,
951 rawInline`<small>(<time dateTime="${dateTime}">`,
952 str`${humanReadable}`,
953 rawInline`</time>)</small>`,
954 ]),
955 ],
956 })).children;
957 } else {
958 /* do nothing */
959 }
960 }
961 }()).reverse(),
962 });
963 } else {
964 isNavigationPage = false;
965 return { content: childrenAndLinks, navigation };
966 }
967 return {
968 content: [
969 {
970 tag: "heading",
971 attributes: {
972 class: "main",
973 generated: "", // will be removed later
974 },
975 level: 1,
976 children: [str`${title}`],
977 },
978 rawBlock`<details id="navigation-about" open="">`,
979 rawBlock`<summary>about this listing</summary>`,
980 rawBlock`<article>`,
981 ...childrenAndLinks,
982 rawBlock`</article>`,
983 rawBlock`</details>`,
984 ],
985 navigation: [
986 rawBlock`<nav id="navigation">`,
987 ...navigation,
988 rawBlock`</nav>`,
989 ],
990 };
991 })();
992 e.children = [
993 rawBlock`<article>`,
994 ...content,
995 ...navigation,
996 rawBlock`</article>`,
997 ];
998 },
999 exit: (_) => {},
1000 },
1001 heading: {
1002 enter: (e) => {
1003 const attributes = e.attributes ?? NIL;
1004 if (
1005 isNavigationPage && e.level == 1 &&
1006 attributes?.class == "main"
1007 ) {
1008 if ("generated" in attributes) {
1009 delete attributes.generated;
1010 } else {
1011 return { stop: [] };
1012 }
1013 } else {
1014 /* do nothing */
1015 }
1016 },
1017 exit: (e) => {
1018 if (e.level == 1 && e.attributes?.class == "main") {
1019 return [
1020 rawBlock`<header class="main">`,
1021 e,
1022 { tag: "verbatim", text: pageRef },
1023 rawBlock`</header>`,
1024 ];
1025 } else {
1026 /* do nothing */
1027 }
1028 },
1029 },
1030 link: {
1031 enter: (_) => {},
1032 exit: (e) => {
1033 e.attributes ??= {};
1034 const { attributes, children, reference } = e;
1035 if (attributes["data-realm"] == "internal") {
1036 delete e.reference;
1037 if (redLinks.has(reference)) {
1038 e.destination =
1039 `/Special:NotFound?path=/${reference}`;
1040 attributes["data-notfound"] = "";
1041 } else {
1042 e.destination = `/${reference}`;
1043 }
1044 if (children.length == 0) {
1045 const section =
1046 pages.get(reference)?.sections?.main ?? NIL;
1047 const { v } = attributes;
1048 if (v == null) {
1049 children.push(
1050 str`${section.title ?? reference}`,
1051 );
1052 } else {
1053 delete attributes.v;
1054 children.push(
1055 str`${
1056 section.variantTitles?.[v] ?? section.title ??
1057 reference
1058 }`,
1059 );
1060 }
1061 }
1062 } else {
1063 if (children.length == 0 && "title" in attributes) {
1064 children.push(
1065 rawInline`<cite>`,
1066 str`${attributes.title}`,
1067 rawInline`</cite>`,
1068 );
1069 }
1070 }
1071 if (
1072 (attributes.class ?? "").split(/\s/gu).includes("sig")
1073 ) {
1074 return {
1075 tag: "span",
1076 attributes: { class: "sig" },
1077 children: [str`—${"\xA0"}`, e],
1078 };
1079 } else {
1080 /* do nothing */
1081 }
1082 },
1083 },
1084 section: {
1085 enter: (_) => {},
1086 exit: (e) => {
1087 if (e.children.length < 1) {
1088 // The heading for this section was removed and it had
1089 // no other children.
1090 return [];
1091 } else {
1092 /* do nothing */
1093 }
1094 },
1095 },
1096 };
1097 });
1098 const renderedAST = djot.renderAST(ast);
1099 const doc = getDOM(template);
1100 const result = getDOM(djot.renderHTML(ast, {
1101 overrides: {
1102 raw_block: (node, context) => {
1103 if (node.format == "html" && node.text == "\uFFFF") {
1104 if (context.nextFootnoteIndex > 1) {
1105 const result = context.renderNotes(ast.footnotes);
1106 context.nextFootnoteIndex = 1;
1107 return result;
1108 } else {
1109 return "";
1110 }
1111 } else {
1112 return context.renderAstNodeDefault(node);
1113 }
1114 },
1115 },
1116 }));
1117 const headElement = domutils.findOne(
1118 (node) => node.name == "head",
1119 doc,
1120 );
1121 const titleElement = domutils.findOne(
1122 (node) => node.name == "title",
1123 headElement,
1124 );
1125 const contentElement = domutils.findOne(
1126 (node) => node.name == "gitwikiweb-content",
1127 doc,
1128 );
1129 if (headElement == null) {
1130 throw new Error(
1131 "GitWikiWeb: Template must explicitly include a <head> element.",
1132 );
1133 } else {
1134 domutils.appendChild(
1135 headElement,
1136 new Element("link", {
1137 rel: "source",
1138 type: "text/x.djot",
1139 href: `/${pageRef}/source.djot`,
1140 }),
1141 );
1142 if (titleElement == null) {
1143 domutils.prependChild(
1144 headElement,
1145 new Element("title", {}, [new Text(title)]),
1146 );
1147 } else {
1148 domutils.prependChild(titleElement, new Text(`${title} | `));
1149 }
1150 }
1151 if (contentElement == null) {
1152 throw new Error(
1153 "GitWikiWeb: Template did not include a <gitwikiweb-content> element.",
1154 );
1155 } else {
1156 for (const node of [...result]) {
1157 domutils.prepend(contentElement, node);
1158 }
1159 domutils.removeElement(contentElement);
1160 }
1161 promises.push(
1162 Deno.writeTextFile(
1163 `${DESTINATION}/${pageRef}/index.html`,
1164 domSerializer(doc, {
1165 emptyAttrs: true,
1166 encodeEntities: "utf8",
1167 selfClosingTags: true,
1168 xmlMode: false,
1169 }),
1170 { createNew: true },
1171 ),
1172 );
1173 promises.push(
1174 Deno.writeTextFile(
1175 `${DESTINATION}/${pageRef}/index.ast`,
1176 renderedAST,
1177 { createNew: true },
1178 ),
1179 );
1180 promises.push(
1181 Deno.writeTextFile(
1182 `${DESTINATION}/${pageRef}/source.djot`,
1183 source,
1184 { createNew: true },
1185 ),
1186 );
1187 }
1188 await Promise.allSettled(promises).then(
1189 logErrorsAndCollectResults,
1190 );
1191 console.log(`GitWikiWeb: Wrote ${pages.size} page(s).`);
1192 }
1193 }
This page took 0.343345 seconds and 5 git commands to generate.