#!/usr/bin/env node
import { argv, exit } from 'node:process';
import { dirname, basename, join } from 'node:path';
import { rm, mkdir, cp, readFile, writeFile, readdir, access } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import chokidar from 'chokidar';
import chalk from 'chalk';
import { JSDOM } from 'jsdom';
import Turndown from 'turndown';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import json from 'highlight.js/lib/languages/json';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('json', json);
// Everything SISL produces lands here, and only what lands here gets deployed.
// The directory is wiped on every build and is .gitignored.
const buildDirName = 'build';
// SISL's databases. They feed the build but are never published.
const dbFiles = new Set(['bibliography.json', 'people.json', 'maturities.json']);
// The home page. Hand-authored, but post-processed to label the spec list.
const indexFile = 'index.html';
// Static files published as-is. Anything not matched here — spec sources, the
// databases above, tooling, design files — stays in the repo.
const staticFiles = [
/\.css$/,
/\.png$/,
'dcbor42.html', // redirect stub kept for old links
'tiles-lexicon.json',
'polity.json',
'LICENSE.md',
];
const staticDirs = ['fonts'];
function isStaticFile (name) {
return staticFiles.some((m) => (typeof m === 'string' ? m === name : m.test(name)));
}
// SISL — Simple Implementation of a Specification Language
// This is just a basic spec generator. Here's what it does:
// - Treats every *.html in the root (bar the index and static files) as a spec source.
// - Pulls metadata from those specs so they can reference one another.
// - Watches bibliography.json for references that specs can use.
// - Parses [[ref]] as a reference.
// - Manages metadata and styling for specs.
// - Creates anchors for definitions and the such
// - Labels the spec list on the index with each spec's maturity.
// - Writes the whole site, generated and static alike, to build/.
class SISL {
constructor (dir) {
this.baseDir = dir;
this.buildDir = join(dir, buildDirName);
this.turnDown = new Turndown({
bulletListMarker: '-',
codeBlockStyle: 'fenced',
headingStyle: 'atx',
});
this.turnDown.addRule('meta-abstract', {
filter: (n) => n.localName === 'table' && n.parentElement?.localName === 'header',
replacement: (_, n) => n.querySelector('#abstract')?.textContent,
});
this.turnDown.keep(['var', 'dfn']);
this.turnDown.addRule('references', {
filter: (n) => n.localName === 'dt' && /^ref-/.test(n.id),
replacement: (cnt, n) => `${cnt}`,
});
this.turnDown.addRule('code', {
filter: ['pre'],
replacement: (cnt) => `\`\`\`\n${cnt.replace(/\n\s+\n?$/s, '')}\n\`\`\``,
});
}
async watch () {
// For reasons that baffle me, chokidar's ignored option doesn't work correctly. So
// we filter ourselves. Files under build/ are past `depth` and never show up here.
const buildIfMatch = (path) => {
const name = basename(path);
if (!dbFiles.has(name) && !/\.(html|css)$/.test(name)) return;
this.build();
};
chokidar
.watch(this.baseDir, {
depth: 0,
ignoreInitial: true,
})
.on('add', (path) => buildIfMatch(path))
.on('change', (path) => buildIfMatch(path))
.on('unlink', (path) => buildIfMatch(path))
.on('ready', () => this.build())
;
}
async build () {
// start from a clean build, so deleted specs don't linger
await rm(this.buildDir, { recursive: true, force: true });
await mkdir(this.buildDir, { recursive: true });
// load data
const bibliography = await loadJSON(this.baseDir, 'bibliography');
const people = await loadJSON(this.baseDir, 'people');
const maturities = await loadJSON(this.baseDir, 'maturities');
const ignoreRevs = await loadIgnoreRevs(this.baseDir);
// list specs
const specs = {};
const specList = (await readdir(this.baseDir))
.filter(f => /\.html$/.test(f) && f !== indexFile && !isStaticFile(f));
for (const s of specList) {
const dom = new JSDOM(await readFile(join(this.baseDir, s), 'utf8'));
const { window: { document: doc } } = dom;
const meta = doc.querySelector('meta[name="authors"]');
const authors = meta?.getAttribute('content')?.split(/\s*,\s*/) || ['robin', 'bumblefudge'];
if (meta) meta.remove();
// short_name: what the homepage shows when the full title is too long for a card.
const snMeta = doc.querySelector('meta[name="short-name"]');
const shortName = snMeta?.getAttribute('content') || doc.title;
if (snMeta) snMeta.remove();
const updated = lastUpdated(this.baseDir, basename(s, '.html'), ignoreRevs);
specs[basename(s).replace(/\.html$/, '')] = { dom, doc, authors, shortName, updated };
}
// extract metadata from all src and add to biblio
Object.keys(specs).forEach(shortname => {
bibliography[shortname] = this.htmlifyReference({
author: joinList(specs[shortname].authors.map(au => people[au].name)),
title: specs[shortname].doc.title,
date: specs[shortname].updated,
url: `https://dasl.ing/${shortname}.html`,
});
});
for (const shortname of Object.keys(specs)) {
const { dom, doc, authors, updated } = specs[shortname];
const mat = maturities[shortname];
if (!mat) err(`No maturity set for ${shortname}`);
const el = makeEl(doc);
console.warn(`--- Processing ${shortname} "${doc.title}" (${doc.body.innerHTML.length}) ---`);
// css
const abstract = doc.querySelector('#abstract');
if (!abstract) err(`Missing abstract in ${doc.title}`);
const head = doc.querySelector('head');
const cmt = doc.createComment(`
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!!!!! GENERATED SPEC — DO NOT EDIT. !!!!
!!!!! Edit ${shortname}.html in the repository root instead.
!!!!! !!!!
!!!!! HEY YOU !!!!
!!!!! YES ***YOU****! !!!!
!!!!! !!!!
!!!!! You're about to edit a generated document. !!!!
!!!!! Don't do that! Why would you do that. !!!!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
`);
el('link', { rel: 'stylesheet', href: 'spec.css' }, [], head);
el('link', { rel: 'preload', href: 'shared.css', as: 'style' }, [], head);
head.append(doc.createTextNode('\n\n'), cmt, doc.createTextNode('\n\n'));
el('link', { rel: 'icon', href: 'data:image/svg+xml,' }, [], head);
el('link', { rel: 'alternate', type: 'text/markdown', href: `https://dasl.ing/${shortname}.md` }, [], head);
el('meta', { name: 'twitter:card', content: 'summary_large_image' }, [], head);
el('meta', { name: 'twitter:title', property: 'og:title', content: `DASL: ${doc.title}` }, [], head);
if (abstract) el('meta', { name: 'twitter:description', property: 'og:description', content: norm(abstract.textContent) }, [], head);
el('meta', { name: 'twitter:image', property: 'og:image', content: `https://dasl.ing/${shortname}.png` }, [], head);
el('meta', { name: 'twitter:image:alt', content: 'Very colourful stripes, so colourful it hurts' }, [], head);
el('meta', { name: 'twitter:url', property: 'og:url', content: 'https://dasl.ing/' }, [], head);
el('meta', { property: 'og:site_name', content: 'DASL' }, [], head);
el('meta', { property: 'og:locale', content: 'en' }, [], head);
el('meta', { name: 'theme-color', content: '#00ff75' }, [], head);
// EXPERIMENT: politi.es integration
// el('script', { src: 'https://politi.es/badge.js', defer: 'defer' }, [], head);
// main & header
const main = doc.createElement('main');
const header = el('header', {}, [el('h1', {}, [doc.title])], main);
const attribution = [];
authors
.map(au => people[au])
.forEach(({ site, name, email }, idx) => {
if (idx) attribution.push(el('br'));
attribution.push(el('a', { href: site }, [name]),
' <', el('a', { href: `mailto:${email}` }, [email]), '>');
})
el(
'table',
{},
[
el('tr', {}, [
el('th', {}, ['last updated']),
el('td', {}, [updated]),
]),
// EXPERIMENT: politi.es integration
//
el('tr', {}, [
el('th', {}, ['status']),
// el('td', {}, [el('polities-badge', {
// polity: 'dasl.ing',
// deliverable: shortname,
// })]),
el('td', {}, [el('a', {
href: 'governance.html',
class: 'status',
}, [maturityLabel(mat)])]),
]),
el('tr', {}, [
el('th', {}, ['editors']),
el('td', {}, attribution),
]),
el('tr', {}, [
el('th', {}, ['abstract']),
el('td', {}, [abstract]),
]),
],
header
);
main.append(...doc.body.childNodes);
doc.body.append(main);
// rainbow stripe, with the way home on the yellow band
const stripe = el('nav', { class: 'stripe', 'aria-label': 'Site' }, [
el('a', { href: '/' }, [el('span', { 'aria-hidden': 'true' }, ['←']), 'DASL']),
...['green', 'purple', 'orange', 'red', 'blue'].map(c => el('span', { class: c, 'aria-hidden': 'true' })),
]);
doc.body.prepend(stripe);
// footer (outside , so it stays out of the Markdown version)
el('footer', { class: 'site-footer' }, [
el('p', {}, ['Made with 👾 by the IPFS Foundation.']),
el('nav', { 'aria-label': 'Project' }, [
el('a', { href: '/#spec' }, ['← All specs']),
el('a', { href: 'https://tangled.org/dasl.ing/dasl.ing' }, ['Source']),
el('a', { href: 'https://tangled.org/dasl.ing/dasl.ing/issues' }, ['Issues']),
]),
], doc.body);
// definitions & xrefs
[...doc.querySelectorAll('dfn')].forEach(dfn => {
const id = slugify(doc, dfn, 'dfn', true);
dfn.setAttribute('id', id);
});
[...doc.querySelectorAll('a:not([href])')].forEach(a => {
const id = slugify(doc, a, 'dfn');
if (doc.getElementById(id)) {
a.setAttribute('href', `#${id}`);
a.className = 'dfn-ref';
}
else {
err(`Empty link "${a.textContent}" (#${id}) has no matching dfn.`);
}
});
// references
const refs = {};
main.innerHTML = main.innerHTML.replace(
/\[\[([\w-]+)\]\]/g,
(_, ref) => {
if (!bibliography[ref]) {
err(`No "${ref}" entry in the bibliography.`);
return `[[${ref}]]`;
}
refs[ref] = bibliography[ref];
return `[${ref}]`;
}
);
if (Object.keys(refs).length) {
const refSec = el('section', {}, [el('h2', {}, ['References'])], main);
const dl = el('dl', {}, [], refSec);
Object.keys(refs).sort().forEach(r => {
el('dt', { id: `ref-${r}` }, [`[${r}]`], dl);
const dd = el('dd', {}, [], dl);
dd.innerHTML = refs[r];
});
}
[...doc.querySelectorAll('pre')].forEach(pre => {
// make them all be wrapped in code
if (!(pre.firstElementChild?.localName === 'code')) {
const c = doc.createElement('code');
c.append(...pre.childNodes);
pre.append(c);
hljs.highlightElement(c);
}
else {
hljs.highlightElement(pre.querySelector('code'));
}
});
try {
await access(join(this.baseDir, `${shortname}.png`));
}
catch (e) {
err(`No banner found for ${shortname}.`);
}
// save
await writeFile(join(this.buildDir, `${shortname}.html`), dom.serialize());
// make some MD
await writeFile(join(this.buildDir, `${shortname}.md`), this.turnDown.turndown(doc.body.querySelector('main')));
}
await this.buildIndex(maturities, specs);
await this.copyStatic();
}
// The index lists the specs by hand, as a
per group: a
holding a link to
// the spec and a
with a one-line description. Everything else on each card is
// data: the title is the spec's short-name, the subtitle is the rest of its full
// title, and the tag comes from maturities.json. So nobody has to keep the index in
// sync with the specs by hand.
async buildIndex (maturities, specs) {
const dom = new JSDOM(await readFile(join(this.baseDir, indexFile), 'utf8'));
const { window: { document: doc } } = dom;
const el = makeEl(doc);
console.warn(`--- Processing ${indexFile} "${doc.title}" ---`);
[...doc.querySelectorAll('#spec dl')].forEach(dl => {
const cards = el('div', { class: 'cards' });
[...dl.querySelectorAll(':scope > dt')].forEach(dt => {
const link = dt.querySelector('a[href$=".html"]');
if (!link) {
err(`Spec list entry "${norm(dt.textContent)}" in ${indexFile} links to no spec.`);
return;
}
const href = link.getAttribute('href');
const shortname = basename(href, '.html');
const spec = specs[shortname];
if (!spec) err(`${indexFile} lists ${shortname}, but there is no ${shortname}.html spec.`);
const mat = maturities[shortname];
if (!mat) err(`No maturity set for ${shortname}, listed in ${indexFile}.`);
const title = spec ? norm(spec.doc.title) : norm(link.textContent);
const short = spec ? norm(spec.shortName) : title;
const sub = cardSubtitle(title, short);
const dd = dt.nextElementSibling?.localName === 'dd' ? dt.nextElementSibling : null;
const head = el('div', { class: 'card-head' }, [el('a', { href }, [short])]);
const tag = maturityTag(el, mat);
if (tag) head.append(tag);
const body = el('div', { class: 'card-body' });
if (sub) el('div', { class: 'card-sub' }, [sub], body);
if (dd) el('p', {}, [...dd.childNodes], body);
el('article', { class: 'card' }, [head, body], cards);
});
dl.replaceWith(cards);
});
await writeFile(join(this.buildDir, indexFile), dom.serialize());
}
async copyStatic () {
for (const entry of await readdir(this.baseDir, { withFileTypes: true })) {
const { name } = entry;
if (entry.isDirectory()) {
if (!staticDirs.includes(name)) continue;
await cp(join(this.baseDir, name), join(this.buildDir, name), { recursive: true });
}
else if (isStaticFile(name)) {
await cp(join(this.baseDir, name), join(this.buildDir, name));
}
}
}
htmlifyReference ({ author, title, date, url }) {
return `${esc(author)}. ${esc(title)}. ${esc(date)}. URL: ${esc(url)}`
}
}
function err (str) {
console.error(chalk.bold.bgRedBright.black(str));
}
function die (str) {
err(str);
exit(1);
}
function makeEl (doc) {
return (n, attr, kids, parent) => {
const el = doc.createElement(n);
if (attr) Object.keys(attr).forEach((k) => el.setAttribute(k, attr[k]));
if (kids) {
kids.forEach(k => {
if (typeof k === 'string') k = doc.createTextNode(k);
el.append(k);
});
}
if (parent) parent.append(el);
return el;
};
}
function slugify (doc, el, pfx, unique) {
if (el.hasAttribute('id')) return el.getAttribute('id');
let suf;
const txt = (norm(el.textContent) || 'empty').toLowerCase().replace(/\W/g, '-').replace(/-{2,}/g, '-').replace(/^-|-$/g, '');
const idify = () => [pfx, txt, suf].filter(Boolean).join('-');
let id = idify();
if (unique) {
while (doc.getElementById(id)) {
if (!suf) suf = 0;
suf++;
id = idify();
}
}
return id;
}
function today () {
return new Date().toISOString().replace(/T.+/, '');
}
// "Last updated" is the date the spec's text last changed, taken from git.
//
// Until SOURCE_CUTOVER, .html was committed build output (rewritten on
// every regen) and the text lived in .src.html. So: use commits to
// .html after the cutover, and otherwise the last commit to
// .src.html before it. Commits listed in .dates-ignore-revs (one hash or
// prefix per line, # for comments) are skipped, for repo-wide changes that
// don't touch the specs' substance. Falls back to today when git or its history
// isn't available (e.g. a shallow CI clone).
const SOURCE_CUTOVER = '905f1b5'; // "simpler gen": specs move from *.src.html to *.html
function lastUpdated (baseDir, name, ignoreRevs) {
const git = (...args) => execFileSync('git', args, {
cwd: baseDir,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d);
const ignored = (hash) => ignoreRevs.some((r) => hash.startsWith(r));
try {
let cutover = null;
try {
cutover = git('rev-parse', '--verify', '-q', `${SOURCE_CUTOVER}^{commit}`);
}
catch (e) {
// cutover not in this history; just use .html
}
const range = cutover ? [`${cutover}..HEAD`] : [];
const log = git('log', '--format=%H %cs', ...range, '--', `${name}.html`);
for (const line of log.split('\n')) {
const [hash, date] = line.split(' ');
if (hash && !ignored(hash) && isDate(date)) return date;
}
if (cutover) {
const legacy = git('log', '--format=%H %cs', `${cutover}~1`, '--', `${name}.src.html`);
for (const line of legacy.split('\n')) {
const [hash, date] = line.split(' ');
if (hash && !ignored(hash) && isDate(date)) return date;
}
}
}
catch (e) {
// fall through
}
return today();
}
async function loadIgnoreRevs (baseDir) {
try {
return (await readFile(join(baseDir, '.dates-ignore-revs'), 'utf8'))
.split('\n')
.map((l) => l.replace(/#.*/, '').trim())
.filter(Boolean);
}
catch (e) {
return [];
}
}
function esc (str) {
return (str || '').replace(/&/g, '&').replace(/ {
if (idx === (authors.length - 1)) return `& ${au}`;
return au;
}).join(', ');
}
function maturityName (mat) {
if (mat === 'hns') return 'hot new stuff';
return mat;
}
// "hot new stuff" → "Hot New Stuff", "stabilising" → "Stabilising"
function maturityLabel (mat) {
return maturityName(mat).replace(/\b\w/g, (c) => c.toUpperCase());
}
// The homepage card subtitle: the part of the full title that isn't the short name,
// when the title is "SHORT — Rest" or "Rest: SHORT". Otherwise, nothing.
// "CID — Content Identifiers" / "CID" → "Content Identifiers"
// "Tiles Protocol: Data Passing" / "Data Passing" → "Tiles Protocol"
// "Governance of the DASL Project" / "Governance" → ""
function cardSubtitle (title, short) {
if (!short || title === short) return '';
for (const sep of [' — ', ': ']) {
if (title.startsWith(short + sep)) return title.slice(short.length + sep.length);
if (title.endsWith(sep + short)) return title.slice(0, -(sep.length + short.length));
}
return '';
}
// Maturity tags on the homepage cards. Informative documents aren't on the
// maturity scale, so they get no tag.
function maturityTag (el, mat) {
if (!mat || mat === 'informative') return null;
if (mat === 'hns') {
return el('span', { class: 'tag hns' }, [
el('span', { 'aria-hidden': 'true' }, ['H', el('span', { class: 'boom' }, ['💥']), 't New Stuff']),
el('span', { class: 'vh' }, ['Hot New Stuff']),
]);
}
return el('span', { class: `tag ${mat}` }, [maturityLabel(mat)]);
}