diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae27d9..1870978 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: npm - name: Install dependencies diff --git a/.github/workflows/refresh.yml b/.github/workflows/refresh.yml index 360cd31..cb2d121 100644 --- a/.github/workflows/refresh.yml +++ b/.github/workflows/refresh.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: npm - name: Install dependencies diff --git a/.nvmrc b/.nvmrc index 209e3ef..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7802f1c..b5f825b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Local checks -Use Node.js 20 or newer. +Use Node.js 22 or newer. ```bash npm ci diff --git a/README.md b/README.md index f1a2a81..d53523e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ paths in SQLite. It publishes a static site from these records. - Refresh project cards from public GitHub repositories. - Build release notes from releases or conventional commits. - Capture repeatable project previews with Playwright. +- Publish one evidence page per project with its full changelog. +- Expose changelog updates as an RSS feed. - Hide projects and redact author emails before publication. - Run one configured refresh from a scheduled workflow. @@ -31,6 +33,8 @@ flowchart LR P --> W S --> W W --> O[Static output] + L --> E[Evidence pages] + L --> X[RSS feed] ``` | Area | Responsibility | @@ -43,15 +47,18 @@ flowchart LR | `src/changelog/` | Prefer release notes and fall back to commit groups. | | `src/privacy/` | Hide projects and block sensitive commit messages. | | `src/preview/` | Capture fixed viewport screenshots with Playwright. | -| `src/publish/` | Render HTML, changelog files, and preview assets. | +| `src/publish/` | Render the index, evidence pages, RSS feed, and preview assets. | | `fixtures/` | Provide deterministic demo data and local preview pages. | The refresh command runs each stage in a fixed order. If a preview fails, the command reports the skip and keeps the rest of the snapshot. +The publisher writes one page per visible project and one changelog feed. +Evidence pages keep the full changelog with source links. +The feed lists the latest changelog entry for each project. ## Setup -Use Node.js 20 or newer. +Use Node.js 22 or newer. ```bash npm ci @@ -106,12 +113,29 @@ Captured demo-engineer-signal-router. Captured demo-engineer-metrics-kit. Published 2 projects to output/index.html. Copied 2 available preview screenshots. +Wrote 2 evidence pages and output/feed.xml. Open output/index.html in a browser. ``` The site shows project facts, source links, changelog previews, and screenshots. +Each card links to a dedicated evidence page. +The evidence page shows the full changelog with source links. +The feed lists the latest changelog entry for each project. The totals come from fixture fields and stored commit records. +## Evidence pages and feed + +The publisher writes these extra files to the output directory: + +- One `feed.xml` file with the latest changelog entry per visible project. +- One `output//index.html` page per visible project. + +Evidence pages show repository facts, topics, and the full changelog. +Each changelog entry keeps its source link and publish date. +The feed sorts entries newest first. +Feed links point to repository URLs. +They never point to an invented deployment host. + ## Commands Build before direct CLI commands. @@ -122,7 +146,7 @@ Build before direct CLI commands. | `npm run ingest -- octocat --limit 3` | Load public repository evidence. | | `npm run ingest -- --fixture` | Load fixture records only. | | `npm run capture -- --fixture` | Capture local fixture pages. | -| `npm run publish` | Rebuild the site from SQLite. | +| `npm run publish` | Rebuild the site, evidence pages, and feed from SQLite. | | `npm run refresh` | Run configured ingest, capture, and publish stages. | | `node dist/index.js status` | Show visibility and recent operations. | | `npm test` | Run deterministic unit and integration tests. | @@ -153,6 +177,7 @@ Each release keeps its tag, notes, date, and source URL. The site displays visible projects only. It links project cards to repositories. It links release notes to their release pages. +It links each card to its local evidence page. It records local operations in an audit table. ## CI and test status @@ -172,6 +197,7 @@ The test suite covers these core behaviors: - Configured refresh orchestration. - Release source links. - Deterministic HTML output. +- Evidence pages and the RSS feed. - Playwright screenshot capture. Run the local checks: @@ -186,6 +212,7 @@ npm test Typecheck and build pass locally. CI runs the complete test suite on Ubuntu with Chromium installed. +CI uses Node.js 22. The fixture pipeline provides deterministic data for repeatable checks. ## Limitations @@ -196,6 +223,7 @@ The fixture pipeline provides deterministic data for repeatable checks. - Changelog quality depends on releases or conventional commits. - External pages can fail during capture. - Capture failures are reported and do not stop publishing. +- Feed links point to repository URLs, not to a deployed host. - Publishing creates local files. It does not deploy them. - Scheduled runs upload artifacts. They do not commit generated output. @@ -205,8 +233,9 @@ The fixture pipeline provides deterministic data for repeatable checks. | --- | --- | --- | | v0.1 | Complete | Fixture demo, GitHub ingest, changelog, capture, publish, and privacy controls. | | v0.2 | Complete | Checked-in configuration, coordinated refresh command, and scheduled artifact workflow. | -| v0.3 | Next | Custom themes and deployment adapters. | -| v0.4 | Later | Commit-diff summaries and an RSS feed. | +| v0.3 | Complete | Evidence pages, RSS changelog feed, and the Node 22 toolchain. | +| v0.4 | Next | Custom themes and deployment adapters. | +| v0.5 | Later | Commit-diff summaries. | ## License diff --git a/package-lock.json b/package-lock.json index 87f5d51..3b0753c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { - "better-sqlite3": "^11.8.1", + "better-sqlite3": "^13.0.2", "commander": "^15.0.0" }, "bin": { @@ -25,7 +25,7 @@ "vitest": "^3.0.5" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1353,79 +1353,16 @@ "node": ">=12" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", - "hasInstallScript": true, + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" } }, "node_modules/cac": { @@ -1465,12 +1402,6 @@ "node": ">= 16" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -1498,21 +1429,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1523,33 +1439,6 @@ "node": ">=6" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1609,15 +1498,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1646,18 +1526,6 @@ } } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -1673,44 +1541,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1735,33 +1565,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1788,31 +1591,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "node_modules/node-addon-api": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, "engines": { - "node": ">=10" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" + "node": "^18 || ^20 || >= 21" } }, "node_modules/pathe": { @@ -1845,6 +1630,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1913,72 +1699,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/rollup": { "version": "4.62.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", @@ -2024,38 +1744,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2063,51 +1751,6 @@ "dev": true, "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2132,24 +1775,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -2163,34 +1788,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2286,18 +1883,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -2340,18 +1925,13 @@ "dev": true, "license": "MIT" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -2548,12 +2128,6 @@ "engines": { "node": ">=8" } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" } } } diff --git a/package.json b/package.json index d36edbe..8d614df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "description": "Self-maintaining engineering portfolio from repository activity", "type": "module", "main": "dist/index.js", @@ -21,7 +21,7 @@ "refresh": "npm run build && node dist/index.js refresh" }, "engines": { - "node": ">=20" + "node": ">=22" }, "keywords": [ "portfolio", @@ -40,7 +40,7 @@ }, "homepage": "https://github.com/DanielCuevas1208/engineer-profile#readme", "dependencies": { - "better-sqlite3": "^11.8.1", + "better-sqlite3": "^13.0.2", "commander": "^15.0.0" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index aea5c27..dc0ad18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ const program = new Command(); program .name("engineer-profile") .description("Build a local engineering portfolio from public repository evidence") - .version("0.2.0"); + .version("0.3.0"); function resolveConfig(options: { config?: string; data?: string; output?: string }): PortfolioConfig { const base = options.config @@ -67,6 +67,7 @@ addConfigOption(program const copied = copyScreenshotsToOutput(config); console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); console.log(`Copied ${copied} available preview screenshots.`); + console.log(`Wrote ${result.evidencePages} evidence pages and ${result.feedPath}.`); console.log("Open output/index.html in a browser."); })); @@ -131,6 +132,7 @@ addConfigOption(program const copied = copyScreenshotsToOutput(config); console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); console.log(`Copied ${copied} available preview screenshots.`); + console.log(`Wrote ${result.evidencePages} evidence pages and ${result.feedPath}.`); })); addConfigOption(program @@ -145,6 +147,7 @@ addConfigOption(program console.log(`Ingested ${result.ingested} repositories for ${config.owner}.`); console.log(`Captured ${result.captured} project previews.`); console.log(`Published ${result.published.projectCount} projects to ${result.published.indexPath}.`); + console.log(`Wrote ${result.published.evidencePages} evidence pages and ${result.published.feedPath}.`); console.log(`Copied ${result.copiedScreenshots} available preview screenshots.`); for (const error of result.captureErrors) { console.warn(`Skipped ${error.slug}: ${error.message}`); diff --git a/src/ingest/github.ts b/src/ingest/github.ts index 6ed0fa7..4b1ee2d 100644 --- a/src/ingest/github.ts +++ b/src/ingest/github.ts @@ -15,7 +15,7 @@ export class GitHubClient { private headers(): Record { const headers: Record = { Accept: "application/vnd.github+json", - "User-Agent": "engineer-profile/0.1.0", + "User-Agent": "engineer-profile/0.3.0", }; if (this.token) headers.Authorization = `Bearer ${this.token}`; return headers; diff --git a/src/publish/evidence.ts b/src/publish/evidence.ts new file mode 100644 index 0000000..3239669 --- /dev/null +++ b/src/publish/evidence.ts @@ -0,0 +1,89 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + displayDate, + escapeHtml, + markdownToHtml, + pageShell, + parseTopics, + safeExternalUrl, + type ProjectView, +} from "./rendering.js"; +import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; + +function entryBlock(entry: ChangelogEntry): string { + const sourceUrl = safeExternalUrl(entry.source_url); + const sourceLabel = entry.source === "release" ? "Read the release on GitHub" : "Inspect the source commits"; + const body = markdownToHtml(entry.body); + return `
+
${escapeHtml(entry.version)} / ${entry.source}${displayDate(entry.published_at)}
+

${escapeHtml(entry.title)}

+

${displayDate(entry.published_at)} / ${escapeHtml(entry.source)}

+
${body}
+ ${sourceUrl ? `${sourceLabel} ` : ""} +
`; +} + +function factsRow(project: ProjectRecord, evidence: ProjectView["evidence"]): string { + const items = [ + ["stars", project.stars], + ["forks", project.forks], + ["commits", evidence.commits], + ["release notes", evidence.releases], + ]; + const cells = items + .map(([label, value]) => `${value}${label}`) + .join(""); + return `
${cells}
`; +} + +export function renderEvidencePage(config: PortfolioConfig, view: ProjectView): string { + const { project, changelog } = view; + const repositoryUrl = safeExternalUrl(project.url) ?? "#"; + const homepageUrl = safeExternalUrl(project.homepage); + const topics = parseTopics(project.topics) + .map((topic) => `${escapeHtml(topic)}`) + .join(""); + const entries = changelog.length + ? changelog.map(entryBlock).join("\n") + : '

No changelog entries were found for this project.

'; + + const body = `
+
+ +
+

Project evidence / ${escapeHtml(project.slug)}

+

${escapeHtml(project.name)}

+

${escapeHtml(project.description ?? "No description provided by the repository.")}

+ ${factsRow(project, view.evidence)} + ${topics ? `
${topics}
` : ""} +
+ View repository + Download changelog + ${homepageUrl ? `Open homepage` : ""} +
+
+
+

Evidence / changelog

Changelog

Every entry keeps its source link and publish date.

+ ${entries} +
+

${escapeHtml(config.title)} / evidence page

Facts on this page come from stored repository data.

+
+
`; + + return pageShell(config, `${project.name} / ${config.title}`, body); +} + +export function writeEvidencePages(config: PortfolioConfig, views: ProjectView[]): number { + let written = 0; + for (const view of views) { + const pageDir = join(config.outputDir, view.project.slug); + mkdirSync(pageDir, { recursive: true }); + writeFileSync(join(pageDir, "index.html"), renderEvidencePage(config, view), "utf-8"); + written++; + } + return written; +} diff --git a/src/publish/feed.ts b/src/publish/feed.ts new file mode 100644 index 0000000..1624421 --- /dev/null +++ b/src/publish/feed.ts @@ -0,0 +1,97 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { safeExternalUrl, type ProjectView } from "./rendering.js"; +import type { ChangelogEntry, PortfolioConfig } from "../types.js"; + +export interface FeedItem { + slug: string; + projectName: string; + title: string; + version: string; + source: string; + link: string; + guid: string; + publishedAt: string; +} + +export function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function rfc822(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toUTCString(); +} + +function entryToItem(view: ProjectView, entry: ChangelogEntry): FeedItem | null { + const fallbackLink = safeExternalUrl(view.project.url) ?? view.project.url; + const link = safeExternalUrl(entry.source_url ?? view.project.url) ?? fallbackLink; + return { + slug: view.project.slug, + projectName: view.project.name, + title: `${view.project.name}: ${entry.title}`, + version: entry.version, + source: entry.source, + link, + guid: entry.source_url ?? `${view.project.slug}:${entry.version}`, + publishedAt: entry.published_at, + }; +} + +export function buildFeedItems(views: ProjectView[]): FeedItem[] { + const items: FeedItem[] = []; + for (const view of views) { + const latest = view.changelog[0]; + if (!latest) continue; + const item = entryToItem(view, latest); + if (item) items.push(item); + } + return items.sort( + (a, b) => b.publishedAt.localeCompare(a.publishedAt) || a.slug.localeCompare(b.slug) + ); +} + +export function buildFeed(config: PortfolioConfig, items: FeedItem[]): string { + const baseUrl = `https://github.com/${encodeURIComponent(config.owner)}`; + const itemXml = items.map((item) => { + const lines = [ + " ", + ` ${escapeXml(item.title)}`, + ` ${escapeXml(item.link)}`, + ` ${escapeXml(item.guid)}`, + ` ${escapeXml(rfc822(item.publishedAt))}`, + ` ${escapeXml(`${item.projectName} ${item.source} changelog entry ${item.version}`)}`, + " ", + ]; + return lines.join("\n"); + }).join("\n"); + + return [ + '', + '', + " ", + ` ${escapeXml(config.title)} / changelog`, + ` ${escapeXml(baseUrl)}`, + ` ${escapeXml(config.tagline)}`, + ` `, + ` ${escapeXml(rfc822(config.clock()))}`, + itemXml, + " ", + "", + "", + ].join("\n"); +} + +export function writeFeed(config: PortfolioConfig, views: ProjectView[]): string { + const feed = buildFeed(config, buildFeedItems(views)); + mkdirSync(config.outputDir, { recursive: true }); + const feedPath = join(config.outputDir, "feed.xml"); + writeFileSync(feedPath, feed, "utf-8"); + return feedPath; +} diff --git a/src/publish/rendering.ts b/src/publish/rendering.ts new file mode 100644 index 0000000..e40e459 --- /dev/null +++ b/src/publish/rendering.ts @@ -0,0 +1,222 @@ +import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; + +export interface ProjectView { + project: ProjectRecord; + changelog: ChangelogEntry[]; + evidence: { + commits: number; + releases: number; + }; +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function safeExternalUrl(value: string | null): string | null { + if (!value) return null; + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? value : null; + } catch { + return null; + } +} + +export function inlineMarkdown(text: string): string { + return escapeHtml(text) + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1"); +} + +export function markdownToHtml(markdown: string): string { + const blocks: string[] = []; + for (const line of markdown.split("\n")) { + if (line.startsWith("### ")) { + blocks.push(`

${inlineMarkdown(line.slice(4))}

`); + } else if (line.startsWith("## ")) { + blocks.push(`

${inlineMarkdown(line.slice(3))}

`); + } else if (line.startsWith("# ")) { + blocks.push(`

${inlineMarkdown(line.slice(2))}

`); + } else if (line.startsWith("- ")) { + blocks.push(`
  • ${inlineMarkdown(line.slice(2))}
  • `); + } else if (line.trim()) { + blocks.push(`

    ${inlineMarkdown(line)}

    `); + } + } + return blocks.join("\n"); +} + +export function parseTopics(value: string): string[] { + try { + const topics = JSON.parse(value) as unknown; + return Array.isArray(topics) ? topics.filter((topic): topic is string => typeof topic === "string") : []; + } catch { + return []; + } +} + +export function displayDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value.slice(0, 10); + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(date); +} + +export function pageShell(config: PortfolioConfig, title: string, bodyHtml: string): string { + return ` + + + + + + + ${escapeHtml(title)} + + + +${bodyHtml} + +`; +} + +export function siteCss(): string { + return ` +:root { + color-scheme: dark; + --ink: #08111f; + --ink-soft: #0d1a2d; + --panel: #112139; + --panel-strong: #172a46; + --line: rgba(169, 195, 222, 0.18); + --text: #f3f7fb; + --muted: #9db0c7; + --blue: #67b7ff; + --blue-soft: #b8dcff; + --mint: #a7f3d0; + --orange: #ffb86b; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.24); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); line-height: 1.5; } +a { color: inherit; } +.site-shell { min-height: 100vh; background: radial-gradient(circle at 82% -10%, rgba(70, 148, 232, 0.2), transparent 34rem), var(--ink); } +.container { width: min(1180px, calc(100% - 48px)); margin: 0 auto; } +.site-nav { display: flex; justify-content: space-between; align-items: center; padding: 28px 0; border-bottom: 1px solid var(--line); } +.brand { display: inline-flex; align-items: center; gap: 12px; font: 700 0.9rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; } +.brand-mark { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--blue); color: var(--blue); border-radius: 8px; font-size: 0.72rem; } +.nav-link { color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; } +.nav-link:hover, .text-link:hover, h2 a:hover, .button.secondary:hover { color: var(--blue); } +.hero { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr); gap: 80px; align-items: end; padding: 86px 0 70px; } +.kicker, .eyebrow, .visual-label, .card-topline, .section-heading, .source-badge, .audit-label { font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.12em; text-transform: uppercase; } +.kicker { color: var(--mint); margin: 0 0 22px; } +h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-weight: 650; letter-spacing: -0.08em; line-height: 0.94; } +.hero-copy { max-width: 530px; margin: 28px 0 0; color: var(--blue-soft); font-size: 1.12rem; } +.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(23, 42, 70, 0.92), rgba(13, 26, 45, 0.72)); box-shadow: var(--shadow); } +.aside-index { display: flex; justify-content: space-between; color: var(--orange); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.12em; text-transform: uppercase; } +.hero-aside p { margin: 24px 0 4px; color: var(--text); font-size: 1rem; } +.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.stat { min-height: 104px; padding: 20px 22px; border-right: 1px solid var(--line); } +.stat:last-child { border-right: 0; } +.stat strong { display: block; color: var(--text); font-size: 1.8rem; font-weight: 600; letter-spacing: -0.04em; } +.stat span { color: var(--muted); font: 0.7rem/1.3 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; } +.audit-panel { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; margin: 26px 0 80px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 10px; background: rgba(17, 33, 57, 0.66); } +.audit-copy { color: var(--muted); font-size: 0.88rem; } +.audit-copy strong { color: var(--text); font-weight: 500; } +.audit-time { color: var(--blue); font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; text-align: right; } +.index-header { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 24px; } +.index-header h2 { margin: 0; font-size: 2rem; font-weight: 550; letter-spacing: -0.05em; } +.index-header p { max-width: 350px; margin: 0; color: var(--muted); font-size: 0.88rem; text-align: right; } +.project-card { display: grid; grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr); overflow: hidden; margin-bottom: 24px; border: 1px solid var(--line); border-radius: 16px; background: linear-gradient(135deg, rgba(23, 42, 70, 0.98), rgba(13, 26, 45, 0.96)); box-shadow: var(--shadow); } +.project-visual { position: relative; min-height: 310px; background: #0a1525; } +.screenshot { display: block; width: 100%; height: 100%; min-height: 310px; object-fit: cover; opacity: 0.9; } +.placeholder { display: flex; min-height: 310px; align-items: center; justify-content: center; flex-direction: column; gap: 7px; color: var(--blue); background: repeating-linear-gradient(135deg, rgba(103, 183, 255, 0.05), rgba(103, 183, 255, 0.05) 1px, transparent 1px, transparent 14px); } +.placeholder small { color: var(--muted); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; text-transform: uppercase; } +.visual-label { position: absolute; right: 16px; bottom: 16px; padding: 7px 9px; border: 1px solid rgba(255,255,255,0.18); border-radius: 5px; background: rgba(8, 17, 31, 0.72); color: var(--blue-soft); } +.project-body { padding: 30px 34px 32px; } +.card-topline { display: flex; justify-content: space-between; gap: 12px; color: var(--blue); } +.project-body h2 { margin: 18px 0 8px; font-size: 2.1rem; font-weight: 560; letter-spacing: -0.06em; } +.project-body h2 a { text-decoration: none; } +.description { max-width: 620px; margin: 0; color: var(--blue-soft); font-size: 0.98rem; } +.facts { display: flex; flex-wrap: wrap; gap: 22px; margin: 24px 0 16px; color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; } +.facts strong { color: var(--text); font-size: 1rem; font-weight: 600; } +.tags { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 26px; } +.tag { padding: 5px 9px; border: 1px solid rgba(167, 243, 208, 0.26); border-radius: 999px; color: var(--mint); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; } +.change-log { padding: 18px 0 20px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.section-heading { display: flex; justify-content: space-between; color: var(--orange); } +.source-badge { color: var(--muted); } +.change-log h3 { margin: 12px 0 2px; font-size: 1.08rem; font-weight: 550; } +.change-date { margin: 0 0 10px; color: var(--muted); font: 0.7rem/1.3 "SFMono-Regular", Consolas, monospace; } +.change-preview { max-height: 130px; overflow: hidden; color: var(--muted); font-size: 0.83rem; } +.change-preview h3, .change-preview h4 { margin: 12px 0 4px; color: var(--text); font-size: 0.78rem; font-weight: 600; } +.change-preview p { margin: 3px 0; } +.change-preview li { margin: 3px 0 3px 18px; } +.change-preview code { padding: 2px 4px; border-radius: 3px; color: var(--mint); background: rgba(167, 243, 208, 0.08); font: 0.76rem "SFMono-Regular", Consolas, monospace; } +.text-link { display: inline-block; margin-top: 14px; color: var(--blue); font: 0.73rem/1 "SFMono-Regular", Consolas, monospace; text-decoration: none; text-transform: uppercase; } +.card-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; } +.button { display: inline-block; padding: 10px 14px; border-radius: 7px; font: 0.72rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.04em; text-decoration: none; text-transform: uppercase; } +.button.primary { background: var(--blue); color: var(--ink); } +.button.primary:hover { background: var(--blue-soft); } +.button.secondary { border: 1px solid var(--line); color: var(--text); } +.button.secondary:hover { border-color: var(--blue); } +.muted { color: var(--muted); } +.source-trail { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 36px; margin: 80px 0; padding: 26px 0; border-top: 1px solid var(--line); } +.source-trail h2 { margin: 0 0 8px; font-size: 1.2rem; font-weight: 550; } +.source-trail p { margin: 0; color: var(--muted); font-size: 0.86rem; } +.audit-list { margin: 0; padding: 0; list-style: none; } +.audit-list li { display: grid; grid-template-columns: 150px 72px 1fr; gap: 12px; padding: 8px 0; border-bottom: 1px solid rgba(169, 195, 222, 0.1); color: var(--muted); font: 0.72rem/1.4 "SFMono-Regular", Consolas, monospace; } +.audit-list time { color: var(--blue); } +.audit-list strong { color: var(--orange); font-weight: 500; text-transform: uppercase; } +.site-footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 40px; border-top: 1px solid var(--line); color: var(--muted); font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; } +.site-footer p { margin: 0; } +.evidence-hero { max-width: 860px; padding: 56px 0 34px; } +.evidence-hero h1 { font-size: clamp(2.6rem, 6vw, 4.6rem); } +.evidence-hero .hero-copy { max-width: 640px; } +.evidence-facts { display: flex; flex-wrap: wrap; gap: 26px; margin: 26px 0 20px; color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; } +.evidence-facts strong { display: block; color: var(--text); font-size: 1.3rem; font-weight: 600; letter-spacing: -0.03em; } +.evidence-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; } +.changelog-full { padding: 10px 0 60px; } +.entry { padding: 24px 0; border-bottom: 1px solid var(--line); } +.entry:last-child { border-bottom: 0; } +.entry h3 { margin: 8px 0 4px; font-size: 1.35rem; font-weight: 560; letter-spacing: -0.04em; } +.entry .change-date { margin-bottom: 14px; } +.entry-body { max-width: 720px; color: var(--blue-soft); font-size: 0.95rem; } +.entry-body h3, .entry-body h4 { margin: 16px 0 6px; color: var(--text); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; } +.entry-body p { margin: 6px 0; } +.entry-body li { margin: 4px 0 4px 20px; } +.entry-body code { padding: 2px 4px; border-radius: 3px; color: var(--mint); background: rgba(167, 243, 208, 0.08); font: 0.82rem "SFMono-Regular", Consolas, monospace; } +.entry-body strong { color: var(--text); } +@media (max-width: 850px) { + .hero { grid-template-columns: 1fr; gap: 34px; padding-top: 58px; } + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .stat:nth-child(2) { border-right: 0; } + .stat:nth-child(-n+2) { border-bottom: 1px solid var(--line); } + .project-card, .source-trail { grid-template-columns: 1fr; } + .project-visual, .screenshot, .placeholder { min-height: 240px; } + .index-header { align-items: start; flex-direction: column; } + .index-header p { text-align: left; } + .audit-panel { grid-template-columns: 1fr; } + .audit-time { text-align: left; } +} +@media (max-width: 560px) { + .container { width: min(100% - 28px, 1180px); } + .site-nav { padding: 20px 0; } + h1 { font-size: 3.4rem; } + .project-body { padding: 24px 20px; } + .project-body h2 { font-size: 1.8rem; } + .audit-list li { grid-template-columns: 1fr; gap: 2px; } + .site-footer { flex-direction: column; } +} +`; +} diff --git a/src/publish/site.ts b/src/publish/site.ts index d382163..ff7a538 100644 --- a/src/publish/site.ts +++ b/src/publish/site.ts @@ -2,59 +2,20 @@ import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { formatChangelogMarkdown } from "../changelog/generator.js"; import { openDatabase } from "../db/client.js"; +import { writeEvidencePages } from "./evidence.js"; +import { writeFeed } from "./feed.js"; +import { + displayDate, + escapeHtml, + markdownToHtml, + pageShell, + parseTopics, + safeExternalUrl, + type ProjectView, +} from "./rendering.js"; import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function safeExternalUrl(value: string | null): string | null { - if (!value) return null; - try { - const url = new URL(value); - return url.protocol === "https:" || url.protocol === "http:" ? value : null; - } catch { - return null; - } -} - -function inlineMarkdown(text: string): string { - return escapeHtml(text) - .replace(/`([^`]+)`/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1"); -} - -function markdownToHtml(markdown: string): string { - const blocks: string[] = []; - for (const line of markdown.split("\n")) { - if (line.startsWith("### ")) { - blocks.push(`

    ${inlineMarkdown(line.slice(4))}

    `); - } else if (line.startsWith("## ")) { - blocks.push(`

    ${inlineMarkdown(line.slice(3))}

    `); - } else if (line.startsWith("# ")) { - blocks.push(`

    ${inlineMarkdown(line.slice(2))}

    `); - } else if (line.startsWith("- ")) { - blocks.push(`
  • ${inlineMarkdown(line.slice(2))}
  • `); - } else if (line.trim()) { - blocks.push(`

    ${inlineMarkdown(line)}

    `); - } - } - return blocks.join("\n"); -} - -function parseTopics(value: string): string[] { - try { - const topics = JSON.parse(value) as unknown; - return Array.isArray(topics) ? topics.filter((topic): topic is string => typeof topic === "string") : []; - } catch { - return []; - } -} +export type { ProjectView }; function publicAuditDetail(detail: string | null): string { if (!detail) return "No detail recorded"; @@ -62,26 +23,10 @@ function publicAuditDetail(detail: string | null): string { return separator >= 0 ? `${detail.slice(0, separator)} -> local artifact` : detail; } -function displayDate(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value.slice(0, 10); - return new Intl.DateTimeFormat("en", { - month: "short", - day: "numeric", - year: "numeric", - timeZone: "UTC", - }).format(date); -} - -interface ProjectEvidence { - commits: number; - releases: number; -} - function projectCard( project: ProjectRecord, changelog: ChangelogEntry[], - evidence: ProjectEvidence + evidence: { commits: number; releases: number } ): string { const topics = parseTopics(project.topics) .map((topic) => `${escapeHtml(topic)}`) @@ -118,130 +63,18 @@ function projectCard(
    View repository + Evidence page ${homepageUrl ? `Open homepage` : ""}
    `; } -function siteCss(): string { - return ` -:root { - color-scheme: dark; - --ink: #08111f; - --ink-soft: #0d1a2d; - --panel: #112139; - --panel-strong: #172a46; - --line: rgba(169, 195, 222, 0.18); - --text: #f3f7fb; - --muted: #9db0c7; - --blue: #67b7ff; - --blue-soft: #b8dcff; - --mint: #a7f3d0; - --orange: #ffb86b; - --shadow: 0 24px 60px rgba(0, 0, 0, 0.24); - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} -* { box-sizing: border-box; } -html { scroll-behavior: smooth; } -body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); line-height: 1.5; } -a { color: inherit; } -.site-shell { min-height: 100vh; background: radial-gradient(circle at 82% -10%, rgba(70, 148, 232, 0.2), transparent 34rem), var(--ink); } -.container { width: min(1180px, calc(100% - 48px)); margin: 0 auto; } -.site-nav { display: flex; justify-content: space-between; align-items: center; padding: 28px 0; border-bottom: 1px solid var(--line); } -.brand { display: inline-flex; align-items: center; gap: 12px; font: 700 0.9rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; } -.brand-mark { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--blue); color: var(--blue); border-radius: 8px; font-size: 0.72rem; } -.nav-link { color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; } -.nav-link:hover, .text-link:hover, h2 a:hover { color: var(--blue); } -.hero { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr); gap: 80px; align-items: end; padding: 86px 0 70px; } -.kicker, .eyebrow, .visual-label, .card-topline, .section-heading, .source-badge, .audit-label { font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.12em; text-transform: uppercase; } -.kicker { color: var(--mint); margin: 0 0 22px; } -h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-weight: 650; letter-spacing: -0.08em; line-height: 0.94; } -.hero-copy { max-width: 530px; margin: 28px 0 0; color: var(--blue-soft); font-size: 1.12rem; } -.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(23, 42, 70, 0.92), rgba(13, 26, 45, 0.72)); box-shadow: var(--shadow); } -.aside-index { display: flex; justify-content: space-between; color: var(--orange); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.12em; text-transform: uppercase; } -.hero-aside p { margin: 24px 0 4px; color: var(--text); font-size: 1rem; } -.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } -.stat { min-height: 104px; padding: 20px 22px; border-right: 1px solid var(--line); } -.stat:last-child { border-right: 0; } -.stat strong { display: block; color: var(--text); font-size: 1.8rem; font-weight: 600; letter-spacing: -0.04em; } -.stat span { color: var(--muted); font: 0.7rem/1.3 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; } -.audit-panel { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; margin: 26px 0 80px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 10px; background: rgba(17, 33, 57, 0.66); } -.audit-copy { color: var(--muted); font-size: 0.88rem; } -.audit-copy strong { color: var(--text); font-weight: 500; } -.audit-time { color: var(--blue); font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; text-align: right; } -.index-header { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 24px; } -.index-header h2 { margin: 0; font-size: 2rem; font-weight: 550; letter-spacing: -0.05em; } -.index-header p { max-width: 350px; margin: 0; color: var(--muted); font-size: 0.88rem; text-align: right; } -.project-card { display: grid; grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr); overflow: hidden; margin-bottom: 24px; border: 1px solid var(--line); border-radius: 16px; background: linear-gradient(135deg, rgba(23, 42, 70, 0.98), rgba(13, 26, 45, 0.96)); box-shadow: var(--shadow); } -.project-visual { position: relative; min-height: 310px; background: #0a1525; } -.screenshot { display: block; width: 100%; height: 100%; min-height: 310px; object-fit: cover; opacity: 0.9; } -.placeholder { display: flex; min-height: 310px; align-items: center; justify-content: center; flex-direction: column; gap: 7px; color: var(--blue); background: repeating-linear-gradient(135deg, rgba(103, 183, 255, 0.05), rgba(103, 183, 255, 0.05) 1px, transparent 1px, transparent 14px); } -.placeholder small { color: var(--muted); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; text-transform: uppercase; } -.visual-label { position: absolute; right: 16px; bottom: 16px; padding: 7px 9px; border: 1px solid rgba(255,255,255,0.18); border-radius: 5px; background: rgba(8, 17, 31, 0.72); color: var(--blue-soft); } -.project-body { padding: 30px 34px 32px; } -.card-topline { display: flex; justify-content: space-between; gap: 12px; color: var(--blue); } -.project-body h2 { margin: 18px 0 8px; font-size: 2.1rem; font-weight: 560; letter-spacing: -0.06em; } -.project-body h2 a { text-decoration: none; } -.description { max-width: 620px; margin: 0; color: var(--blue-soft); font-size: 0.98rem; } -.facts { display: flex; flex-wrap: wrap; gap: 22px; margin: 24px 0 16px; color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; } -.facts strong { color: var(--text); font-size: 1rem; font-weight: 600; } -.tags { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 26px; } -.tag { padding: 5px 9px; border: 1px solid rgba(167, 243, 208, 0.26); border-radius: 999px; color: var(--mint); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; } -.change-log { padding: 18px 0 20px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } -.section-heading { display: flex; justify-content: space-between; color: var(--orange); } -.source-badge { color: var(--muted); } -.change-log h3 { margin: 12px 0 2px; font-size: 1.08rem; font-weight: 550; } -.change-date { margin: 0 0 10px; color: var(--muted); font: 0.7rem/1.3 "SFMono-Regular", Consolas, monospace; } -.change-preview { max-height: 130px; overflow: hidden; color: var(--muted); font-size: 0.83rem; } -.change-preview h3, .change-preview h4 { margin: 12px 0 4px; color: var(--text); font-size: 0.78rem; font-weight: 600; } -.change-preview p { margin: 3px 0; } -.change-preview li { margin: 3px 0 3px 18px; } -.change-preview code { padding: 2px 4px; border-radius: 3px; color: var(--mint); background: rgba(167, 243, 208, 0.08); font: 0.76rem "SFMono-Regular", Consolas, monospace; } -.text-link { display: inline-block; margin-top: 14px; color: var(--blue); font: 0.73rem/1 "SFMono-Regular", Consolas, monospace; text-decoration: none; text-transform: uppercase; } -.card-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; } -.button { display: inline-block; padding: 10px 14px; border-radius: 7px; font: 0.72rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.04em; text-decoration: none; text-transform: uppercase; } -.button.primary { background: var(--blue); color: var(--ink); } -.button.primary:hover { background: var(--blue-soft); } -.button.secondary { border: 1px solid var(--line); color: var(--text); } -.button.secondary:hover { border-color: var(--blue); color: var(--blue); } -.muted { color: var(--muted); } -.source-trail { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 36px; margin: 80px 0; padding: 26px 0; border-top: 1px solid var(--line); } -.source-trail h2 { margin: 0 0 8px; font-size: 1.2rem; font-weight: 550; } -.source-trail p { margin: 0; color: var(--muted); font-size: 0.86rem; } -.audit-list { margin: 0; padding: 0; list-style: none; } -.audit-list li { display: grid; grid-template-columns: 150px 72px 1fr; gap: 12px; padding: 8px 0; border-bottom: 1px solid rgba(169, 195, 222, 0.1); color: var(--muted); font: 0.72rem/1.4 "SFMono-Regular", Consolas, monospace; } -.audit-list time { color: var(--blue); } -.audit-list strong { color: var(--orange); font-weight: 500; text-transform: uppercase; } -.site-footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 40px; border-top: 1px solid var(--line); color: var(--muted); font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; } -.site-footer p { margin: 0; } -@media (max-width: 850px) { - .hero { grid-template-columns: 1fr; gap: 34px; padding-top: 58px; } - .stats-grid { grid-template-columns: repeat(2, 1fr); } - .stat:nth-child(2) { border-right: 0; } - .stat:nth-child(-n+2) { border-bottom: 1px solid var(--line); } - .project-card, .source-trail { grid-template-columns: 1fr; } - .project-visual, .screenshot, .placeholder { min-height: 240px; } - .index-header { align-items: start; flex-direction: column; } - .index-header p { text-align: left; } - .audit-panel { grid-template-columns: 1fr; } - .audit-time { text-align: left; } -} -@media (max-width: 560px) { - .container { width: min(100% - 28px, 1180px); } - .site-nav { padding: 20px 0; } - h1 { font-size: 3.4rem; } - .project-body { padding: 24px 20px; } - .project-body h2 { font-size: 1.8rem; } - .audit-list li { grid-template-columns: 1fr; gap: 2px; } - .site-footer { flex-direction: column; } -} -`; -} - export interface PublishResult { indexPath: string; projectCount: number; + evidencePages: number; + feedPath: string | null; generatedAt: string; } @@ -250,7 +83,7 @@ export function publishSite(config: PortfolioConfig): PublishResult { try { const projects = db.listProjects(true); const generatedAt = config.clock(); - const views = projects.map((project) => ({ + const views: ProjectView[] = projects.map((project) => ({ project, changelog: db.getChangelog(project.id), evidence: { @@ -271,62 +104,49 @@ export function publishSite(config: PortfolioConfig): PublishResult { ? '
    Portfolio / 00Empty

    No visible projects are ready. Run ingest, then publish again.

    ' : ""; - const html = ` - - - - - - - ${escapeHtml(config.title)} / Portfolio - - - -
    -
    - -
    -
    -

    Public repository signal / 01

    -

    Evidence before adjectives.

    -

    ${escapeHtml(config.tagline)}. Each project stays connected to its repository, commits, releases, and preview.

    -
    - -
    -
    -
    ${projects.length}visible projects
    -
    ${totalStars}total stars
    -
    ${totalCommits}commits tracked
    -
    ${releaseNotes}release notes
    -
    -
    -
    Auditable snapshot. Sourced from public repository metadata, commits, and releases. Source: auditable SQLite data. ${languages.length ? `Languages: ${languages.map(escapeHtml).join(", ")}.` : "No language data was provided."}
    -
    Generated ${escapeHtml(generatedAt.slice(0, 19).replace("T", " "))} UTC
    Emails redacted / hidden projects excluded
    -
    -
    -

    Portfolio / 02

    Project index

    Visible projects only. Every number comes from stored repository data.

    - ${visibleProjects} - ${cards} -
    -
    -

    Audit / 03

    Refresh trail

    EngineerProfile records each ingest, capture, and publish operation locally.

    -
      ${auditItems}
    -
    -

    ${escapeHtml(config.title)} / local publisher

    Public output contains visible projects only. No invented metrics.

    -
    + const body = `
    +
    + +
    +
    +

    Public repository signal / 01

    +

    Evidence before adjectives.

    +

    ${escapeHtml(config.tagline)}. Each project stays connected to its repository, commits, releases, and preview.

    +
    + +
    +
    +
    ${projects.length}visible projects
    +
    ${totalStars}total stars
    +
    ${totalCommits}commits tracked
    +
    ${releaseNotes}release notes
    +
    +
    +
    Auditable snapshot. Sourced from public repository metadata, commits, and releases. Source: auditable SQLite data. ${languages.length ? `Languages: ${languages.map(escapeHtml).join(", ")}.` : "No language data was provided."}
    +
    Generated ${escapeHtml(generatedAt.slice(0, 19).replace("T", " "))} UTC
    Emails redacted / hidden projects excluded
    +
    +
    +

    Portfolio / 02

    Project index

    Visible projects only. Every number comes from stored repository data.

    + ${visibleProjects} + ${cards} +
    +
    +

    Audit / 03

    Refresh trail

    EngineerProfile records each ingest, capture, and publish operation locally.

    +
      ${auditItems}
    +
    +

    ${escapeHtml(config.title)} / local publisher

    Public output contains visible projects only. No invented metrics.

    - -`; +
    `; mkdirSync(config.outputDir, { recursive: true }); const indexPath = join(config.outputDir, "index.html"); - writeFileSync(indexPath, html, "utf-8"); + writeFileSync(indexPath, pageShell(config, `${config.title} / Portfolio`, body), "utf-8"); for (const view of views) { if (view.changelog.length === 0) continue; @@ -344,8 +164,11 @@ export function publishSite(config: PortfolioConfig): PublishResult { writeFileSync(join(config.outputDir, `${view.project.slug}-changelog.md`), markdown, "utf-8"); } + const evidencePages = writeEvidencePages(config, views); + const feedPath = writeFeed(config, views); + db.logIngest("publish", `${projects.length} projects -> ${indexPath}`); - return { indexPath, projectCount: projects.length, generatedAt }; + return { indexPath, projectCount: projects.length, evidencePages, feedPath, generatedAt }; } finally { db.close(); } @@ -367,4 +190,4 @@ export function copyScreenshotsToOutput(config: PortfolioConfig): number { db.close(); } return copied; -} \ No newline at end of file +} diff --git a/tests/deterministic.test.ts b/tests/deterministic.test.ts index 78fc6ea..5215ece 100644 --- a/tests/deterministic.test.ts +++ b/tests/deterministic.test.ts @@ -46,4 +46,20 @@ describe("deterministic publishing", () => { expect(html).toContain("https://github.com/demo-engineer/signal-router/releases/tag/v0.3.0"); expect(existsSync(join(OUTPUTS[0], "demo-engineer-signal-router-changelog.md"))).toBe(true); }); + + it("produces identical feed and evidence pages across runs", async () => { + const fixtures = loadAllFixtures(); + const feeds = []; + const pages = []; + for (let index = 0; index < RUNS.length; index++) { + const config = fixedConfig(RUNS[index], OUTPUTS[index]); + await ingestOwnerRepos(config, config.owner, fixtures.length, fixtures); + const result = publishSite(config); + feeds.push(readFileSync(result.feedPath!, "utf-8")); + pages.push(readFileSync(join(OUTPUTS[index], "demo-engineer-signal-router", "index.html"), "utf-8")); + } + + expect(feeds[0]).toBe(feeds[1]); + expect(pages[0]).toBe(pages[1]); + }); }); \ No newline at end of file diff --git a/tests/evidence.test.ts b/tests/evidence.test.ts new file mode 100644 index 0000000..eae07d6 --- /dev/null +++ b/tests/evidence.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { ingestOwnerRepos, ingestRepository } from "../src/ingest/orchestrator.js"; +import { publishSite } from "../src/publish/site.js"; +import { loadAllFixtures, loadFixtureCommits, loadFixtureReleases, loadFixtureRepo } from "../src/fixtures/loader.js"; +import { openDatabase } from "../src/db/client.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = "data/test-evidence"; +const TEST_OUTPUT = "output/test-evidence"; + +function fixedConfig() { + return { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + clock: () => "2026-07-31T00:00:00.000Z", + }; +} + +beforeEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +describe("evidence pages", () => { + it("writes one page per visible project with the full changelog", async () => { + const config = fixedConfig(); + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + + const result = publishSite(config); + expect(result.evidencePages).toBe(2); + + const signalPath = join(TEST_OUTPUT, "demo-engineer-signal-router", "index.html"); + expect(existsSync(signalPath)).toBe(true); + const html = readFileSync(signalPath, "utf-8"); + expect(html).toContain("signal-router"); + expect(html).toContain("Priority queues"); + expect(html).toContain("Typed channels"); + expect(html).toContain("View repository"); + expect(html).toContain("../index.html"); + expect(html).toContain("demo-engineer-signal-router-changelog.md"); + }); + + it("links each index card to its evidence page", async () => { + const config = fixedConfig(); + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + + publishSite(config); + const index = readFileSync(join(TEST_OUTPUT, "index.html"), "utf-8"); + expect(index).toContain('href="demo-engineer-signal-router/index.html"'); + expect(index).toContain('href="demo-engineer-metrics-kit/index.html"'); + }); + + it("does not publish evidence pages for hidden projects", async () => { + const config = fixedConfig(); + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + + const db = openDatabase(TEST_DATA, config.clock); + db.setVisibility("demo-engineer-metrics-kit", false); + db.close(); + + const result = publishSite(config); + expect(result.evidencePages).toBe(1); + expect(existsSync(join(TEST_OUTPUT, "demo-engineer-metrics-kit", "index.html"))).toBe(false); + }); + + it("writes a page for a project without changelog entries", async () => { + const config = fixedConfig(); + await ingestRepository( + config, + { owner: "demo-engineer", repo: "signal-router" }, + { + repo: loadFixtureRepo("signal-router"), + commits: [], + releases: [], + } + ); + + const db = openDatabase(TEST_DATA, config.clock); + db.replaceChangelog(db.getProjectBySlug("demo-engineer-signal-router")!.id, []); + db.close(); + + const result = publishSite(config); + const page = readFileSync( + join(TEST_OUTPUT, "demo-engineer-signal-router", "index.html"), + "utf-8" + ); + expect(result.evidencePages).toBe(1); + expect(page).toContain("No changelog entries were found"); + }); +}); diff --git a/tests/feed.test.ts b/tests/feed.test.ts new file mode 100644 index 0000000..e20c55a --- /dev/null +++ b/tests/feed.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { buildFeed, buildFeedItems, escapeXml, rfc822, type FeedItem } from "../src/publish/feed.js"; +import { ingestOwnerRepos } from "../src/ingest/orchestrator.js"; +import { publishSite } from "../src/publish/site.js"; +import { loadAllFixtures } from "../src/fixtures/loader.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; +import type { ChangelogEntry, ProjectRecord } from "../src/types.js"; + +const TEST_DATA = "data/test-feed"; +const TEST_OUTPUT = "output/test-feed"; + +function fixedConfig() { + return { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + clock: () => "2026-07-31T00:00:00.000Z", + }; +} + +afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +function viewFor( + project: Partial, + entry: Partial +) { + return { + project: { + id: 1, + slug: "demo-project", + name: "Demo", + description: null, + url: "https://github.com/demo-engineer/demo", + homepage: null, + language: "TypeScript", + stars: 0, + forks: 0, + topics: "[]", + last_pushed: "2026-01-01T00:00:00Z", + visible: 1, + screenshot_path: null, + ingested_at: "2026-01-01T00:00:00Z", + ...project, + }, + evidence: { commits: 3, releases: 1 }, + changelog: [ + { + id: 1, + project_id: 1, + version: "v1.0.0", + title: "First release", + body: "Initial notes", + source: "release", + source_url: "https://github.com/demo-engineer/demo/releases/tag/v1.0.0", + published_at: "2026-07-14T10:00:00Z", + commit_shas: "[]", + ...entry, + }, + ] as ChangelogEntry[], + }; +} + +describe("feed primitives", () => { + it("escapes XML-special characters", () => { + expect(escapeXml(`A & B < "quoted" > 'x'`)).toBe( + "A & B < "quoted" > 'x'" + ); + }); + + it("formats ISO dates as RFC 822", () => { + expect(rfc822("2026-07-14T10:00:00Z")).toBe("Tue, 14 Jul 2026 10:00:00 GMT"); + }); + + it("keeps the raw value when the date is invalid", () => { + expect(rfc822("not-a-date")).toBe("not-a-date"); + }); +}); + +describe("feed items", () => { + it("sorts the latest entry of each project newest first", () => { + const items = buildFeedItems([ + viewFor({ slug: "a", name: "Alpha" }, { published_at: "2026-07-01T00:00:00Z" }), + viewFor({ slug: "b", name: "Beta" }, { published_at: "2026-07-20T00:00:00Z" }), + ]); + + expect(items.map((item) => item.slug)).toEqual(["b", "a"]); + expect(items[0].title).toBe("Beta: First release"); + }); + + it("skips projects without changelog entries", () => { + const view = viewFor({ slug: "a", name: "Alpha" }); + view.changelog = []; + const items = buildFeedItems([view]); + expect(items).toEqual([]); + }); + + it("uses the repository URL when a source link is missing", () => { + const view = viewFor({ slug: "a", name: "Alpha" }, { source_url: null }); + const items = buildFeedItems([view]); + expect(items[0].link).toBe("https://github.com/demo-engineer/demo"); + }); +}); + +describe("feed integration", () => { + it("writes feed.xml with one item per visible project", async () => { + const config = fixedConfig(); + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + + const result = publishSite(config); + expect(result.feedPath).toBe(join(TEST_OUTPUT, "feed.xml")); + expect(existsSync(result.feedPath!)).toBe(true); + + const feed = readFileSync(result.feedPath!, "utf-8"); + expect(feed).toContain(''); + expect(feed).toContain('EngineerProfile / changelog"); + expect(feed).toContain("signal-router: Priority queues"); + expect(feed).toContain("metrics-kit: Unreleased changes"); + expect(feed).toContain("https://github.com/demo-engineer/signal-router/releases/tag/v0.3.0"); + expect(feed).toContain("Tue, 14 Jul 2026 10:00:00 GMT"); + expect(feed).toContain("Mon, 20 Jul 2026 18:45:00 GMT"); + }); + + it("keeps hidden projects out of the feed", async () => { + const config = fixedConfig(); + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + + const { openDatabase } = await import("../src/db/client.js"); + const db = openDatabase(TEST_DATA, config.clock); + db.setVisibility("demo-engineer-signal-router", false); + db.close(); + + publishSite(config); + const feed = readFileSync(join(TEST_OUTPUT, "feed.xml"), "utf-8"); + expect(feed).not.toContain("signal-router: Priority queues"); + expect(feed).toContain("metrics-kit: Unreleased changes"); + }); + + it("writes an empty channel when no projects are visible", () => { + const config = fixedConfig(); + const feed = buildFeed(config, [] as FeedItem[]); + expect(feed).toContain(""); + expect(feed).not.toContain(""); + }); +}); diff --git a/tests/site.test.ts b/tests/site.test.ts index 03061cf..a8efb0b 100644 --- a/tests/site.test.ts +++ b/tests/site.test.ts @@ -127,4 +127,26 @@ describe("database and publish pipeline", () => { const md = readFileSync(changelogPath, "utf-8"); expect(md).toContain("v0.3.0"); }); + + it("publishes evidence pages and a changelog feed", async () => { + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; + await ingestRepository( + config, + { owner: "demo-engineer", repo: "signal-router" }, + { + repo: loadFixtureRepo("signal-router"), + commits: loadFixtureCommits("signal-router"), + releases: loadFixtureReleases("signal-router"), + } + ); + + const result = publishSite(config); + expect(result.evidencePages).toBe(1); + expect(result.feedPath).toMatch(/feed\.xml$/); + expect(existsSync(join(TEST_OUTPUT, "demo-engineer-signal-router", "index.html"))).toBe(true); + expect(existsSync(join(TEST_OUTPUT, "feed.xml"))).toBe(true); + expect(readFileSync(join(TEST_OUTPUT, "feed.xml"), "utf-8")).toContain( + "signal-router: Priority queues" + ); + }); });