Skip to content
Draft
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: 5 additions & 0 deletions .claude/skills/records/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ node scripts/commitRecords.js --out page.html [--pr <number>] [--title <text>] \
and comments the records as Markdown (`--markdown <file> --url <page>`,
scripts/recordMarkdown.js) with the page's link: after a push that keeps
the previous head, only the commits it added (`--since <previous head>`).
- The viewer draws records in the browser from GitHub's API, with the same
renderer: `https://cdn.rescript-lang.org/semantic-diff/view/index.html`
with `?pr=<number>`, `?range=<base>...<tip>` or `?commit=<sha>[,<sha>]`,
at most 250 commits (`scripts/recordViewer.js`,
`.github/workflows/semantic_diff_viewer.yml`).
- In prose, `#1234` and full pull request and issue URLs link as `#1234`,
and the 9-character short sha of a commit on the page links to its card.

Expand Down
78 changes: 78 additions & 0 deletions .github/workflows/semantic_diff_viewer.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Semantic Diff Viewer

# Publishes the Semantic Diff viewer (scripts/recordViewer.html), which draws
# the commit records of any pull request, range or commit in the browser,
# reading them from GitHub's API:
#
# https://cdn.rescript-lang.org/semantic-diff/view/index.html?pr=<number>
#
# A push to master publishes it there. A pull request from a branch of this
# repository that changes it publishes its own version under
# semantic-diff/view/pr-<number>/ and comments that address, so the change
# can be tried before it lands; a fork's pull request publishes nothing.

on:
push:
branches: [master]
paths: &viewer
- scripts/recordViewer.html
- scripts/recordViewer.js
- scripts/recordRender.js
- .github/workflows/semantic_diff_viewer.yml
pull_request:
paths: *viewer
workflow_dispatch:

concurrency:
group: semantic-diff-viewer-${{ github.event.pull_request.number || 'master' }}
cancel-in-progress: true

jobs:
publish:
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: write
env:
DIR: semantic-diff/view${{ github.event_name == 'pull_request' && format('/pr-{0}', github.event.pull_request.number) || '' }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Setup Rclone
uses: cometkim/rclone-actions/setup-rclone@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18

- name: Configure Rclone remote
uses: cometkim/rclone-actions/configure-remote/s3-provider@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18
with:
name: rescript
provider: Cloudflare
endpoint: https://${{ vars.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
access-key-id: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
acl: private

# The page and its modules are revalidated within five minutes, so a
# new version reaches readers soon after it is published.
- name: Upload viewer
run: |
upload() {
rclone copyto --s3-no-check-bucket --header-upload "Cache-Control: max-age=300" "scripts/$1" "rescript:cdn-assets/${DIR}/$2"
}
upload recordViewer.html index.html
upload recordViewer.js recordViewer.js
upload recordRender.js recordRender.js

- name: Comment viewer address
if: github.event_name == 'pull_request'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ github.event.pull_request.number }}
comment-tag: semantic-diff-viewer
message: |
Semantic Diff viewer of this pull request: https://cdn.rescript-lang.org/${{ env.DIR }}/index.html?pr=${{ github.event.pull_request.number }}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"changelog:release": "node scripts/changelog.js release",
"changelog:open": "node scripts/changelog.js open",
"test-changelog": "node --test scripts/changelog.test.js",
"test-commit-records": "node --test scripts/commitRecords.test.js scripts/recordMarkdown.test.js"
"test-commit-records": "node --test scripts/commitRecords.test.js scripts/recordMarkdown.test.js scripts/recordViewer.test.js"
},
"files": [
"CHANGELOG.md",
Expand Down
4 changes: 2 additions & 2 deletions scripts/recordRender.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// @ts-check

/* Commit records, parsed from a commit message and drawn as HTML. The
* caller (commitRecords.js) reads each commit's identity and message from
* git. The
* caller reads each commit's identity and message: commitRecords.js from
* git, recordViewer.js in the browser from GitHub's API. The
* format is the `records` skill's (.claude/skills/records/SKILL.md): a
* subject, a `Why:` section, object blocks such as
* `AST modified (raw extensions):`, each followed by an optional `Example:`
Expand Down
21 changes: 21 additions & 0 deletions scripts/recordViewer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Semantic Diff</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=STIX+Two+Text:ital,wght@0,400;0,500;1,400&display=swap"
/>
<!-- Draws the records the address names (recordViewer.js). -->
<script type="module">
import { show } from "./recordViewer.js";

show();
</script>
</head>
<body>
<main><header><p>Loading…</p></header></main>
</body>
</html>
204 changes: 204 additions & 0 deletions scripts/recordViewer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// @ts-check

/* The Semantic Diff viewer: draws the commit records of a pull request, a
* range or commits of this repository in the browser, reading them from
* GitHub's REST API and drawing them with the page's renderer
* (recordRender.js). recordViewer.html is the page;
* .github/workflows/semantic_diff_viewer.yml publishes both to the CDN.
*
* ?pr=8958 the pull request's commits
* ?pr=8958&range=a...b the commits of a range, headed by the pull request
* ?range=a...b the commits b has and a lacks
* ?commit=a1b2c3d,e4f5a6b commits, one card each
*
* Without a token, GitHub answers 60 requests an hour for each address; a
* pull request takes two, and one more for each 100 commits past the first.
* A pull request or range holds at most 250 commits (GitHub lists no more
* of a pull request's), and a query names at most 20 commits, so a page
* takes at most 4 requests or 20.
*/

import { CSS, esc, pageBody, pageTitle, parseRecord } from "./recordRender.js";

/** @import { CommitRecord, Group } from "./recordRender.js" */

/**
* A GitHub API request: the path under the repository, answered as JSON.
* @typedef {(path: string) => Promise<any>} FetchJson
*/

/**
* A commit as GitHub's API returns it, the fields the viewer reads.
* @typedef {{
* sha: string,
* parents: unknown[],
* commit: { message: string, author: { name: string, date: string } },
* }} ApiCommit
*/

const REPO = "rescript-lang/rescript";
const API = `https://api.github.com/repos/${REPO}`;
const MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
const PAGE = 100;
/** The commits a pull request or range holds at most. */
export const MAX_COMMITS = 250;
/** The commits a query names at most. */
export const MAX_NAMED = 20;

/** A commit's sha, full or abbreviated. */
const SHA = /^[0-9a-f]{4,40}$/i;
const USAGE =
"Name what to draw: ?pr=<number>, ?range=<base>...<tip> or ?commit=<sha>[,<sha>].";

/**
* A range `base..tip` or `base...tip` of shas, tags or branch names: words
* joined by single dots or slashes, so a range names no other API path.
*/
const RANGE = /^(\w[\w-]*(?:[./]\w[\w-]*)*)\.{2,3}(\w[\w-]*(?:[./]\w[\w-]*)*)$/;

/**
* A commit's record, its date as `git log --date=format:%b %d, %Y` prints
* it, in UTC.
* @param {ApiCommit} c
* @returns {CommitRecord}
*/
export function recordOf(c) {
const d = new Date(c.commit.author.date);
return parseRecord({
sha: c.sha.slice(0, 9),
author: c.commit.author.name,
date: `${MONTHS[d.getUTCMonth()]} ${String(d.getUTCDate()).padStart(2, "0")}, ${d.getUTCFullYear()}`,
message: c.commit.message,
});
}

/**
* The commits a paginated listing holds, page after page until one comes
* back short. A listing answers either an array or `{ commits }`.
* @param {FetchJson} fetchJson
* @param {string} path
* @returns {Promise<ApiCommit[]>}
*/
async function listing(fetchJson, path) {
/** @type {ApiCommit[]} */
const all = [];
for (let page = 1; ; page++) {
const answer = await fetchJson(
`${path}${path.includes("?") ? "&" : "?"}per_page=${PAGE}&page=${page}`,
);
/** @type {ApiCommit[]} */
const commits = Array.isArray(answer) ? answer : answer.commits;
tooMany(answer.total_commits);
all.push(...commits);
if (commits.length < PAGE) return all;
}
}

/**
* Refuses a pull request or range of more commits than the viewer draws.
* @param {number | undefined} count
*/
function tooMany(count) {
if (count !== undefined && count > MAX_COMMITS)
throw new Error(
`It holds ${count} commits; the viewer draws at most ${MAX_COMMITS}.`,
);
}

/**
* Records of the commits, merge commits left out as `git rev-list
* --no-merges` leaves them.
* @param {ApiCommit[]} commits
*/
const records = commits =>
commits.filter(c => c.parents.length < 2).map(recordOf);

/**
* The page the query asks for: its heading and its groups of records.
* @param {URLSearchParams} query
* @param {FetchJson} fetchJson
* @returns {Promise<{ heading: { pr?: string, title?: string }, groups: Group[] }>}
*/
export async function load(query, fetchJson) {
const pr = query.get("pr") ?? "";
const range = query.get("range") ?? "";
const commits = (query.get("commit") ?? "").split(",").filter(Boolean);
if (pr && !/^\d+$/.test(pr)) throw new Error(`Not a pull request: ${pr}`);
const ends = range ? RANGE.exec(range) : null;
if (range && !ends) throw new Error(`Not a range: ${range}`);
const bad = commits.find(c => !SHA.test(c));
if (bad) throw new Error(`Not a commit: ${bad}`);
if (commits.length > MAX_NAMED)
throw new Error(
`The query names ${commits.length} commits; the viewer draws at most ${MAX_NAMED}.`,
);
if (!pr && !ends && !commits.length) throw new Error(USAGE);

/** @type {Group[]} */
const groups = [];
/** @type {{ pr?: string, title?: string }} */
let heading = {};
if (pr) {
const pull = await fetchJson(`/pulls/${pr}`);
heading = { pr, title: pull.title };
if (!ends) {
tooMany(pull.commits);
groups.push({
range: `${pull.base.ref}..${pull.head.ref}`,
records: records(await listing(fetchJson, `/pulls/${pr}/commits`)),
});
}
}
if (ends) {
const [, base, tip] = ends;
groups.push({
range: `${base}..${tip}`,
records: records(await listing(fetchJson, `/compare/${base}...${tip}`)),
});
}
for (const sha of commits)
groups.push({ records: [recordOf(await fetchJson(`/commits/${sha}`))] });
return { heading, groups };
}

/**
* A request to GitHub's API, its failures as errors a reader can act on.
* @type {FetchJson}
*/
async function fetchJson(path) {
const response = await fetch(`${API}${path}`, {
headers: { Accept: "application/vnd.github+json" },
});
if (response.ok) return response.json();
if (response.headers.get("x-ratelimit-remaining") === "0") {
const reset = new Date(
Number(response.headers.get("x-ratelimit-reset")) * 1000,
);
throw new Error(
`GitHub's API allows this address no more requests until ${reset.toLocaleTimeString()}.`,
);
}
throw new Error(
response.status === 404
? `GitHub found nothing at ${REPO}${path.replace(/\?.*/, "")}.`
: `GitHub answered ${response.status} ${response.statusText}.`,
);
}

/**
* Draws the page the address asks for into the document, or why it can't.
*/
export async function show() {
const style = document.createElement("style");
style.textContent = CSS;
document.head.append(style);
try {
const page = await load(new URLSearchParams(location.search), fetchJson);
document.title = pageTitle(page.heading);
document.body.innerHTML = pageBody(page.groups, page.heading);
if (location.hash)
document.getElementById(location.hash.slice(1))?.scrollIntoView();
} catch (e) {
document.body.innerHTML = `<main><header><h1 class="brand">Semantic <span>Diff</span></h1><p class="lede">${esc(e instanceof Error ? e.message : String(e))}</p></header></main>`;
}
}
Loading
Loading