1 // 🐙🕸️ GitWikiWeb ∷ build.js
2 // ====================================================================
4 // Copyright © 2023 Lady [@ Lady’s Computer].
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/>.
10 // --------------------------------------------------------------------
12 // A script for generating static wiki pages from a git repository.
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 :—
19 // #!/usr/bin/env -S sh
20 // export GITWIKIWEB=/srv/git/GitWikiWeb
21 // git archive --remote=$GITWIKIWEB HEAD build.js \
23 // | deno run -A - ~/public/wiki $GITWIKIWEB current
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.
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.
40 } from "https://deno.land/std@0.196.0/fs/mod.ts";
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";
51 const DESTINATION
= Deno
.args
[0] ?? "~/public/wiki";
52 const REMOTE
= Deno
.args
[1] ?? "/srv/git/GitWikiWeb";
53 const REV
= Deno
.args
[2] ?? "HEAD";
61 const NIL
= Object
.preventExtensions(Object
.create(null));
63 const rawBlock
= (strings
, ...substitutions
) => ({
66 text
: String
.raw(strings
, substitutions
),
68 const rawInline
= (strings
, ...substitutions
) => ({
71 text
: String
.raw(strings
, substitutions
),
73 const str
= (strings
, ...substitutions
) => ({
75 text
: String
.raw(strings
, substitutions
),
78 const getDOM
= (source
) => {
80 const handler
= new DomHandler((error
, dom
) => {
82 throw new Error("GitWikiWeb: Failed to process DOM.", {
89 const parser
= new Parser(handler
);
95 const getRemoteContent
= async (pathName
) => {
96 const getArchive
= new Deno
.Command("git", {
97 args
: ["archive", `--remote=${REMOTE}`, REV
, pathName
],
101 const untar
= new Deno
.Command("tar", {
107 getArchive
.stdout
.pipeTo(untar
.stdin
);
114 ] = await Promise
.allSettled([
115 new Response(getArchive
.stderr
).text(),
117 new Response(untar
.stdout
).text(),
118 new Response(untar
.stderr
).text(),
120 ]).then(logErrorsAndCollectResults
);
122 console
.error(err1
+ err2
);
126 if (!getArchiveStatus
.success
) {
128 `GitWikiWeb: git archive returned nonzero exit code: ${getArchiveStatus.code}.`,
130 } else if (!untarStatus
.success
) {
132 `GitWikiWeb: tar returned nonzero exit code: ${untarStatus.code}.`,
139 const logErrorsAndCollectResults
= (results
) =>
140 results
.map(({ value
, reason
}) => {
142 console
.error(reason
);
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
153 const listOfInternalLinks
= (references
, wrapper
= ($) => $) => ({
157 children
: Array
.from(
160 const [namespace, pageName
] = splitReference(reference
);
168 "data-realm": "internal",
169 "data-pagename": pageName
,
170 "data-namespace": namespace,
181 const diffReferences
= async (hash
, againstHead
= false) => {
182 const diff
= new Deno
.Command("git", {
190 ...(againstHead
? [hash
, "HEAD"] : [hash
]),
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
202 function* references(paths
) {
203 for (const path
of paths
) {
204 const reference
= getReferenceFromPath(path
);
213 const splitReference
= (reference
) => {
214 const colonIndex
= reference
.indexOf(":");
216 reference
.substring(0, colonIndex
),
217 reference
.substring(colonIndex
+ 1),
221 class GitWikiWebPage
{
222 #internalLinks
= new Set();
223 #externalLinks
= new Map();
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
234 const attributes
= e
.attributes
?? {};
236 (attributes
.class ?? "").split(/\s/gu).includes(
240 const years
= new Map();
242 const { children
: [{ children
: termChildren
}, defn
] }
245 const { label
, year
} = (() => {
246 if (termChildren
.length
!= 1) {
247 return { label
: termChildren
, year
: termChildren
};
249 const str
= termChildren
[0];
250 if (str
.tag
!= "str") {
256 const { text
} = str
;
260 /^(-?[0-9X]{4})(?:-[0-9X]{2}(?:-[0-9X]{2})?)?$/u
261 .exec(text
)?.[1] ?? text
,
266 const yearList
= (() => {
273 if (years
.has(year
)) {
274 const yearMap
= years
.get(year
);
275 if (yearMap
.has(label
)) {
276 return yearMap
.get(label
);
278 yearMap
.set(label
, result
);
282 years
.set(year
, new Map([[label
, result
]]));
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
);
291 misc
.children
.push(child
);
294 if (misc
.children
.length
> 0) {
295 yearList
.children
.unshift(misc
);
300 const sorted
= [...years
].sort(([a
], [b
]) =>
301 typeof a
!= "string" || isNaN(a
) ||
302 typeof b
!= "string" || isNaN(b
) || +a
== +b
306 sorted
.forEach((pair
) =>
307 pair
[1] = [...pair
[1]].sort(([a
], [b
]) =>
314 children
: sorted
.flatMap(([year
, yearDef
]) => [
315 rawBlock
`<details open="">`,
317 ...(Array
.isArray(year
) ? year
: [str
`${year}`]),
318 rawBlock
`</summary>`,
320 ...yearDef
.map(([label
, list
]) => ({
324 ...(Array
.isArray(label
) ? label
: [str
`${label}`]),
332 rawBlock
`</details>`,
343 if (titleSoFar
!= null) {
357 const { attributes
} = e
;
358 attributes
.title
??= titleSoFar
;
365 const { attributes
, reference
, destination
} = e
;
367 /^(?:[A-Z][0-9A-Za-z]*|[@#])?:(?:[A-Z][0-9A-Za-z]*(?:\/[A-Z][0-9A-Za-z]*)*)?$/u
368 .test(reference
?? "")
370 const [namespacePrefix
, pageName
] = splitReference(
373 const expandedNamespace
= {
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
;
386 resolvedReference
.startsWith("Editor:") &&
387 (attributes
.class ?? "").split(/\s/gu).includes("sig")
389 // This is a special internal link; do not record it.
392 // This is a non‐special internal link; record it.
393 internalLinks
.add(resolvedReference
);
396 attributes
["data-realm"] = "external";
397 const remote
= destination
??
398 ast
.references
[reference
]?.destination
;
400 externalLinks
.set(remote
, attributes
?.title
);
407 non_breaking_space
: {
409 if (titleSoFar
!= null) {
410 titleSoFar
+= "\xA0";
421 const { attributes
, children
} = e
;
422 const heading
= children
.find(({ tag
}) =>
425 const title
= (() => {
426 if (heading
?.attributes
?.title
) {
427 const result
= heading
.attributes
.title
;
428 delete heading
.attributes
.title
;
431 return heading
.level
== 1
432 ? `${namespace}:${name}`
433 : "untitled section";
436 const variantTitles
= Object
.create(null);
437 for (const attr
in attributes
) {
438 if (attr
.startsWith("v-")) {
439 Object
.defineProperty(
442 { ...READ_ONLY
, value
: attributes
[attr
] },
444 delete attributes
[attr
];
449 const definition
= Object
.create(null, {
450 title
: { ...READ_ONLY
, value
: title
},
453 value
: Object
.preventExtensions(variantTitles
),
456 if (heading
.level
== 1 && !("main" in sections
)) {
457 attributes
.id
= "main";
458 heading
.attributes
??= {};
459 heading
.attributes
.class = "main";
464 Object
.defineProperty(
470 value
: Object
.preventExtensions(definition
),
475 `GitWikiWeb: A section with the provided @id already exists: ${attributes.id}`,
483 if (titleSoFar
!= null) {
492 enter
: ({ text
}) => {
493 if (titleSoFar
!= null) {
504 const codepoint
= /^U\+([0-9A-Fa-f]+)$/u.exec(alias
)?.[1];
507 String.fromCodePoint(parseInt(codepoint, 16))
510 const resolved
= config
.symbols
?.[alias
];
511 return resolved
!= null ? str
`${resolved}` : e
;
517 Object
.defineProperties(this, {
518 ast
: { ...READ_ONLY
, value
: ast
},
519 namespace: { ...READ_ONLY
, value
: namespace },
520 name
: { ...READ_ONLY
, value
: name
},
523 value
: Object
.preventExtensions(sections
),
525 source
: { ...READ_ONLY
, value
: source
},
530 yield* this.#externalLinks
;
534 yield* this.#internalLinks
;
539 // Patches for Djot HTML renderer.
540 const { HTMLRenderer
: { prototype: htmlRendererPrototype
} } = djot
;
541 const { inTags
: upstreamInTags
} = htmlRendererPrototype
;
542 htmlRendererPrototype
.inTags = function (
546 extraAttrs
= undefined,
548 const attributes
= node
.attributes
?? NIL
;
549 if ("as" in attributes
) {
550 const newTag
= attributes
.as
;
551 delete attributes
.as
;
552 return upstreamInTags
.call(
560 return upstreamInTags
.call(
571 const config
= await
getRemoteContent("config.yaml").then((yaml
) =>
572 parseYaml(yaml
, { schema
: JSON_SCHEMA
})
574 const ls
= new Deno
.Command("git", {
575 args
: ["ls-tree", "-rz", "HEAD"],
583 ] = await Promise
.allSettled([
584 new Response(ls
.stdout
).text().then((lsout
) =>
587 .slice(0, -1) // drop the last entry; it is empty
588 .map(($) => $.split(/\s+/g))
590 new Response(ls
.stderr
).text(),
592 ]).then(logErrorsAndCollectResults
);
594 console
.error(lserr
);
598 if (!lsstatus
.success
) {
600 `GitWikiWeb: git ls-tree returned nonzero exit code: ${lsstatus.code}.`,
603 const requiredButMissingPages
= new Map([
604 ["Special:FrontPage", "front page"],
605 ["Special:NotFound", "not found"],
606 ["Special:RecentlyChanged", "recently changed"],
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) {
617 const [namespace, pageName
] = splitReference(reference
);
618 const cat
= new Deno
.Command("git", {
619 args
: ["cat-file", "blob", hash
],
623 const promise
= Promise
.allSettled([
624 new Response(cat
.stdout
).text(),
625 new Response(cat
.stderr
).text(),
627 ]).then(logErrorsAndCollectResults
).then(
628 ([source
, caterr
, catstatus
]) => {
630 console
.error(caterr
);
634 if (!catstatus
.success
) {
636 `GitWikiWeb: git cat-file returned nonzero exit code: ${catstatus.code}.`,
639 const reference
= `${namespace}:${pageName}`;
640 const page
= new GitWikiWebPage(
645 console
.warn(`Djot(${reference}): ${$.render()}`),
650 pages
.set(reference
, page
);
651 requiredButMissingPages
.delete(reference
);
655 promises
.push(promise
);
658 for (const [reference
, defaultTitle
] of requiredButMissingPages
) {
659 const [namespace, pageName
] = splitReference(reference
);
660 const source
= `# ${defaultTitle}\n`;
661 const page
= new GitWikiWebPage(
666 console
.warn(`Djot(${reference}): ${$.render()}`),
671 pages
.set(reference
, page
);
673 await Promise
.allSettled(promises
).then(
674 logErrorsAndCollectResults
,
676 const [template
, recentlyChanged
] = await Promise
.allSettled([
677 getRemoteContent("template.html"),
679 const dateParse
= new Deno
.Command("git", {
680 args
: ["rev-parse", "--after=1 week ago"],
684 const [maxAge
] = await Promise
.allSettled([
685 new Response(dateParse
.stdout
).text(),
686 new Response(dateParse
.stderr
).text(),
687 ]).then(logErrorsAndCollectResults
);
692 const revList
= new Deno
.Command("git", {
693 args
: ["rev-list", maxAge
, "--reverse", "HEAD"],
697 [commit
] = await Promise
.allSettled([
698 new Response(revList
.stdout
).text().then((list
) =>
701 new Response(revList
.stderr
).text(),
702 ]).then(logErrorsAndCollectResults
);
705 const revList2
= new Deno
.Command("git", {
706 args
: ["rev-list", "--max-count=1", "HEAD^"],
710 [commit
] = await Promise
.allSettled([
711 new Response(revList2
.stdout
).text().then((list
) =>
714 new Response(revList2
.stderr
).text(),
715 ]).then(logErrorsAndCollectResults
);
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
;
725 const show
= new Deno
.Command("git", {
729 "--format=%H%x00%cI%x00%cD",
730 recency
? `HEAD~${maxRecency - recency}` : commit
,
736 [hash
, dateTime
, humanReadable
],
737 ] = await Promise
.allSettled([
738 new Response(show
.stdout
).text().then((rev
) =>
739 rev
.trim().split("\0")
741 new Response(show
.stderr
).text(),
742 ]).then(logErrorsAndCollectResults
);
746 const ref
of (await
diffReferences(current
, !recency
))
755 results
[recency
] = { dateTime
, hash
, humanReadable
, refs
};
756 } while (recency
-- > 0 && current
&& current
!= commit
);
761 (name
) => ensureDir(`${DESTINATION}/${name}`),
763 ["style.css"].map((dependency
) =>
764 getRemoteContent(dependency
).then((source
) =>
766 `${DESTINATION}/${dependency}`,
772 ]).then(logErrorsAndCollectResults
);
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
;
780 (superRef
= superRef
.substring(0, superRef
.indexOf("/")))
782 // Iterate over potential superpages and record them if they
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
);
790 // The identified superpage does not already have other
792 subpages
.set(superRef
, new Set([pageRef
]));
796 // The superpage for the current page has not been found
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.
808 // The link was not defined; it is a redlink.
813 return { redLinks
, subpages
};
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;
826 const seeAlsoSection
= [];
827 const linksSection
= [];
828 if (subpageRefs
.size
) {
830 rawBlock
`<nav id="seealso">`,
838 rawBlock
`<section id="subpages">`,
846 listOfInternalLinks(subpageRefs
),
847 rawBlock
`</section>`,
853 if (internalLinks
.size
|| externalLinks
.size
) {
855 rawBlock
`<nav id="links">`,
859 children
: [str
`this page contains links`],
862 if (internalLinks
.size
) {
864 rawBlock
`<details open="">`,
865 rawBlock
`<summary>on this wiki</summary>`,
866 listOfInternalLinks(internalLinks
),
867 rawBlock
`</details>`,
872 if (externalLinks
.size
) {
874 rawBlock
`<details open="">`,
875 rawBlock
`<summary>elsewhere on the Web</summary>`,
880 children
: Array
.from(
882 ([destination
, text
]) => ({
888 attributes
: { "data-realm": "external" },
906 rawBlock
`</details>`,
917 const childrenAndLinks
= [
921 rawBlock
`${"\uFFFF"}`, // footnote placeholder
925 const { content
, navigation
} = (() => {
926 const navigation
= [];
927 if (pageRef
== "Special:RecentlyChanged") {
930 attributes
: { class: "recent-changes" },
933 children
: Array
.from(function* () {
935 const [index
, result
] of recentlyChanged
938 if (result
!= null) {
944 yield* listOfInternalLinks(refs
, (link
) => ({
945 tag
: index
== 0 ? "span" : "strong",
946 attributes
: { "data-recency": `${index}` },
949 ...(index
== 0 ? [] : [
951 rawInline
`<small>(<time dateTime="${dateTime}">`,
952 str
`${humanReadable}`,
953 rawInline
`</time>)</small>`,
964 isNavigationPage
= false;
965 return { content
: childrenAndLinks
, navigation
};
973 generated
: "", // will be removed later
976 children
: [str
`${title}`],
978 rawBlock
`<details id="navigation-about" open="">`,
979 rawBlock
`<summary>about this listing</summary>`,
982 rawBlock
`</article>`,
983 rawBlock
`</details>`,
986 rawBlock
`<nav id="navigation">`,
996 rawBlock
`</article>`,
1003 const attributes
= e
.attributes
?? NIL
;
1005 isNavigationPage
&& e
.level
== 1 &&
1006 attributes
?.class == "main"
1008 if ("generated" in attributes
) {
1009 delete attributes
.generated
;
1011 return { stop
: [] };
1018 if (e
.level
== 1 && e
.attributes
?.class == "main") {
1020 rawBlock
`<header class="main">`,
1022 { tag
: "verbatim", text
: pageRef
},
1023 rawBlock
`</header>`,
1033 e
.attributes
??= {};
1034 const { attributes
, children
, reference
} = e
;
1035 if (attributes
["data-realm"] == "internal") {
1037 if (redLinks
.has(reference
)) {
1039 `/Special:NotFound?path=/${reference}`;
1040 attributes
["data-notfound"] = "";
1042 e
.destination
= `/${reference}`;
1044 if (children
.length
== 0) {
1046 pages
.get(reference
)?.sections
?.main
?? NIL
;
1047 const { v
} = attributes
;
1050 str
`${section.title ?? reference}`,
1053 delete attributes
.v
;
1056 section.variantTitles?.[v] ?? section.title ??
1063 if (children
.length
== 0 && "title" in attributes
) {
1066 str
`${attributes.title}`,
1072 (attributes
.class ?? "").split(/\s/gu).includes("sig")
1076 attributes
: { class: "sig" },
1077 children
: [str
`—${"\xA0"}`, e
],
1087 if (e
.children
.length
< 1) {
1088 // The heading for this section was removed and it had
1089 // no other children.
1098 const renderedAST
= djot
.renderAST(ast
);
1099 const doc
= getDOM(template
);
1100 const result
= getDOM(djot
.renderHTML(ast
, {
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;
1112 return context
.renderAstNodeDefault(node
);
1117 const headElement
= domutils
.findOne(
1118 (node
) => node
.name
== "head",
1121 const titleElement
= domutils
.findOne(
1122 (node
) => node
.name
== "title",
1125 const contentElement
= domutils
.findOne(
1126 (node
) => node
.name
== "gitwikiweb-content",
1129 if (headElement
== null) {
1131 "GitWikiWeb: Template must explicitly include a <head> element.",
1134 domutils
.appendChild(
1136 new Element("link", {
1138 type
: "text/x.djot",
1139 href
: `/${pageRef}/source.djot`,
1142 if (titleElement
== null) {
1143 domutils
.prependChild(
1145 new Element("title", {}, [new Text(title
)]),
1148 domutils
.prependChild(titleElement
, new Text(`${title} | `));
1151 if (contentElement
== null) {
1153 "GitWikiWeb: Template did not include a <gitwikiweb-content> element.",
1156 for (const node
of [...result
]) {
1157 domutils
.prepend(contentElement
, node
);
1159 domutils
.removeElement(contentElement
);
1163 `${DESTINATION}/${pageRef}/index.html`,
1164 domSerializer(doc
, {
1166 encodeEntities
: "utf8",
1167 selfClosingTags
: true,
1170 { createNew
: true },
1175 `${DESTINATION}/${pageRef}/index.ast`,
1177 { createNew
: true },
1182 `${DESTINATION}/${pageRef}/source.djot`,
1184 { createNew
: true },
1188 await Promise
.allSettled(promises
).then(
1189 logErrorsAndCollectResults
,
1191 console
.log(`GitWikiWeb: Wrote ${pages.size} page(s).`);