#!/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)]); }