// export GITWIKIWEB=/srv/git/GitWikiWeb
// git archive --remote=$GITWIKIWEB HEAD build.js \
// | tar -xO \
-// | deno run -A - ~/public/wiki $GITWIKIWEB
+// | deno run -A - ~/public/wiki $GITWIKIWEB current
//
// The directory `~/public/wiki` (or whatever you specify as the first
// argument to `deno run -A -`) **will be deleted** and a new static
import {
emptyDir,
ensureDir,
-} from "https://deno.land/std@0.195.0/fs/mod.ts";
-import djot from "npm:@djot/djot@0.2.3";
+} from "https://deno.land/std@0.196.0/fs/mod.ts";
+import {
+ JSON_SCHEMA,
+ parse as parseYaml,
+} from "https://deno.land/std@0.196.0/yaml/mod.ts";
+import djot from "npm:@djot/djot@0.3.1";
import { Parser } from "npm:htmlparser2@9.0.0";
import { DomHandler, Element, Text } from "npm:domhandler@5.0.3";
import * as domutils from "npm:domutils@3.1.0";
const DESTINATION = Deno.args[0] ?? "~/public/wiki";
const REMOTE = Deno.args[1] ?? "/srv/git/GitWikiWeb";
+const REV = Deno.args[2] ?? "HEAD";
const READ_ONLY = {
configurable: false,
writable: false,
};
+const NIL = Object.preventExtensions(Object.create(null));
+
const rawBlock = (strings, ...substitutions) => ({
tag: "raw_block",
format: "html",
const getRemoteContent = async (pathName) => {
const getArchive = new Deno.Command("git", {
- args: ["archive", `--remote=${REMOTE}`, "HEAD", pathName],
+ args: ["archive", `--remote=${REMOTE}`, REV, pathName],
stdout: "piped",
stderr: "piped",
}).spawn();
});
const getReferenceFromPath = (path) =>
- /Sources\/([A-Z][0-9A-Za-z]*\/[A-Z][0-9A-Za-z]*)\.djot$/u.exec(path)
- ?.[1]?.replace?.("/", ":");
+ /Sources\/([A-Z][0-9A-Za-z]*(?:\/[A-Z][0-9A-Za-z]*)+)\.djot$/u
+ .exec(path)?.[1]?.replace?.("/", ":"); // only replaces first slash
-const listOfInternalLinks = (references) => ({
+const listOfInternalLinks = (references, wrapper = ($) => $) => ({
tag: "bullet_list",
tight: true,
style: "*",
tag: "list_item",
children: [{
tag: "para",
- children: [{
+ children: [wrapper({
tag: "link",
attributes: {
"data-realm": "internal",
},
reference,
children: [],
- }],
+ })],
}],
};
},
),
});
+const diffReferences = async (hash, againstHead = false) => {
+ const diff = new Deno.Command("git", {
+ args: [
+ "diff-tree",
+ "-r",
+ "-z",
+ "--name-only",
+ "--no-renames",
+ "--diff-filter=AM",
+ ...(againstHead ? [hash, "HEAD"] : [hash]),
+ ],
+ stdout: "piped",
+ stderr: "piped",
+ }).spawn();
+ const [diffNames] = await Promise.allSettled([
+ new Response(diff.stdout).text(),
+ new Response(diff.stderr).text(),
+ ]).then(logErrorsAndCollectResults);
+ return references(diffNames.split("\0")); // returns an iterable
+};
+
function* references(paths) {
for (const path of paths) {
const reference = getReferenceFromPath(path);
#internalLinks = new Set();
#externalLinks = new Map();
- constructor(namespace, name, ast, source) {
+ constructor(namespace, name, ast, source, config) {
const internalLinks = this.#internalLinks;
const externalLinks = this.#externalLinks;
const sections = Object.create(null);
djot.applyFilter(ast, () => {
let titleSoFar = null; // used to collect strs from headings
return {
- doc: {
- enter: (_) => {},
- exit: (e) => {
- const links_section = [];
- if (internalLinks.size || externalLinks.size) {
- links_section.push(
- rawBlock`<footer>`,
- rawBlock`<nav id="links">`,
- {
- tag: "heading",
- level: 2,
- children: [str`this page contains links`],
- },
- );
- if (internalLinks.size) {
- links_section.push(
- rawBlock`<details>`,
- rawBlock`<summary>on this wiki</summary>`,
- listOfInternalLinks(internalLinks),
- rawBlock`</details>`,
- );
- } else {
- /* do nothing */
- }
- if (externalLinks.size) {
- links_section.push(
- rawBlock`<details>`,
- rawBlock`<summary>elsewhere on the Web</summary>`,
- {
+ definition_list: {
+ enter: (e) => {
+ const attributes = e.attributes ?? {};
+ if (
+ (attributes.class ?? "").split(/\s/gu).includes(
+ "timeline",
+ )
+ ) {
+ const years = new Map();
+ for (
+ const { children: [{ children: termChildren }, defn] }
+ of e.children
+ ) {
+ const { label, year } = (() => {
+ if (termChildren.length != 1) {
+ return { label: termChildren, year: termChildren };
+ } else {
+ const str = termChildren[0];
+ if (str.tag != "str") {
+ return {
+ label: termChildren,
+ year: termChildren,
+ };
+ } else {
+ const { text } = str;
+ return {
+ label: text,
+ year:
+ /^(-?[0-9X]{4})(?:-[0-9X]{2}(?:-[0-9X]{2})?)?$/u
+ .exec(text)?.[1] ?? text,
+ };
+ }
+ }
+ })();
+ const yearList = (() => {
+ const result = {
tag: "bullet_list",
- tight: true,
- style: "*",
- children: Array.from(
- externalLinks,
- ([destination, text]) => ({
- tag: "list_item",
- children: [{
- tag: "para",
- children: [{
- tag: "link",
- attributes: { "data-realm": "external" },
- destination,
- children: text
- ? [
- rawInline`<cite>`,
- str`${text}`,
- rawInline`</cite>`,
- ]
- : [
- rawInline`<code>`,
- str`${destination}`,
- rawInline`</code>`,
- ],
- }],
- }],
- }),
- ),
- },
- rawBlock`</details>`,
- );
- } else {
- /* do nothing */
+ tight: false,
+ style: "-",
+ children: [],
+ };
+ if (years.has(year)) {
+ const yearMap = years.get(year);
+ if (yearMap.has(label)) {
+ return yearMap.get(label);
+ } else {
+ yearMap.set(label, result);
+ return result;
+ }
+ } else {
+ years.set(year, new Map([[label, result]]));
+ return result;
+ }
+ })();
+ const misc = { tag: "list_item", children: [] };
+ for (const child of defn.children) {
+ if (child.tag == "bullet_list") {
+ yearList.children.push(...child.children);
+ } else {
+ misc.children.push(child);
+ }
+ }
+ if (misc.children.length > 0) {
+ yearList.children.unshift(misc);
+ } else {
+ /* do nothing */
+ }
}
- links_section.push(
- rawBlock`</nav>`,
- rawBlock`</footer>`,
+ const sorted = [...years].sort(([a], [b]) =>
+ typeof a != "string" || isNaN(a) ||
+ typeof b != "string" || isNaN(b) || +a == +b
+ ? 0
+ : 1 - 2 * (+a < b)
+ );
+ sorted.forEach((pair) =>
+ pair[1] = [...pair[1]].sort(([a], [b]) =>
+ 1 - 2 * (a < b)
+ )
);
+ return {
+ tag: "div",
+ attributes,
+ children: sorted.flatMap(([year, yearDef]) => [
+ rawBlock`<details open="">`,
+ rawBlock`<summary>`,
+ ...(Array.isArray(year) ? year : [str`${year}`]),
+ rawBlock`</summary>`,
+ rawBlock`<dl>`,
+ ...yearDef.map(([label, list]) => ({
+ tag: "div",
+ children: [
+ rawBlock`<dt>`,
+ ...(Array.isArray(label) ? label : [str`${label}`]),
+ rawBlock`</dt>`,
+ rawBlock`<dd>`,
+ list,
+ rawBlock`</dd>`,
+ ],
+ })),
+ rawBlock`</dl>`,
+ rawBlock`</details>`,
+ ]),
+ };
} else {
/* do nothing */
}
- e.children.push(...links_section);
},
+ exit: (_) => {},
},
hard_break: {
enter: (_) => {
e.attributes ??= {};
const { attributes, reference, destination } = e;
if (
- /^(?:[A-Z][0-9A-Za-z]*|[@#])?:(?:[A-Z][0-9A-Za-z]*)?$/u
+ /^(?:[A-Z][0-9A-Za-z]*|[@#])?:(?:[A-Z][0-9A-Za-z]*(?:\/[A-Z][0-9A-Za-z]*)*)?$/u
.test(reference ?? "")
) {
const [namespacePrefix, pageName] = splitReference(
const resolvedReference = pageName == ""
? `Namespace:${expandedNamespace}`
: `${expandedNamespace}:${pageName}`;
- this.#internalLinks.add(resolvedReference);
e.reference = resolvedReference;
attributes["data-realm"] = "internal";
attributes["data-pagename"] = pageName;
attributes["data-namespace"] = expandedNamespace;
+ if (
+ resolvedReference.startsWith("Editor:") &&
+ (attributes.class ?? "").split(/\s/gu).includes("sig")
+ ) {
+ // This is a special internal link; do not record it.
+ /* do nothing */
+ } else {
+ // This is a non‐special internal link; record it.
+ internalLinks.add(resolvedReference);
+ }
} else {
attributes["data-realm"] = "external";
const remote = destination ??
},
exit: (_) => {},
},
+ symb: {
+ enter: (e) => {
+ const { alias } = e;
+ const codepoint = /^U\+([0-9A-Fa-f]+)$/u.exec(alias)?.[1];
+ if (codepoint) {
+ return str`${
+ String.fromCodePoint(parseInt(codepoint, 16))
+ }`;
+ } else {
+ const resolved = config.symbols?.[alias];
+ return resolved != null ? str`${resolved}` : e;
+ }
+ },
+ },
};
});
Object.defineProperties(this, {
}
{
+ // Patches for Djot HTML renderer.
+ const { HTMLRenderer: { prototype: htmlRendererPrototype } } = djot;
+ const { inTags: upstreamInTags } = htmlRendererPrototype;
+ htmlRendererPrototype.inTags = function (
+ tag,
+ node,
+ newlines,
+ extraAttrs = undefined,
+ ) {
+ const attributes = node.attributes ?? NIL;
+ if ("as" in attributes) {
+ const newTag = attributes.as;
+ delete attributes.as;
+ return upstreamInTags.call(
+ this,
+ newTag,
+ node,
+ newlines,
+ extraAttrs,
+ );
+ } else {
+ return upstreamInTags.call(
+ this,
+ tag,
+ node,
+ newlines,
+ extraAttrs,
+ );
+ }
+ };
+}
+{
+ const config = await getRemoteContent("config.yaml").then((yaml) =>
+ parseYaml(yaml, { schema: JSON_SCHEMA })
+ );
const ls = new Deno.Command("git", {
- args: ["ls-tree", "-rz", "live"],
+ args: ["ls-tree", "-rz", "HEAD"],
stdout: "piped",
stderr: "piped",
}).spawn();
`GitWikiWeb: git cat-file returned nonzero exit code: ${catstatus.code}.`,
);
} else {
+ const reference = `${namespace}:${pageName}`;
const page = new GitWikiWebPage(
namespace,
pageName,
console.warn(`Djot(${reference}): ${$.render()}`),
}),
source,
+ config,
);
- const reference = `${namespace}:${pageName}`;
pages.set(reference, page);
requiredButMissingPages.delete(reference);
}
console.warn(`Djot(${reference}): ${$.render()}`),
}),
source,
+ config,
);
pages.set(reference, page);
}
} else {
/* do nothing */
}
- const diff = new Deno.Command("git", {
- args: [
- "diff",
- "-z",
- "--name-only",
- "--no-renames",
- "--diff-filter=AM",
- commit,
- ],
- stdout: "piped",
- stderr: "piped",
- }).spawn();
- const [diffNames] = await Promise.allSettled([
- new Response(diff.stdout).text(),
- new Response(diff.stderr).text(),
- ]).then(logErrorsAndCollectResults);
- return [...references(diffNames.split("\0"))];
+ const results = new Array(6);
+ const seen = new Set();
+ const maxRecency = Math.max(config.max_recency | 0, 0);
+ let recency = maxRecency;
+ let current;
+ do {
+ const show = new Deno.Command("git", {
+ args: [
+ "show",
+ "-s",
+ "--format=%H%x00%cI%x00%cD",
+ recency ? `HEAD~${maxRecency - recency}` : commit,
+ ],
+ stdout: "piped",
+ stderr: "piped",
+ }).spawn();
+ const [
+ [hash, dateTime, humanReadable],
+ ] = await Promise.allSettled([
+ new Response(show.stdout).text().then((rev) =>
+ rev.trim().split("\0")
+ ),
+ new Response(show.stderr).text(),
+ ]).then(logErrorsAndCollectResults);
+ const refs = [];
+ current = hash;
+ for (
+ const ref of (await diffReferences(current, !recency))
+ ) {
+ if (seen.has(ref)) {
+ /* do nothing */
+ } else {
+ refs.push(ref);
+ seen.add(ref);
+ }
+ }
+ results[recency] = { dateTime, hash, humanReadable, refs };
+ } while (recency-- > 0 && current && current != commit);
+ return results;
})(),
...Array.from(
pages.keys(),
),
]).then(logErrorsAndCollectResults);
promises.length = 0;
- const redLinks = (() => {
- const result = new Set();
- for (const page of pages.values()) {
+ const { redLinks, subpages } = (() => {
+ const redLinks = new Set();
+ const subpages = new Map();
+ for (const [pageRef, page] of pages) {
+ let superRef = pageRef;
+ while (
+ (superRef = superRef.substring(0, superRef.indexOf("/")))
+ ) {
+ // Iterate over potential superpages and record them if they
+ // actually exist.
+ if (pages.has(superRef)) {
+ // There is a superpage for the current page; record it.
+ if (subpages.has(superRef)) {
+ // The identified superpage already has other subpages.
+ subpages.get(superRef).add(pageRef);
+ } else {
+ // The identified superpage does not already have other
+ // subpages.
+ subpages.set(superRef, new Set([pageRef]));
+ }
+ break;
+ } else {
+ // The superpage for the current page has not been found
+ // yet.
+ /* do nothing */
+ }
+ }
for (const link of page.internalLinks()) {
+ // Iterate over the internal links of the current page and
+ // ensure they are all defined.
if (pages.has(link)) {
+ // The link was defined.
continue;
} else {
- result.add(link);
+ // The link was not defined; it is a redlink.
+ redLinks.add(link);
}
}
}
- return result;
+ return { redLinks, subpages };
})();
- for (
- const [pageRef, { ast, namespace, sections, source }] of pages
- ) {
+ for (const [pageRef, page] of pages) {
+ const { ast, sections, source } = page;
const title = sections.main?.title ?? pageRef;
+ const internalLinks = new Set(page.internalLinks());
+ const externalLinks = new Map(page.externalLinks());
+ const subpageRefs = subpages.get(pageRef) ?? new Set();
djot.applyFilter(ast, () => {
let isNavigationPage = true;
return {
doc: {
enter: (e) => {
+ const seeAlsoSection = [];
+ const linksSection = [];
+ if (subpageRefs.size) {
+ seeAlsoSection.push(
+ rawBlock`<nav id="seealso">`,
+ {
+ tag: "heading",
+ level: 2,
+ children: [
+ str`see also`,
+ ],
+ },
+ rawBlock`<section id="subpages">`,
+ {
+ tag: "heading",
+ level: 3,
+ children: [
+ str`subpages`,
+ ],
+ },
+ listOfInternalLinks(subpageRefs),
+ rawBlock`</section>`,
+ rawBlock`</nav>`,
+ );
+ } else {
+ /* do nothing */
+ }
+ if (internalLinks.size || externalLinks.size) {
+ linksSection.push(
+ rawBlock`<nav id="links">`,
+ {
+ tag: "heading",
+ level: 2,
+ children: [str`this page contains links`],
+ },
+ );
+ if (internalLinks.size) {
+ linksSection.push(
+ rawBlock`<details open="">`,
+ rawBlock`<summary>on this wiki</summary>`,
+ listOfInternalLinks(internalLinks),
+ rawBlock`</details>`,
+ );
+ } else {
+ /* do nothing */
+ }
+ if (externalLinks.size) {
+ linksSection.push(
+ rawBlock`<details open="">`,
+ rawBlock`<summary>elsewhere on the Web</summary>`,
+ {
+ tag: "bullet_list",
+ tight: true,
+ style: "*",
+ children: Array.from(
+ externalLinks,
+ ([destination, text]) => ({
+ tag: "list_item",
+ children: [{
+ tag: "para",
+ children: [{
+ tag: "link",
+ attributes: { "data-realm": "external" },
+ destination,
+ children: text
+ ? [
+ rawInline`<cite>`,
+ str`${text}`,
+ rawInline`</cite>`,
+ ]
+ : [
+ rawInline`<code>`,
+ str`${destination}`,
+ rawInline`</code>`,
+ ],
+ }],
+ }],
+ }),
+ ),
+ },
+ rawBlock`</details>`,
+ );
+ } else {
+ /* do nothing */
+ }
+ linksSection.push(
+ rawBlock`</nav>`,
+ );
+ } else {
+ /* do nothing */
+ }
+ const childrenAndLinks = [
+ ...e.children,
+ ...seeAlsoSection,
+ rawBlock`<footer>`,
+ rawBlock`${"\uFFFF"}`, // footnote placeholder
+ ...linksSection,
+ rawBlock`</footer>`,
+ ];
const { content, navigation } = (() => {
const navigation = [];
if (pageRef == "Special:RecentlyChanged") {
- navigation.push(
- listOfInternalLinks(recentlyChanged),
- );
+ navigation.push({
+ tag: "bullet_list",
+ attributes: { class: "recent-changes" },
+ tight: true,
+ style: "*",
+ children: Array.from(function* () {
+ for (
+ const [index, result] of recentlyChanged
+ .entries()
+ ) {
+ if (result != null) {
+ const {
+ dateTime,
+ humanReadable,
+ refs,
+ } = result;
+ yield* listOfInternalLinks(refs, (link) => ({
+ tag: index == 0 ? "span" : "strong",
+ attributes: { "data-recency": `${index}` },
+ children: [
+ link,
+ ...(index == 0 ? [] : [
+ str` `,
+ rawInline`<small>(<time dateTime="${dateTime}">`,
+ str`${humanReadable}`,
+ rawInline`</time>)</small>`,
+ ]),
+ ],
+ })).children;
+ } else {
+ /* do nothing */
+ }
+ }
+ }()).reverse(),
+ });
} else {
isNavigationPage = false;
- return { content: e.children, navigation };
+ return { content: childrenAndLinks, navigation };
}
return {
content: [
level: 1,
children: [str`${title}`],
},
- rawBlock`<details open="">`,
+ rawBlock`<details id="navigation-about" open="">`,
rawBlock`<summary>about this listing</summary>`,
- ...e.children,
+ rawBlock`<article>`,
+ ...childrenAndLinks,
+ rawBlock`</article>`,
rawBlock`</details>`,
],
navigation: [
},
heading: {
enter: (e) => {
- const attributes = e.attributes ?? Object.create(null);
+ const attributes = e.attributes ?? NIL;
if (
isNavigationPage && e.level == 1 &&
attributes?.class == "main"
if (attributes["data-realm"] == "internal") {
delete e.reference;
if (redLinks.has(reference)) {
- e.destination = `/Special:NotFound?path=/${reference}`;
+ e.destination =
+ `/Special:NotFound?path=/${reference}`;
attributes["data-notfound"] = "";
} else {
e.destination = `/${reference}`;
}
if (children.length == 0) {
const section =
- pages.get(reference)?.sections?.main ??
- Object.create(null);
+ pages.get(reference)?.sections?.main ?? NIL;
const { v } = attributes;
if (v == null) {
children.push(
},
};
});
+ const renderedAST = djot.renderAST(ast);
const doc = getDOM(template);
- const result = getDOM(`${djot.renderHTML(ast)}`);
+ const result = getDOM(djot.renderHTML(ast, {
+ overrides: {
+ raw_block: (node, context) => {
+ if (node.format == "html" && node.text == "\uFFFF") {
+ if (context.nextFootnoteIndex > 1) {
+ const result = context.renderNotes(ast.footnotes);
+ context.nextFootnoteIndex = 1;
+ return result;
+ } else {
+ return "";
+ }
+ } else {
+ return context.renderAstNodeDefault(node);
+ }
+ },
+ },
+ }));
const headElement = domutils.findOne(
(node) => node.name == "head",
doc,
"GitWikiWeb: Template did not include a <gitwikiweb-content> element.",
);
} else {
- for (const node of result) {
+ for (const node of [...result]) {
domutils.prepend(contentElement, node);
}
domutils.removeElement(contentElement);
{ createNew: true },
),
);
+ promises.push(
+ Deno.writeTextFile(
+ `${DESTINATION}/${pageRef}/index.ast`,
+ renderedAST,
+ { createNew: true },
+ ),
+ );
promises.push(
Deno.writeTextFile(
`${DESTINATION}/${pageRef}/source.djot`,