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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ result-*
graphify-out/
# lychee's answers during `just md-quality --online`
.lycheecache
# The Unison codebase of `just unison-install` (unison/refpulse/README.md)
unison/.codebase/
unison/refpulse/install.output.md
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ is cited, and friends under an alias from `PERSONAS.md`. `/note` makes them; `ju
is a measure, not a gate: no check fails on a score.
- `works/` holds full texts copied as is, only when free to copy in the US and Brazil, indexed in
`works/index.md`. The `ouro-get` skill gets them; never trim or edit a file there.
- `unison/refpulse/` is a Unison service the site's Live button calls (`just unison-*`). What it
answers is live, never a fact of the book; the site works when it is down.
- A skill in `.claude/skills/` is written here and names its sources; do not copy a skill from
another repository. After a change to one, run `scripts/readme.sh --lock`.
- The book's colours are black and red, with yellow letters for the title, after Hoppe's
Expand Down
2 changes: 1 addition & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ SPDX-FileCopyrightText = "2026 Matheus Hoffmann"
SPDX-License-Identifier = "CC-BY-4.0"

[[annotations]]
path = ["scripts/**", "tools/**", ".github/**", "justfile", "flake.nix", "flake.lock", "site/**"]
path = ["scripts/**", "tools/**", ".github/**", "justfile", "flake.nix", "flake.lock", "site/**", "unison/**/*.u"]
precedence = "closest"
SPDX-FileCopyrightText = "2026 Matheus Hoffmann"
SPDX-License-Identifier = "AGPL-3.0-only"
Expand Down
2 changes: 2 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
site = gleam-website.devShells.${system}.default;
# md: the three Markdown analysers of `just md-quality` (.github/workflows/md.yml).
md = pkgs.mkShell { packages = [ pkgs.markdownlint-cli2 pkgs.vale pkgs.lychee ]; };
# unison: the Unison Codebase Manager for unison/ (the refpulse prototype, `just unison-*`).
unison = pkgs.mkShell { packages = [ pkgs.unison-ucm ]; };
})
paper.devShells;
};
Expand Down
14 changes: 14 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ self-test:
md-quality *args:
nix develop --extra-experimental-features 'nix-command flakes' --no-update-lock-file .#md --command scripts/md-quality.sh {{args}}

# Unison (unison/refpulse/README.md): make the refpulse project in unison/.codebase/, with its
# libraries from Unison Share. Run it again after a change to refpulse.u.
unison-install:
rm -rf unison/.codebase unison/refpulse/install.output.md
nix develop --no-update-lock-file .#unison --command ucm -C unison/.codebase transcript.in-place unison/refpulse/install.md

# Serve refpulse on this machine, in Unison Cloud's local runtime; it prints its URL. Enter stops it.
unison-serve:
nix develop --no-update-lock-file .#unison --command ucm -c unison/.codebase run refpulse/main:.refpulse.deploy.local

# Deploy refpulse to Unison Cloud. Log in once first: `ucm -c unison/.codebase`, then `auth.login`.
unison-deploy:
nix develop --no-update-lock-file .#unison --command ucm -c unison/.codebase run refpulse/main:.refpulse.deploy.cloud

# Fetch the public ouroboric-dev repositories into build/ouroboric/ and list what changed since
# OUROBORIC.md recorded them. `/update-ouroboric` (a Claude Code command) then updates the digests.
ouroboric-sources:
Expand Down
149 changes: 147 additions & 2 deletions site/src/closed_source_site/app.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import closed_source_site/content.{
import closed_source_site/ffi
import gleam/int
import gleam/list
import gleam/result
import gleam/string
import lustre/attribute.{type Attribute}
import lustre/effect.{type Effect}
Expand All @@ -23,20 +24,32 @@ pub type Text {
}

pub type Model {
Model(text: Text, cite: Int, copied: Bool)
Model(text: Text, cite: Int, copied: Bool, pulse: Pulse)
}

/// The live DOI check: a request to the refpulse service on Unison Cloud (unison/refpulse/).
pub type Pulse {
Idle
Running
/// The service answered: where, in how many milliseconds, and #(doi, status, ok) per DOI.
Done(endpoint: String, ms: Int, results: List(#(String, Int, Bool)))
Failed(String)
}

pub type Msg {
ShowText(Text)
ShowCite(Int)
Copy
Copied(Bool)
/// Ask the refpulse service about the book's DOIs.
CheckDois
Pulsed(Result(String, String))
/// Scroll the timeline of the thinkers' lives sideways, by this many pixels.
ScrollTimeline(Int)
}

pub fn initial() -> Model {
Model(text: Original, cite: 0, copied: False)
Model(text: Original, cite: 0, copied: False, pulse: Idle)
}

pub fn init(_: Nil) -> #(Model, Effect(Msg)) {
Expand All @@ -52,6 +65,15 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
ffi.copy(citation(model.cite).1, fn(ok) { dispatch(Copied(ok)) })
})
Copied(ok) -> #(Model(..model, copied: ok), effect.none())
CheckDois -> #(Model(..model, pulse: Running), {
use dispatch <- effect.from
ffi.refpulse(refpulse_url, book_dois(), fn(r) { dispatch(Pulsed(r)) })
})
Pulsed(Ok(text)) -> #(
Model(..model, pulse: parse_pulse(text)),
effect.none(),
)
Pulsed(Error(e)) -> #(Model(..model, pulse: Failed(e)), effect.none())
ScrollTimeline(dx) -> #(model, {
use _ <- effect.from
ffi.scroll_by(timeline_id, dx)
Expand Down Expand Up @@ -95,6 +117,7 @@ pub fn view(model: Model) -> Element(Msg) {
authors_section(),
fabric_section(),
ci_section(),
live_section(model),
scores_section(),
cite_section(model),
]),
Expand Down Expand Up @@ -410,6 +433,7 @@ fn jump() -> Element(msg) {
link("#authors", "Author"),
link("#fabric", "How it is made"),
link("#ci", "CI"),
link("#live", "Live"),
link("#scores", "Scores"),
link("#cite", "Cite"),
])
Expand Down Expand Up @@ -689,6 +713,127 @@ fn timeline() -> Element(Msg) {
}
}

/// The refpulse service's URL on Unison Cloud, printed by `just unison-deploy`. Empty until it is
/// deployed: then the button says so, unless the page is opened with `?refpulse=<url>` (a local
/// `just unison-serve`, for example).
pub const refpulse_url = ""

/// The book's own DOIs: the concept DOI, then each version's.
pub fn book_dois() -> List(String) {
[content.doi, ..list.map(content.version_dois, fn(v) { v.1 })]
|> list.unique
}

/// The FFI's answer: "ms\tendpoint", then one "doi\tstatus\tok" line per DOI.
pub fn parse_pulse(text: String) -> Pulse {
case string.split(text, "\n") {
[head, ..rows] ->
case string.split(head, "\t") {
[ms, endpoint] ->
Done(
endpoint,
result.unwrap(int.parse(ms), 0),
list.filter_map(rows, fn(row) {
case string.split(row, "\t") {
[doi, status, ok] ->
Ok(#(doi, result.unwrap(int.parse(status), 0), ok == "true"))
_ -> Error(Nil)
}
}),
)
_ -> Failed("the service's answer could not be read")
}
[] -> Failed("the service gave an empty answer")
}
}

fn live_section(model: Model) -> Element(Msg) {
html.section([attribute.aria_labelledby("live")], [
section_head(
"live",
"Live",
external(
content.repo <> "/blob/main/unison/refpulse/README.md",
"refpulse on Unison",
),
),
html.p([attribute.class("sub")], [
html.text(
"This page is static, on GitHub Pages. The button asks a Unison service, refpulse, to check now that each of the book's DOIs resolves at doi.org, and shows its answer here. A live answer, not a fact of the book.",
),
]),
html.button(
[
attribute.class("pulse-button"),
attribute.disabled(model.pulse == Running),
event.on_click(CheckDois),
],
[
html.text(case model.pulse {
Running -> "Checking…"
_ -> "Check the DOIs now"
}),
],
),
html.div(
[attribute.class("pulse"), attribute.attribute("aria-live", "polite")],
[pulse_view(model.pulse)],
),
])
}

fn pulse_view(pulse: Pulse) -> Element(msg) {
case pulse {
Idle -> element.none()
Running -> html.p([], [html.text("Asking refpulse…")])
Failed(e) -> html.p([attribute.class("pulse-failed")], [html.text(e)])
Done(endpoint, ms, results) -> {
let ok = list.count(results, fn(r) { r.2 })
html.div([], [
html.p([], [
html.b([], [
html.text(
int.to_string(ok)
<> " of "
<> int.to_string(list.length(results))
<> " DOIs resolve",
),
]),
html.text(
" · answered by "
<> endpoint
<> " in "
<> int.to_string(ms)
<> " ms",
),
]),
html.ul(
[attribute.class("pulse-results")],
list.map(results, fn(r) {
let #(doi, status, good) = r
html.li(
[
attribute.class(case good {
True -> "is-ok"
False -> "is-dead"
}),
],
[
html.text(case good {
True -> "✓ "
False -> "✗ "
}),
external("https://doi.org/" <> doi, doi),
html.text(" · " <> int.to_string(status)),
],
)
}),
),
])
}
}
}

fn notes_section() -> Element(msg) {
html.section(
[attribute.aria_labelledby("notes"), attribute.class("feature")],
Expand Down
9 changes: 9 additions & 0 deletions site/src/closed_source_site/ffi.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,12 @@ pub fn copy(text: String, done: fn(Bool) -> Nil) -> Nil
/// Scroll the element with this id sideways, smoothly unless the reader asks for less motion.
@external(javascript, "../closed_source_site_ffi.mjs", "scroll_by")
pub fn scroll_by(id: String, dx: Int) -> Nil

/// Ask the refpulse service (its URL, or the page's `?refpulse=` parameter) about these DOIs.
/// The callback gets "ms\tendpoint" and one "doi\tstatus\tok" line per DOI, or why it failed.
@external(javascript, "../closed_source_site_ffi.mjs", "refpulse")
pub fn refpulse(
url: String,
dois: List(String),
done: fn(Result(String, String)) -> Nil,
) -> Nil
18 changes: 18 additions & 0 deletions site/src/closed_source_site_ffi.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Ok, Error } from "./gleam.mjs";
// Browser glue the Gleam modules cannot express.
export function copy(text, done) {
if (!navigator.clipboard) return void done(false);
Expand All @@ -10,3 +11,20 @@ export function scroll_by(id, dx) {
const calm = matchMedia("(prefers-reduced-motion: reduce)").matches;
el.scrollBy({ left: dx, behavior: calm ? "auto" : "smooth" });
}

// The live DOI check (refpulse, unison/refpulse/). A plain GET, so no CORS preflight: the
// service answers with Access-Control-Allow-Origin: *.
export function refpulse(url, dois, done) {
const endpoint = new URLSearchParams(location.search).get("refpulse") || url;
if (!endpoint) return void done(new Error("refpulse is not deployed yet: see unison/refpulse/README.md"));
const query = [...dois].map((d) => "doi=" + encodeURIComponent(d)).join("&");
const started = performance.now();
fetch(endpoint.replace(/\/$/, "") + "/check?" + query)
.then((r) => (r.ok ? r.json() : Promise.reject(new globalThis.Error("HTTP " + r.status))))
.then((j) => {
const ms = Math.round(performance.now() - started);
const rows = (j.results || []).map((x) => [x.doi, x.status, x.ok].join("\t"));
done(new Ok([ms + "\t" + endpoint, ...rows].join("\n")));
})
.catch((e) => done(new Error("refpulse did not answer (" + e.message + "). The last checked links are in links.json.")));
}
14 changes: 14 additions & 0 deletions site/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -420,3 +420,17 @@ main section.shelf { padding-top: 48px; }

/* A note the owner approved for the book: the cover's black with yellow letters. */
.tag.is-approved { border-color: var(--black); background: var(--black); color: var(--yellow); }

/* Live: the refpulse button (unison/refpulse/) */
.pulse-button {
font: 600 14px/1 var(--caps); letter-spacing: .04em; text-transform: uppercase;
color: var(--paper); background: var(--red-text); border: 0; padding: 12px 18px; min-height: 44px;
cursor: pointer; margin-top: 16px;
}
.pulse-button:hover { background: var(--ink); }
.pulse-button[disabled] { opacity: .6; cursor: progress; }
.pulse { margin-top: 12px; font-size: 16px; }
.pulse-results { list-style: none; padding: 0; margin: 8px 0 0; }
.pulse-results li { padding: 4px 0; border-bottom: 1px solid var(--rule); }
.pulse-results .is-ok { color: var(--ok); }
.pulse-results .is-dead, .pulse-failed { color: var(--red-text); }
18 changes: 18 additions & 0 deletions site/test/closed_source_site_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,21 @@ pub fn the_timeline_scrolls_and_each_life_takes_focus_test() {
assert string.contains(html, "id=\"tl\"")
assert string.contains(html, "<g class=\"tl-who\" tabindex=\"0\"")
}

pub fn the_live_check_reads_the_services_answer_test() {
assert app.parse_pulse(
"120\thttps://x.unison.cloud/s/refpulse\n10.5281/zenodo.1\t302\ttrue\n10.9/none\t404\tfalse",
)
== app.Done("https://x.unison.cloud/s/refpulse", 120, [
#("10.5281/zenodo.1", 302, True),
#("10.9/none", 404, False),
])
assert app.parse_pulse("garbage") != app.Idle
}

pub fn the_live_check_asks_about_the_books_own_dois_test() {
let html = app.initial() |> app.view |> element.to_string
assert list.first(app.book_dois()) == Ok(content.doi)
assert string.contains(html, "Check the DOIs now")
assert string.contains(html, "id=\"live\"")
}
Loading
Loading