Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ assets. Working conventions and the traps that break this site silently are in
/talks/ talks index — the descriptions live here
/talks/mental-model/ deck: The Mental Model
/talks/essential-complexity/ deck: Essential Complexity
/surfaces/ every place the model is published and how each is made — reads the same model.json as /model/
/team/ the two profiles and the eight seats they hold — reads the same model.json as /model/
/timeline/ the experiences as a ledger — reads the same model.json as /model/
```
Expand All @@ -28,7 +29,7 @@ works from `file://` as well as a local server.
npm install && npx playwright install chromium
npm run serve # python3 -m http.server 8000
npm run verify # Playwright DOM assertions against all four pages, plus the sitemap
npm run og # regenerate the nine 1200×630 og:image share cards
npm run og # regenerate the 1200×630 og:image share cards
npm run og:check # do those cards still show the pages they were rendered from?
npm run test:og # unit tests for the card recipe the check compares
npm run pdf # regenerate both decks' PDF fallbacks
Expand All @@ -37,7 +38,7 @@ npm run model # fetch the pinned model and write model.json — needs t
npm run model:check # is model.json still what that commit parses to?
npm run pages # render model.json into every derived page region
npm run pages:check # do those pages still match model.json?
npm run test:build # unit tests for the three renderers
npm run test:build # unit tests for the renderers

npm run sitemap # date each sitemap URL from its page's last commit — run before committing a page
npm run sitemap:check # are those dates still what git says?
Expand Down
2 changes: 1 addition & 1 deletion build/jsonld.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const SITE = "https://blust.ch";

// Every page whose graph defines the person, rather than merely pointing at them.
const PAGES = ["index.html", "ideas/index.html", "model/index.html", "principles/index.html",
"privacy/index.html", "talks/index.html", "team/index.html", "timeline/index.html",
"privacy/index.html", "surfaces/index.html", "talks/index.html", "team/index.html", "timeline/index.html",
"talks/mental-model/index.html", "talks/essential-complexity/index.html"];

// The person is the profile whose name is the root's — the company of one — and the addresses
Expand Down
2 changes: 1 addition & 1 deletion build/note.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// The one sentence that says why a generated region does not translate, and the only copy of
// it. Two renderers write it — the principles page and the team page — and a note explaining
// it. Every renderer that writes a generated page's note takes it from here, and a note explaining
// why a page does not translate is exactly the note that must not say two different things on
// two pages.
export const NOTE_EN = "Generated from the model, so the words below are its own — and in the one " +
Expand Down
3 changes: 2 additions & 1 deletion build/pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
import { writePrinciples } from "./principles.mjs";
import { writeJsonLd } from "./jsonld.mjs";
import { writeTeam } from "./team.mjs";
import { writeSurfaces } from "./surfaces.mjs";

const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const { repo, commit } = JSON.parse(fs.readFileSync(path.join(ROOT, "source.json"), "utf8"));
Expand All @@ -30,7 +31,7 @@ if (data.commit !== commit) {
}

const check = process.argv.includes("--check");
const RENDERERS = [writePrinciples, writeTeam, writeJsonLd];
const RENDERERS = [writePrinciples, writeTeam, writeSurfaces, writeJsonLd];

const stale = RENDERERS.flatMap((write) => write(data, { check }));

Expand Down
73 changes: 72 additions & 1 deletion build/renderers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ test("the board carries one details per seat, with its slug as an address", () =
test("the note that says why a region does not translate has one home", () => {
const princ = fs.readFileSync(new URL("./principles.mjs", import.meta.url), "utf8");
const team = fs.readFileSync(new URL("./team.mjs", import.meta.url), "utf8");
for (const [name, src] of [["principles.mjs", princ], ["team.mjs", team]]) {
const surf = fs.readFileSync(new URL("./surfaces.mjs", import.meta.url), "utf8");
for (const [name, src] of [["principles.mjs", princ], ["team.mjs", team], ["surfaces.mjs", surf]]) {
assert.match(src, /from "\.\/note\.mjs"/, `${name} does not import the note`);
assert.ok(!/Generated from the model, so the words below/.test(src),
`${name} carries its own copy of the note`);
Expand Down Expand Up @@ -338,3 +339,73 @@ test("the board is followed by what each phase is, in the model's own words", ()
const block = html.slice(html.indexOf('<dl class="phases">'));
assert.ok(!/data-de/.test(block), "a phase tagline carries a translation it should not");
});

// ── the surfaces lineage ──────────────────────────────────────────────────────────────
import { writeSurfaces, makersOf, hostOf } from "./surfaces.mjs";

// One written surface and three built by two repositories, deliberately not the real model:
// this asserts the grouping, and the real model's shape is asserted by pages:check and verify.
const surface = (slug, name, fields) => ({ id: `surfaces/${slug}`, type: "surface", name, tagline: "t.",
path: `model/surfaces/${slug}.md`, fields: { source: "Local", ...fields }, sections: [] });
const SURFACES_FIXTURE = {
...FIXTURE,
repo: "someone/a-model",
entities: [
surface("zine", "Zine", { production: "written", url: "https://www.example.org/zine/" }),
surface("site", "Site", { production: "built", "built-by": "https://github.com/someone/zz-site", url: "https://example.org" }),
surface("feed", "feed listing", { production: "built", "built-by": "https://github.com/someone/aa-server", url: "https://example.org/feed?x=1" }),
surface("api", "API server", { production: "built", "built-by": "https://github.com/someone/aa-server", url: "https://api.example.org/" }),
],
};

function renderSurfacesInto(fixture) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-surfaces-"));
fs.mkdirSync(path.join(dir, "surfaces"));
fs.writeFileSync(path.join(dir, "surfaces/index.html"),
"<html><body><p class=\"tagline\">t</p>\n<!-- surfaces-note:start -->\n<!-- surfaces-note:end -->\n" +
"<div class=\"lbl\">The surfaces</div>\n<!-- surfaces:start -->\n<!-- surfaces:end --></body></html>");
writeSurfaces(fixture, { root: dir });
return fs.readFileSync(path.join(dir, "surfaces/index.html"), "utf8");
}

test("makersOf puts the hand first, then each build by repository", () => {
assert.deepEqual(makersOf(SURFACES_FIXTURE).map((m) => m.key),
["hand", "https://github.com/someone/aa-server", "https://github.com/someone/zz-site"]);
});

test("makersOf groups every surface under the maker its own fields name, sorted by name", () => {
const m = makersOf(SURFACES_FIXTURE);
assert.deepEqual(m.map((g) => g.surfaces.map((s) => s.name)), [["Zine"], ["API server", "feed listing"], ["Site"]]);
assert.equal(m[1].repo, "someone/aa-server");
});

test("a surface the page cannot place is an error, not a node left out", () => {
const f = structuredClone(SURFACES_FIXTURE);
f.entities[1].fields.production = "generated";
assert.throws(() => makersOf(f), /production this page does not draw: generated/);
const g = structuredClone(SURFACES_FIXTURE);
delete g.entities[1].fields["built-by"];
assert.throws(() => makersOf(g), /is built but names no built-by/);
});

test("hostOf shows an address without its scheme, www, query or trailing slash", () => {
assert.equal(hostOf("https://www.example.org/zine/"), "example.org/zine");
assert.equal(hostOf("https://example.org/feed?x=1"), "example.org/feed");
});

test("each surface is a button with its slug as an address, under its maker", () => {
const html = renderSurfacesInto(SURFACES_FIXTURE);
assert.equal((html.match(/class="ln-s"/g) || []).length, 4);
assert.match(html, /<li class="ln-group hand">/);
assert.match(html, /id="api" data-id="surfaces\/api" data-maker="https:\/\/github.com\/someone\/aa-server" aria-pressed="false"/);
assert.match(html, /<div class="nm">a-model<\/div>/);
assert.ok(html.indexOf('id="zine"') < html.indexOf('id="api"'), "the hand's surfaces do not come first");
});

test("the surfaces page missing either marker is an error, not a page half-generated", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-surfaces-"));
fs.mkdirSync(path.join(dir, "surfaces"));
fs.writeFileSync(path.join(dir, "surfaces/index.html"),
"<html><body><!-- surfaces:start -->\n<!-- surfaces:end --></body></html>");
assert.throws(() => writeSurfaces(SURFACES_FIXTURE, { root: dir }), /surfaces-note:start/);
});
124 changes: 124 additions & 0 deletions build/surfaces.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Renders the lineage — the model, whoever makes each surface, and the surfaces — into
// surfaces/index.html from the artifact.
//
// The nodes are generated for the reason the team board is: a crawler and an assistant read them
// without running anything, and pages:check fails the moment they fall behind model.json. The
// lines between them are not. They are drawn by the page from where the nodes landed, because
// only the browser knows that, and they carry nothing the nesting of the lists does not already
// say. The cards are fetched on demand, as on /team/.
//
// No line is typed. A surface sits under the maker its own `production` and `built-by` name, so
// a surface that changes how it is made moves without anyone editing this file.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NOTE_EN, NOTE_DE } from "./note.mjs";

const HERE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const START = "<!-- surfaces:start -->";
const END = "<!-- surfaces:end -->";
const NOTE_START = "<!-- surfaces-note:start -->";
const NOTE_END = "<!-- surfaces-note:end -->";
const GITHUB = "https://github.com/";

const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");

// What a visitor reads as the address: host and path, without the scheme or a trailing slash,
// the way the card writes a link out of the model.
export const hostOf = (url) => String(url).replace(/^https?:\/\//, "").replace(/^www\./, "")
.replace(/\?.*$/, "").replace(/\/$/, "");

// The makers in the order the drawing stacks them: the hand first, because a person is the one
// maker nothing re-runs, then each build by the repository's name. Inside a maker, surfaces sort
// by name in English, one rule a reader can predict.
export function makersOf(data) {
const surfaces = data.entities.filter((e) => e.type === "surface");
if (!surfaces.length) throw new Error("the model holds no surface; the page would draw nothing");
const byKey = new Map();
for (const s of surfaces) {
const f = s.fields || {};
let key;
if (f.production === "written") key = "hand";
else if (f.production === "built") {
if (!f["built-by"]) throw new Error(`${s.path} is built but names no built-by`);
if (!f["built-by"].startsWith(GITHUB)) throw new Error(`${s.path} is built by something that is not a repository: ${f["built-by"]}`);
key = f["built-by"];
} else throw new Error(`${s.path} has a production this page does not draw: ${f.production}`);
if (!f.url) throw new Error(`${s.path} names no url`);
if (!byKey.has(key)) byKey.set(key, []);
byKey.get(key).push(s);
}
return [...byKey.entries()]
.sort(([a], [b]) => (a === "hand" ? -1 : b === "hand" ? 1 : a.localeCompare(b, "en")))
.map(([key, list]) => ({
key,
hand: key === "hand",
repo: key === "hand" ? null : key.slice(GITHUB.length),
surfaces: list.sort((a, b) => a.name.localeCompare(b.name, "en")),
}));
}

const slug = (s) => s.id.split("/").slice(1).join("-");

// The same two natures /team/ tells apart by form, never by a second hue: the pen is filled
// because a person holds it, the build is outlined because a machine runs it. The symbols are
// in the page.
const MARK_HAND = '<svg class="mk hand" aria-hidden="true"><use href="#m-hand"/></svg>';
const MARK_BUILD = '<svg class="mk build" aria-hidden="true"><use href="#m-build"/></svg>';

function render(data) {
const makers = makersOf(data);
const out = [];
const name = String(data.repo || "").split("/")[1];
if (!name) throw new Error(`model.json names no repository to draw as the model: ${data.repo}`);

out.push(` <div class="lineage" id="lineage">`);
out.push(` <svg class="wires" id="wires" aria-hidden="true"></svg>`);
out.push(` <div class="ln-model" id="lnmodel"><div class="lbl" data-de="Das Modell">The model</div>` +
`<div class="nm">${esc(name)}</div><div class="at mono">@${esc(data.commit.slice(0, 7))}</div></div>`);
out.push(` <ul class="ln-groups">`);
for (const m of makers) {
const who = m.hand
? `<span data-de="Der Owner">The owner</span><span class="how" data-de="von Hand">by hand</span>`
: `${esc(m.repo.split("/").pop())}<span class="how" data-de="Build">build</span>`;
out.push(` <li class="ln-group${m.hand ? " hand" : ""}">`);
out.push(` <div class="ln-maker" data-maker="${esc(m.key)}">${m.hand ? MARK_HAND : MARK_BUILD}` +
`<span class="who">${who}</span></div>`);
out.push(` <ul class="ln-surfaces">`);
for (const s of m.surfaces) {
out.push(` <li><button class="ln-s" type="button" id="${esc(slug(s))}" data-id="${esc(s.id)}" ` +
`data-maker="${esc(m.key)}" aria-pressed="false" aria-controls="lnpanel">` +
`<span class="nm">${esc(s.name)}</span><span class="host">${esc(hostOf(s.fields.url))}</span></button></li>`);
}
out.push(` </ul>`);
out.push(` </li>`);
}
out.push(` </ul>`);
out.push(` </div>`);
return out.join("\n");
}

export function writeSurfaces(data, { check = false, root = HERE } = {}) {
const rel = "surfaces/index.html";
const file = path.join(root, rel);
const page = fs.readFileSync(file, "utf8");

const regions = [
[NOTE_START, NOTE_END, " ",
() => ` <p class="note" data-de="${esc(NOTE_DE)}">${esc(NOTE_EN)}</p>`],
[START, END, " ", () => render(data)],
];
let next = page;
for (const [start, end, indent, body] of regions) {
const re = new RegExp(`${start}[\\s\\S]*?${end}`);
if (!re.test(next)) throw new Error(`${rel} has no ${start} … ${end} block`);
// The function form, so a `$&` in a surface's name is two characters and not a reference.
next = next.replace(re, () => `${start}\n${body()}\n${indent}${end}`);
}

if (next === page) return [];
if (check) return [rel];
fs.writeFileSync(file, next);
return [];
}
21 changes: 14 additions & 7 deletions card.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,24 @@
return a;
}
function resolve(data, text){ for (var i = 0; i < data.entities.length; i++) if (data.entities[i].name === text) return data.entities[i].id; return null; }
// Markdown inline code — the one span-level mark the model's fixed shape uses — becomes
// code.mono; a URL inside a sentence becomes a link. Appended as nodes, never as innerHTML:
// these strings come out of the data block, and the day a name contains a "<" an innerHTML
// assignment would start parsing it as markup.
// Two span-level marks reach a card from the model's fixed shape. Inline code becomes
// code.mono, and bold becomes b, because a list such as a surface's What it shows writes every
// item as a bold name and a sentence. Code is split out first, so asterisks inside backticks
// stay characters. A URL inside a sentence becomes a link. Appended as nodes, never as
// innerHTML: these strings come out of the data block, and the day a name contains a "<" an
// innerHTML assignment would start parsing it as markup.
function inline(el, text){
String(text).split(/`([^`]+)`/).forEach(function(part, i){
if (!part) return;
if (i % 2) { el.appendChild(h("code", part, "mono")); return; }
part.split(/(https?:\/\/[^\s)\]]+)/).forEach(function(bit, j){
if (!bit) return;
el.appendChild(j % 2 ? extLink(bit.replace(/[.,;:]+$/, "")) : document.createTextNode(bit));
part.split(/\*\*([^*]+)\*\*/).forEach(function(run, k){
if (!run) return;
var into = el;
if (k % 2) { into = h("b", null); el.appendChild(into); }
run.split(/(https?:\/\/[^\s)\]]+)/).forEach(function(bit, j){
if (!bit) return;
into.appendChild(j % 2 ? extLink(bit.replace(/[.,;:]+$/, "")) : document.createTextNode(bit));
});
});
});
return el;
Expand Down
Loading