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
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.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";
47 const DESTINATION
= Deno
.args
[0] ?? "~/public/wiki";
48 const REMOTE
= Deno
.args
[1] ?? "/srv/git/GitWikiWeb";
56 const rawBlock
= (strings
, ...substitutions
) => ({
59 text
: String
.raw(strings
, substitutions
),
61 const rawInline
= (strings
, ...substitutions
) => ({
64 text
: String
.raw(strings
, substitutions
),
66 const str
= (strings
, ...substitutions
) => ({
68 text
: String
.raw(strings
, substitutions
),
71 const getDOM
= (source
) => {
73 const handler
= new DomHandler((error
, dom
) => {
75 throw new Error("GitWikiWeb: Failed to process DOM.", {
82 const parser
= new Parser(handler
);
88 const getRemoteContent
= async (pathName
) => {
89 const getArchive
= new Deno
.Command("git", {
90 args
: ["archive", `--remote=${REMOTE}`, "HEAD", pathName
],
94 const untar
= new Deno
.Command("tar", {
100 getArchive
.stdout
.pipeTo(untar
.stdin
);
107 ] = await Promise
.allSettled([
108 new Response(getArchive
.stderr
).text(),
110 new Response(untar
.stdout
).text(),
111 new Response(untar
.stderr
).text(),
113 ]).then(logErrorsAndCollectResults
);
115 console
.error(err1
+ err2
);
119 if (!getArchiveStatus
.success
) {
121 `GitWikiWeb: git archive returned nonzero exit code: ${getArchiveStatus.code}.`,
123 } else if (!untarStatus
.success
) {
125 `GitWikiWeb: tar returned nonzero exit code: ${untarStatus.code}.`,
132 const logErrorsAndCollectResults
= (results
) =>
133 results
.map(({ value
, reason
}) => {
135 console
.error(reason
);
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
?.("/", ":");
146 const listOfInternalLinks
= (references
) => ({
150 children
: Array
.from(
153 const [namespace, pageName
] = splitReference(reference
);
161 "data-realm": "internal",
162 "data-pagename": pageName
,
163 "data-namespace": namespace,
174 function* references(paths
) {
175 for (const path
of paths
) {
176 const reference
= getReferenceFromPath(path
);
185 const splitReference
= (reference
) => {
186 const colonIndex
= reference
.indexOf(":");
188 reference
.substring(0, colonIndex
),
189 reference
.substring(colonIndex
+ 1),
193 class GitWikiWebPage
{
194 #internalLinks
= new Set();
195 #externalLinks
= new Map();
197 constructor(namespace, name
, ast
, source
) {
198 const internalLinks
= this.#internalLinks
;
199 const externalLinks
= this.#externalLinks
;
200 const sections
= Object
.create(null);
201 djot
.applyFilter(ast
, () => {
202 let titleSoFar
= null; // used to collect strs from headings
207 const links_section
= [];
208 if (internalLinks
.size
|| externalLinks
.size
) {
211 rawBlock
`<nav id="links">`,
215 children
: [str
`this page contains links`],
218 if (internalLinks
.size
) {
221 rawBlock
`<summary>on this wiki</summary>`,
222 listOfInternalLinks(internalLinks
),
223 rawBlock
`</details>`,
228 if (externalLinks
.size
) {
231 rawBlock
`<summary>elsewhere on the Web</summary>`,
236 children
: Array
.from(
238 ([destination
, text
]) => ({
244 attributes
: { "data-realm": "external" },
262 rawBlock
`</details>`,
274 e
.children
.push(...links_section
);
279 if (titleSoFar
!= null) {
293 const { attributes
} = e
;
294 attributes
.title
??= titleSoFar
;
301 const { attributes
, reference
, destination
} = e
;
303 /^(?:[A-Z][0-9A-Za-z]*|[@#])?:(?:[A-Z][0-9A-Za-z]*)?$/u
304 .test(reference
?? "")
306 const [namespacePrefix
, pageName
] = splitReference(
309 const expandedNamespace
= {
313 }[namespacePrefix
] ?? namespacePrefix
;
314 const resolvedReference
= pageName
== ""
315 ? `Namespace:${expandedNamespace}`
316 : `${expandedNamespace}:${pageName}`;
317 this.#internalLinks
.add(resolvedReference
);
318 e
.reference
= resolvedReference
;
319 attributes
["data-realm"] = "internal";
320 attributes
["data-pagename"] = pageName
;
321 attributes
["data-namespace"] = expandedNamespace
;
323 attributes
["data-realm"] = "external";
324 const remote
= destination
??
325 ast
.references
[reference
]?.destination
;
327 externalLinks
.set(remote
, attributes
?.title
);
334 non_breaking_space
: {
336 if (titleSoFar
!= null) {
337 titleSoFar
+= "\xA0";
348 const { attributes
, children
} = e
;
349 const heading
= children
.find(({ tag
}) =>
352 const title
= (() => {
353 if (heading
?.attributes
?.title
) {
354 const result
= heading
.attributes
.title
;
355 delete heading
.attributes
.title
;
358 return heading
.level
== 1
359 ? `${namespace}:${name}`
360 : "untitled section";
363 const variantTitles
= Object
.create(null);
364 for (const attr
in attributes
) {
365 if (attr
.startsWith("v-")) {
366 Object
.defineProperty(
369 { ...READ_ONLY
, value
: attributes
[attr
] },
371 delete attributes
[attr
];
376 const definition
= Object
.create(null, {
377 title
: { ...READ_ONLY
, value
: title
},
380 value
: Object
.preventExtensions(variantTitles
),
383 if (heading
.level
== 1 && !("main" in sections
)) {
384 attributes
.id
= "main";
385 heading
.attributes
??= {};
386 heading
.attributes
.class = "main";
391 Object
.defineProperty(
397 value
: Object
.preventExtensions(definition
),
402 `GitWikiWeb: A section with the provided @id already exists: ${attributes.id}`,
410 if (titleSoFar
!= null) {
419 enter
: ({ text
}) => {
420 if (titleSoFar
!= null) {
430 Object
.defineProperties(this, {
431 ast
: { ...READ_ONLY
, value
: ast
},
432 namespace: { ...READ_ONLY
, value
: namespace },
433 name
: { ...READ_ONLY
, value
: name
},
436 value
: Object
.preventExtensions(sections
),
438 source
: { ...READ_ONLY
, value
: source
},
443 yield* this.#externalLinks
;
447 yield* this.#internalLinks
;
452 const ls
= new Deno
.Command("git", {
453 args
: ["ls-tree", "-rz", "live"],
461 ] = await Promise
.allSettled([
462 new Response(ls
.stdout
).text().then((lsout
) =>
465 .slice(0, -1) // drop the last entry; it is empty
466 .map(($) => $.split(/\s+/g))
468 new Response(ls
.stderr
).text(),
470 ]).then(logErrorsAndCollectResults
);
472 console
.error(lserr
);
476 if (!lsstatus
.success
) {
478 `GitWikiWeb: git ls-tree returned nonzero exit code: ${lsstatus.code}.`,
481 const requiredButMissingPages
= new Map([
482 ["Special:FrontPage", "front page"],
483 ["Special:NotFound", "not found"],
484 ["Special:RecentlyChanged", "recently changed"],
486 const pages
= new Map();
487 const promises
= [emptyDir(DESTINATION
)];
488 for (const object
of objects
) {
489 const hash
= object
[2];
490 const path
= object
[3];
491 const reference
= getReferenceFromPath(path
);
492 if (reference
== null) {
495 const [namespace, pageName
] = splitReference(reference
);
496 const cat
= new Deno
.Command("git", {
497 args
: ["cat-file", "blob", hash
],
501 const promise
= Promise
.allSettled([
502 new Response(cat
.stdout
).text(),
503 new Response(cat
.stderr
).text(),
505 ]).then(logErrorsAndCollectResults
).then(
506 ([source
, caterr
, catstatus
]) => {
508 console
.error(caterr
);
512 if (!catstatus
.success
) {
514 `GitWikiWeb: git cat-file returned nonzero exit code: ${catstatus.code}.`,
517 const page
= new GitWikiWebPage(
522 console
.warn(`Djot(${reference}): ${$.render()}`),
526 const reference
= `${namespace}:${pageName}`;
527 pages
.set(reference
, page
);
528 requiredButMissingPages
.delete(reference
);
532 promises
.push(promise
);
535 for (const [reference
, defaultTitle
] of requiredButMissingPages
) {
536 const [namespace, pageName
] = splitReference(reference
);
537 const source
= `# ${defaultTitle}\n`;
538 const page
= new GitWikiWebPage(
543 console
.warn(`Djot(${reference}): ${$.render()}`),
547 pages
.set(reference
, page
);
549 await Promise
.allSettled(promises
).then(
550 logErrorsAndCollectResults
,
552 const [template
, recentlyChanged
] = await Promise
.allSettled([
553 getRemoteContent("template.html"),
555 const dateParse
= new Deno
.Command("git", {
556 args
: ["rev-parse", "--after=1 week ago"],
560 const [maxAge
] = await Promise
.allSettled([
561 new Response(dateParse
.stdout
).text(),
562 new Response(dateParse
.stderr
).text(),
563 ]).then(logErrorsAndCollectResults
);
568 const revList
= new Deno
.Command("git", {
569 args
: ["rev-list", maxAge
, "--reverse", "HEAD"],
573 [commit
] = await Promise
.allSettled([
574 new Response(revList
.stdout
).text().then((list
) =>
577 new Response(revList
.stderr
).text(),
578 ]).then(logErrorsAndCollectResults
);
581 const revList2
= new Deno
.Command("git", {
582 args
: ["rev-list", "--max-count=1", "HEAD^"],
586 [commit
] = await Promise
.allSettled([
587 new Response(revList2
.stdout
).text().then((list
) =>
590 new Response(revList2
.stderr
).text(),
591 ]).then(logErrorsAndCollectResults
);
595 const diff
= new Deno
.Command("git", {
607 const [diffNames
] = await Promise
.allSettled([
608 new Response(diff
.stdout
).text(),
609 new Response(diff
.stderr
).text(),
610 ]).then(logErrorsAndCollectResults
);
611 return [...references(diffNames
.split("\0"))];
615 (name
) => ensureDir(`${DESTINATION}/${name}`),
617 ["style.css"].map((dependency
) =>
618 getRemoteContent(dependency
).then((source
) =>
620 `${DESTINATION}/${dependency}`,
626 ]).then(logErrorsAndCollectResults
);
628 const redLinks
= (() => {
629 const result
= new Set();
630 for (const page
of pages
.values()) {
631 for (const link
of page
.internalLinks()) {
632 if (pages
.has(link
)) {
642 const [pageRef
, { ast
, namespace, sections
, source
}] of pages
644 const title
= sections
.main
?.title
?? pageRef
;
645 djot
.applyFilter(ast
, () => {
646 let isNavigationPage
= true;
650 const { content
, navigation
} = (() => {
651 const navigation
= [];
652 if (pageRef
== "Special:RecentlyChanged") {
654 listOfInternalLinks(recentlyChanged
),
657 isNavigationPage
= false;
658 return { content
: e
.children
, navigation
};
666 generated
: "", // will be removed later
669 children
: [str
`${title}`],
671 rawBlock
`<details open="">`,
672 rawBlock
`<summary>about this listing</summary>`,
674 rawBlock
`</details>`,
677 rawBlock
`<nav id="navigation">`,
687 rawBlock
`</article>`,
694 const attributes
= e
.attributes
?? Object
.create(null);
696 isNavigationPage
&& e
.level
== 1 &&
697 attributes
?.class == "main"
699 if ("generated" in attributes
) {
700 delete attributes
.generated
;
709 if (e
.level
== 1 && e
.attributes
?.class == "main") {
711 rawBlock
`<header class="main">`,
713 { tag
: "verbatim", text
: pageRef
},
725 const { attributes
, children
, reference
} = e
;
726 if (attributes
["data-realm"] == "internal") {
728 if (redLinks
.has(reference
)) {
729 e
.destination
= `/Special:NotFound?path=/${reference}`;
730 attributes
["data-notfound"] = "";
732 e
.destination
= `/${reference}`;
734 if (children
.length
== 0) {
736 pages
.get(reference
)?.sections
?.main
??
738 const { v
} = attributes
;
741 str
`${section.title ?? reference}`,
747 section.variantTitles?.[v] ?? section.title ??
754 if (children
.length
== 0 && "title" in attributes
) {
757 str
`${attributes.title}`,
763 (attributes
.class ?? "").split(/\s/gu).includes("sig")
767 attributes
: { class: "sig" },
768 children
: [str
`—${"\xA0"}`, e
],
778 if (e
.children
.length
< 1) {
779 // The heading for this section was removed and it had
780 // no other children.
789 const doc
= getDOM(template
);
790 const result
= getDOM(`${djot.renderHTML(ast)}`);
791 const headElement
= domutils
.findOne(
792 (node
) => node
.name
== "head",
795 const titleElement
= domutils
.findOne(
796 (node
) => node
.name
== "title",
799 const contentElement
= domutils
.findOne(
800 (node
) => node
.name
== "gitwikiweb-content",
803 if (headElement
== null) {
805 "GitWikiWeb: Template must explicitly include a <head> element.",
808 domutils
.appendChild(
810 new Element("link", {
813 href
: `/${pageRef}/source.djot`,
816 if (titleElement
== null) {
817 domutils
.prependChild(
819 new Element("title", {}, [new Text(title
)]),
822 domutils
.prependChild(titleElement
, new Text(`${title} | `));
825 if (contentElement
== null) {
827 "GitWikiWeb: Template did not include a <gitwikiweb-content> element.",
830 for (const node
of result
) {
831 domutils
.prepend(contentElement
, node
);
833 domutils
.removeElement(contentElement
);
837 `${DESTINATION}/${pageRef}/index.html`,
840 encodeEntities
: "utf8",
841 selfClosingTags
: true,
849 `${DESTINATION}/${pageRef}/source.djot`,
855 await Promise
.allSettled(promises
).then(
856 logErrorsAndCollectResults
,
858 console
.log(`GitWikiWeb: Wrote ${pages.size} page(s).`);