diff --git a/package-lock.json b/package-lock.json index 4ee3017..6a6b386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@atproto/lex-resolver": "^0.0.15", "@atproto/syntax": "^0.3.0", "@hono/node-server": "^1.19.9", + "cheerio": "1.0.0", "dotenv": "^16.5.0", "gray-matter": "^4.0.3", "hono": "^4.12.2", @@ -1053,6 +1054,12 @@ ], "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -1116,6 +1123,48 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cheerio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -1155,6 +1204,34 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1203,6 +1280,61 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1215,6 +1347,31 @@ "url": "https://dotenvx.com" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1407,6 +1564,37 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1675,6 +1863,18 @@ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", "license": "(Apache-2.0 AND MIT)" }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1701,6 +1901,55 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/pino": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/pino/-/pino-8.21.0.tgz", @@ -1879,6 +2128,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -2063,6 +2318,15 @@ "multiformats": "^9.4.2" } }, + "node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -2178,6 +2442,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 0ed8686..d20f769 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "knowledge:promote": "tsx scripts/promote-knowledge.ts", "knowledge:sync": "tsx scripts/sync-knowledge-atproto.ts", "test:markdown": "tsx --test src/markdown.test.ts", - "test:content": "tsx --test src/about-content.test.ts src/blog-data.test.ts src/pds-data.test.ts src/profile-data.test.ts src/knowledge.test.ts src/knowledge-landing.test.tsx src/services.test.tsx src/links.test.tsx src/tide-pool.test.tsx src/brinewake.test.tsx scripts/tide-pool.test.mjs", + "test:content": "tsx --test src/about-content.test.ts src/blog-data.test.ts src/pds-data.test.ts src/profile-data.test.ts src/knowledge.test.ts src/knowledge-landing.test.tsx src/services.test.tsx src/links.test.tsx src/tide-pool.test.tsx src/brinewake.test.tsx src/code-activity.test.ts scripts/tide-pool.test.mjs", "test:charts": "tsx --test src/charts/charts.test.ts src/knowledge-charts/knowledge-charts.test.tsx", "test:worker": "bash scripts/test-content-sync-worker.sh", "test": "pnpm test:markdown && pnpm test:content && pnpm test:charts && pnpm test:worker && pnpm knowledge:check", @@ -23,6 +23,7 @@ "@atproto/lex-resolver": "^0.0.15", "@atproto/syntax": "^0.3.0", "@hono/node-server": "^1.19.9", + "cheerio": "1.0.0", "dotenv": "^16.5.0", "gray-matter": "^4.0.3", "hono": "^4.12.2", diff --git a/public/site.css b/public/site.css index 18e20ce..4768a65 100644 --- a/public/site.css +++ b/public/site.css @@ -2350,6 +2350,75 @@ h1 { -webkit-line-clamp: 2; } +.code-recent-commits { + margin-top: 1.35rem; +} + +.code-recent-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.4rem; +} + +.code-recent-heading h3, +.code-recent-heading p { + margin: 0; +} + +.code-recent-heading h3 { + font-size: 0.78rem; +} + +.code-recent-heading p { + color: var(--site-text-muted); + font-size: 0.65rem; +} + +.code-recent-list { + margin: 0; + padding: 0; + border-top: 1px solid var(--site-border); + list-style: none; +} + +.code-recent-commit { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(7rem, 16%) max-content; + align-items: baseline; + gap: 0.75rem; + min-width: 0; + padding: 0.55rem 0; + border-bottom: 1px solid var(--site-border); + font-size: 0.72rem; +} + +.code-recent-subject { + overflow: hidden; + min-width: 0; + color: var(--site-text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-recent-subject:hover { + color: var(--site-accent); +} + +.code-recent-repository, +.code-recent-time { + overflow: hidden; + color: var(--site-text-muted); + font-size: 0.65rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-recent-time { + font-variant-numeric: tabular-nums; +} + @media (max-width: 520px) { .code-activity-heading { gap: 0.75rem; @@ -2371,6 +2440,21 @@ h1 { grid-template-columns: 1fr; } + .code-recent-commit { + grid-template-columns: minmax(0, 1fr) max-content; + gap: 0.45rem 0.75rem; + } + + .code-recent-repository { + grid-column: 1; + grid-row: 2; + } + + .code-recent-time { + grid-column: 2; + grid-row: 2; + } + .code-pinned-repository:nth-child(odd), .code-pinned-repository:nth-child(even) { padding-right: 0; diff --git a/src/code-activity.fixture.html b/src/code-activity.fixture.html new file mode 100644 index 0000000..6ef74a3 --- /dev/null +++ b/src/code-activity.fixture.html @@ -0,0 +1,23 @@ + +
+
+ +
+ 07547ccf +
+
+
+ Make Charter a Jazz-native distributed runtime. + main +
+
+
+
+ + + +
+
+
diff --git a/src/code-activity.test.ts b/src/code-activity.test.ts new file mode 100644 index 0000000..be5958a --- /dev/null +++ b/src/code-activity.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { fetchPinnedRecentCommits, getCodeActivity, parseTangledHistoryUrl, parseTangledRecentCommits } from "./code-activity.ts"; + +const fixture = readFileSync(new URL("./code-activity.fixture.html", import.meta.url), "utf8"); +const repo = (name = "charter") => ({ repoDid: name, name, description: "", website: null, url: `https://tangled.org/cameron.stream/${name}` }); +const landing = (name = "charter", branch = "main") => `
Commits
`; + +test("real history shape selects subjects rather than SHA links and pairs each row's time", () => { + const commits = parseTangledRecentCommits(fixture); + assert.equal(commits.length, 2); + assert.equal(commits[0].subject, "Make Charter a Jazz-native distributed runtime."); + assert.equal(commits[0].time, "2026-07-11T19:19:35-07:00"); + assert.equal(commits[1].subject, "Make Charter runtime-first."); + assert.notEqual(commits[0].time, commits[1].time); + assert.equal(parseTangledRecentCommits(fixture, 1).length, 1); + assert.equal(parseTangledRecentCommits(fixture, 0).length, 0); + assert.deepEqual(parseTangledRecentCommits(fixture + fixture), commits); +}); + +test("invalid and missing time never borrow from the next row or outside the history", () => { + for (const replacement of ["", '']) { + const html = fixture.replace(/]*>[\s\S]*?<\/time>/, replacement); + const result = parseTangledRecentCommits(html + ''); + assert.equal(result.length, 1); + assert.equal(result[0].subject, "Make Charter runtime-first."); + } +}); + +test("HTML entities and nested subject markup decode without losing text", () => { + const html = fixture.replace("Make Charter a Jazz-native distributed runtime.", "fix & <parser> 'quote' —") + .replace("2026-07-11T19:19:35-07:00", "2026-07-11T19:19:35+00:00"); + assert.equal(parseTangledRecentCommits(html)[0].subject, "fix & 'quote' —"); + assert.equal(parseTangledRecentCommits(html)[0].time, "2026-07-11T19:19:35+00:00"); +}); + +test("default history navigation supports non-main branches and rejects unrelated links", () => { + assert.equal(parseTangledHistoryUrl(landing("charter", "trunk"), repo().url), `${repo().url}/commits/trunk`); + assert.equal(parseTangledHistoryUrl(landing("charter", "feature%2Fnext"), repo().url), `${repo().url}/commits/feature%2Fnext`); + for (const html of ["", '', landing("other"), '']) { + assert.equal(parseTangledHistoryUrl(html, repo().url), null); + } +}); + +test("mocked fetch bounds to four unique repos, deduplicates and sorts, fails soft", async (t) => { + const calls: string[] = []; + t.mock.method(globalThis, "fetch", async (input: string, init: RequestInit) => { + calls.push(input); + assert.ok(init.signal); + if (input === repo("broken").url) throw new Error("timeout"); + if (input.includes("/commits/")) return new Response(fixture.replaceAll("/charter/", "/other/").replaceAll("2026-07-11", "2026-09-08") + fixture + fixture); + if (input === repo("empty").url) return new Response("unavailable", { status: 503 }); + return new Response(landing(input.split("/").at(-1), "trunk")); + }); + const result = await fetchPinnedRecentCommits([repo(), repo(), repo("other"), repo("broken"), repo("empty"), repo("fifth")]); + assert.equal(result.length, 4); + assert.equal(result[0].repository, "cameron.stream/other"); + assert.equal(new Set(result.map((c) => c.url)).size, 4); + assert.equal(calls.length, 6); + assert.ok(calls.includes(`${repo().url}/commits/trunk`)); + assert.ok(!calls.some((url) => url.includes("fifth"))); +}); + +test("getCodeActivity fetches public records without defaultBranch and caches the populated result", async (t) => { + let calls = 0; + t.mock.method(globalThis, "fetch", async (input: string) => { + calls++; + if (input.includes("plc.directory")) return Response.json({ service: [{ type: "AtprotoPersonalDataServer", serviceEndpoint: "https://public.example" }] }); + if (input.includes("getRecord")) return Response.json({ value: { pinnedRepositories: ["charter"] } }); + if (input.includes("listRecords")) return Response.json({ records: [{ uri: "at://did:plc:example/sh.tangled.repo/charter", value: { repoDid: "charter" } }] }); + if (input === repo().url) return new Response(landing()); + if (input === `${repo().url}/commits/main`) return new Response(fixture); + return new Response("", { status: 503 }); + }); + const first = await getCodeActivity(); + assert.equal(first.tangledRecent.length, 2); + const before = calls; + assert.deepEqual(await getCodeActivity(), first); + assert.equal(calls, before); +}); diff --git a/src/code-activity.ts b/src/code-activity.ts index b3169af..820d659 100644 --- a/src/code-activity.ts +++ b/src/code-activity.ts @@ -1,3 +1,4 @@ +import { load } from "cheerio"; import { cacheGet, cacheSet } from "./cache.ts"; export interface ActivityDay { @@ -20,10 +21,18 @@ export interface TangledRepository { url: string; } +export interface TangledCommit { + subject: string; + repository: string; + time: string; + url: string; +} + export interface CodeActivityData { github: ActivitySeries | null; tangled: ActivitySeries | null; tangledPinned: TangledRepository[]; + tangledRecent: TangledCommit[]; } interface DidDocument { @@ -107,6 +116,42 @@ function tangledLevel(count: number): number { return 4; } +// Resolve the default branch from the repository landing page's own history +// navigation, never from README links or an undocumented ATProto record field. +export function parseTangledHistoryUrl(html: string, repositoryUrl: string): string | null { + const $ = load(html); + const href = $("#commit-log a[href]").first().attr("href"); + if (!href || !URL.canParse(href, "https://tangled.org") || !URL.canParse(repositoryUrl)) return null; + const url = new URL(href, "https://tangled.org"); + const repository = new URL(repositoryUrl); + const prefix = `${repository.pathname}/commits/`; + return url.origin === "https://tangled.org" && url.pathname.startsWith(prefix) && + url.pathname.length > prefix.length && !url.search && !url.hash ? url.href : null; +} + +export function parseTangledRecentCommits(html: string, limit = 6): TangledCommit[] { + const $ = load(html); + const commits = new Map(); + // Each history row includes a hash link, a nested subject link and a sibling + // time column. Restrict all extraction to that row, including missing times. + for (const row of $("div.grid-cols-14").toArray()) { + const item = $(row); + const time = item.find("time[datetime]").attr("datetime"); + if (!time || Number.isNaN(Date.parse(time))) continue; + for (const anchor of item.find(".col-span-6 a[href]").toArray()) { + const link = $(anchor); + const path = link.attr("href") ?? ""; + const match = path.match(/^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]{40})$/i); + const subject = link.text().replace(/\s+/g, " ").trim(); + if (!match || !subject) continue; + const url = `https://tangled.org${path}`; + commits.set(url, { subject, repository: match[1], time, url }); + break; + } + } + return [...commits.values()].slice(0, Math.max(0, limit)); +} + export function parseTangledActivity(html: string): ActivitySeries | null { const totalMatch = html.match(/Punchcard[\s\S]{0,500}?([\d,]+) commits/i); const days: ActivityDay[] = []; @@ -243,8 +288,33 @@ async function getTangledPinnedRepositories(): Promise { ); } +export async function fetchPinnedRecentCommits(repositories: TangledRepository[]): Promise { + const commits: TangledCommit[] = []; + const pinned = [...new Map(repositories.map((repo) => [repo.url, repo])).values()].slice(0, 4); + for (let index = 0; index < pinned.length; index += 2) { + const batch = pinned.slice(index, index + 2).map(async (repository) => { + const landing = await fetchText(repository.url); + const historyUrl = landing ? parseTangledHistoryUrl(landing, repository.url) : null; + if (!historyUrl) return []; + const html = await fetchText(historyUrl); + return html ? parseTangledRecentCommits(html).filter((commit) => + commit.url.startsWith(`${repository.url}/commit/`)) : []; + }); + const results = await Promise.all(batch); + commits.push(...results.flat()); + } + const unique = new Map(); + for (const commit of commits) { + const hash = commit.url.slice(commit.url.lastIndexOf("/") + 1); + unique.set(`${commit.repository}:${hash}`, commit); + } + return [...unique.values()] + .sort((a, b) => Date.parse(b.time) - Date.parse(a.time)) + .slice(0, 6); +} + export async function getCodeActivity(): Promise { - const cacheKey = "code-activity:v2"; + const cacheKey = "code-activity:v3"; const cached = await cacheGet(cacheKey); if (cached !== undefined) return cached as CodeActivityData; @@ -257,10 +327,11 @@ export async function getCodeActivity(): Promise { github: githubHtml ? parseGithubActivity(githubHtml) : null, tangled: tangledHtml ? parseTangledActivity(tangledHtml) : null, tangledPinned, + tangledRecent: await fetchPinnedRecentCommits(tangledPinned), }; await cacheSet(cacheKey, activity, { - life: activity.github && activity.tangled && activity.tangledPinned.length > 0 + life: activity.github && activity.tangled && activity.tangledPinned.length > 0 && activity.tangledRecent.length > 0 ? "hours" : "minutes", }); diff --git a/src/components/code-activity.tsx b/src/components/code-activity.tsx index 9761203..1814478 100644 --- a/src/components/code-activity.tsx +++ b/src/components/code-activity.tsx @@ -119,6 +119,27 @@ export async function CodeActivity() { )} + {activity.tangledRecent.length > 0 && ( +
+
+

Recent commits in pinned repositories

+

Public Tangled activity

+
+
    + {activity.tangledRecent.map((commit) => ( +
  • + + {commit.subject} + + {commit.repository.split("/").at(-1)} + +
  • + ))} +
+
+ )}