From 405c5d2ac0e1b3b933bad2c8e56b9c6dc95cf49f Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:25:44 -0700 Subject: [PATCH] feat: extend engineer profile --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 58 ++++ .github/workflows/refresh.yml | 2 +- .nvmrc | 2 +- CONTRIBUTING.md | 2 +- README.md | 151 ++++++++- engineer-profile.config.json | 7 + fixtures/metrics-kit-commits.json | 12 +- fixtures/signal-router-commits.json | 16 +- package-lock.json | 464 ++-------------------------- package.json | 10 +- src/changelog/generator.ts | 24 +- src/config/loader.ts | 150 ++++++++- src/db/client.ts | 61 +++- src/deploy/adapters.ts | 44 +++ src/feed/rss.ts | 145 +++++++++ src/index.ts | 41 ++- src/ingest/github.ts | 12 +- src/publish/site.ts | 150 ++------- src/refresh/run.ts | 4 + src/theme/base.ts | 125 ++++++++ src/theme/midnight.ts | 31 ++ src/theme/paper.ts | 33 ++ src/theme/registry.ts | 23 ++ src/theme/resolve.ts | 48 +++ src/theme/terminal.ts | 36 +++ src/types.ts | 53 ++++ tests/changelog.test.ts | 32 ++ tests/config.test.ts | 120 +++++++ tests/db.test.ts | 78 +++++ tests/deploy.test.ts | 61 ++++ tests/feed.test.ts | 146 +++++++++ tests/github.test.ts | 31 ++ tests/site.test.ts | 84 +++++ tests/theme.test.ts | 65 ++++ vitest.config.mjs | 8 +- vitest.config.ts | 6 + 37 files changed, 1733 insertions(+), 604 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 src/deploy/adapters.ts create mode 100644 src/feed/rss.ts create mode 100644 src/theme/base.ts create mode 100644 src/theme/midnight.ts create mode 100644 src/theme/paper.ts create mode 100644 src/theme/registry.ts create mode 100644 src/theme/resolve.ts create mode 100644 src/theme/terminal.ts create mode 100644 tests/deploy.test.ts create mode 100644 tests/feed.test.ts create mode 100644 tests/theme.test.ts 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/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b176767 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,58 @@ +name: Deploy portfolio to GitHub Pages + +on: + schedule: + - cron: "23 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: engineer-profile-pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.pages.outputs.base_url }} + env: + GITHUB_TOKEN: ${{ github.token }} + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install chromium --with-deps + + - name: Refresh portfolio + run: npm run refresh -- --config engineer-profile.config.json + + - name: Stage deployment files + run: node dist/index.js deploy + + - name: Configure GitHub Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Upload site artifact + uses: actions/upload-pages-artifact@v3 + with: + path: output/ + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 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..692825b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # EngineerProfile EngineerProfile builds a local engineering portfolio from public repository data. -It stores repository metadata, commits, releases, privacy settings, and preview -paths in SQLite. It publishes a static site from these records. +It stores repository metadata, commits, releases, privacy settings, and preview paths in SQLite. +It publishes a static site from these records. ## Value @@ -11,6 +11,10 @@ paths in SQLite. It publishes a static site from these records. - Build release notes from releases or conventional commits. - Capture repeatable project previews with Playwright. - Hide projects and redact author emails before publication. +- Choose a built-in theme or supply your own CSS. +- Surface commit-diff totals on every project card. +- Publish an RSS feed of release and commit signals. +- Stage the site for GitHub Pages deployment. - Run one configured refresh from a scheduled workflow. The fixture demo runs without secrets and without network access. @@ -27,23 +31,31 @@ flowchart LR D --> L[Changelog] D --> P[Privacy] D --> S[Playwright] + D --> F[RSS feed] L --> W[Publisher] P --> W S --> W + T[Theme] --> W W --> O[Static output] + F --> O + O --> K[Deploy] + K --> U[GitHub Pages] ``` | Area | Responsibility | | --- | --- | -| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, and privacy settings. | +| `engineer-profile.config.json` | Store owner, presentation, theme, refresh, deployment, paths, and privacy settings. | | `src/config/` | Validate checked-in JSON and merge safe defaults. | -| `src/refresh/` | Coordinate ingest, best-effort capture, and static publishing. | +| `src/refresh/` | Coordinate ingest, best-effort capture, static publishing, and deployment staging. | | `src/ingest/` | Fetch public GitHub data and map it to records. | | `src/db/` | Store projects, commits, changelogs, and audit events. | | `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/theme/` | Provide built-in themes and merge custom CSS. | +| `src/feed/` | Render a deterministic RSS feed from changelog entries. | | `src/publish/` | Render HTML, changelog files, and preview assets. | +| `src/deploy/` | Stage the published site for a deployment platform. | | `fixtures/` | Provide deterministic demo data and local preview pages. | The refresh command runs each stage in a fixed order. @@ -51,7 +63,7 @@ If a preview fails, the command reports the skip and keeps the rest of the snaps ## Setup -Use Node.js 20 or newer. +Use Node.js 22 or newer. ```bash npm ci @@ -68,7 +80,7 @@ Both directories are ignored by Git. ## Configuration `engineer-profile.config.json` is the checked-in source for scheduled refreshes. -It sets the GitHub owner, site presentation, repository limit, paths, and privacy controls. +It sets the GitHub owner, site presentation, repository limit, paths, privacy controls, and feed settings. The loader accepts repository limits from 1 through 100. It rejects malformed values before network access. @@ -94,6 +106,93 @@ npm run refresh Do not put a token in repository files. Use `.env.example` as a variable reference. +## Themes + +The site uses a theme for presentation. +Pick a theme by name in the config file. +Run `npm run themes` to list the built-in themes. + +The default theme is `midnight`. + +| Theme | Appearance | +| --- | --- | +| `midnight` | Dark interface with blue and mint accents. | +| `paper` | Light interface with serif type and warm paper tones. | +| `terminal` | Monospace interface inspired by a terminal emulator. | + +Set the theme by name: + +```json +{ "theme": "paper" } +``` + +Set a custom theme with an object: + +```json +{ "theme": { "name": "studio", "customCss": "themes/studio.css" } } +``` + +The loader rejects unknown theme names unless you provide a custom CSS file. +The publisher appends your CSS after the built-in stylesheet. +Use CSS variables to override the base palette and fonts. +Start from a built-in theme, then override the variables you need. + +## Deployment + +`engineer-profile` can stage a site for GitHub Pages. +Set the deployment platform in the config file. +The publisher writes a `.nojekyll` file for GitHub Pages. +It writes a `CNAME` file when you configure a custom domain. + +```json +{ + "deployment": { + "platform": "github-pages", + "cname": "engineering.example.com" + } +} +``` + +Stage the published site with a command: + +```bash +npm run deploy +``` + +The `deploy` workflow publishes the site to GitHub Pages. +It runs on a schedule and on manual dispatch. +Enable the Pages feature in the repository settings first. +The workflow does not manage repository settings. + +## RSS feed + +The publisher writes an RSS feed at publish time. +The feed contains the latest changelog entry for each visible project. +Entries link back to their release pages or commit records. + +Set the feed channel in the config file: + +```json +{ + "feed": { + "enabled": true, + "limit": 20, + "description": "Release notes from public repositories.", + "siteUrl": "https://engineering.example.com" + } +} +``` + +The feed is enabled by default. +The limit caps the number of items. +The description falls back to the portfolio tagline. +The site URL sets the canonical channel link. +Without a site URL, the publisher uses the deployment domain or the repository homepage. + +Feed output is deterministic. +The same snapshot produces the same `feed.xml`. +The page head links to the feed with an `alternate` tag. + ## Sample output The fixture set contains `signal-router` and `metrics-kit`. @@ -106,12 +205,22 @@ Captured demo-engineer-signal-router. Captured demo-engineer-metrics-kit. Published 2 projects to output/index.html. Copied 2 available preview screenshots. +Wrote output/feed.xml with 2 entries. Open output/index.html in a browser. ``` -The site shows project facts, source links, changelog previews, and screenshots. +The site shows project facts, source links, changelog previews, screenshots, and diff totals. The totals come from fixture fields and stored commit records. +With GitHub Pages configured, the deploy command adds one line: + +```text +Published 2 projects to output/index.html. +Copied 2 available preview screenshots. +Wrote output/feed.xml with 2 entries. +Staged .nojekyll for github-pages. +``` + ## Commands Build before direct CLI commands. @@ -122,8 +231,10 @@ 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 refresh` | Run configured ingest, capture, and publish stages. | +| `npm run publish` | Rebuild the site and RSS feed from SQLite. | +| `npm run deploy` | Publish, write the feed, and stage deployment files. | +| `npm run themes` | List built-in themes. | +| `npm run refresh` | Run configured ingest, capture, publish, and feed stages. | | `node dist/index.js status` | Show visibility and recent operations. | | `npm test` | Run deterministic unit and integration tests. | | `npm run typecheck` | Validate TypeScript types. | @@ -148,8 +259,12 @@ Sensitive commit messages are skipped before storage. Each project stores a repository URL and its last pushed timestamp. Each stored commit keeps its SHA, message first line, date, and source URL. +Each stored commit also keeps its line additions, line deletions, change total, and file count. Each release keeps its tag, notes, date, and source URL. +The site shows the commit diff totals on each project card. +Commit-based changelog bullets carry per-commit diff markers. + The site displays visible projects only. It links project cards to repositories. It links release notes to their release pages. @@ -158,8 +273,12 @@ It records local operations in an audit table. ## CI and test status The regular CI workflow runs typecheck, build, tests, the fixture demo, and artifact upload. +It runs on Node.js 22. The scheduled refresh workflow runs each Monday and supports manual dispatch. It uploads the generated site as a workflow artifact. +The deploy workflow publishes the site to GitHub Pages. +Tests run in a single forked process. +This keeps native modules and Playwright stable on shared runners. The test suite covers these core behaviors: @@ -168,6 +287,10 @@ The test suite covers these core behaviors: - Release-first changelog generation. - SQLite upserts and changelog replacement. - Privacy filtering and email redaction. +- Theme resolution and custom CSS overlays. +- Deployment staging for GitHub Pages. +- Commit diff totals and per-commit diff markers. +- Deterministic RSS feed output. - Fixture ingestion and static publishing. - Configured refresh orchestration. - Release source links. @@ -198,6 +321,11 @@ The fixture pipeline provides deterministic data for repeatable checks. - Capture failures are reported and do not stop publishing. - Publishing creates local files. It does not deploy them. - Scheduled runs upload artifacts. They do not commit generated output. +- GitHub Pages deployment needs the Pages feature enabled in the repository settings. +- Custom themes must define the CSS variables the base stylesheet expects. +- Diff totals cover the tracked commit window only. +- The RSS feed contains visible projects only. +- Feed links fall back to the repository homepage without a site URL. ## Roadmap @@ -205,8 +333,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 | Custom themes and deployment adapters. | +| v0.4 | Complete | Commit-diff summaries and an RSS feed. | +| v0.5 | Next | Project detail pages and a machine-readable JSON export. | ## License diff --git a/engineer-profile.config.json b/engineer-profile.config.json index 81008fb..cc01ca5 100644 --- a/engineer-profile.config.json +++ b/engineer-profile.config.json @@ -5,6 +5,13 @@ "repositoryLimit": 5, "dataDir": "data", "outputDir": "output", + "theme": "midnight", + "deployment": { + "platform": "github-pages" + }, + "feed": { + "enabled": true + }, "privacy": { "hiddenProjects": [], "redactEmails": true, diff --git a/fixtures/metrics-kit-commits.json b/fixtures/metrics-kit-commits.json index 149d3d4..cbaf0e7 100644 --- a/fixtures/metrics-kit-commits.json +++ b/fixtures/metrics-kit-commits.json @@ -9,7 +9,9 @@ "date": "2026-07-20T18:45:00Z" } }, - "html_url": "https://github.com/demo-engineer/metrics-kit/commit/e5f6789" + "html_url": "https://github.com/demo-engineer/metrics-kit/commit/e5f6789", + "stats": { "additions": 61, "deletions": 8, "total": 69 }, + "files": [{ "filename": "src/export.ts", "additions": 61, "deletions": 8, "status": "added" }] }, { "sha": "f6789012345678901234567890abcde12f345678", @@ -21,7 +23,9 @@ "date": "2026-07-18T12:30:00Z" } }, - "html_url": "https://github.com/demo-engineer/metrics-kit/commit/f678901" + "html_url": "https://github.com/demo-engineer/metrics-kit/commit/f678901", + "stats": { "additions": 3, "deletions": 21, "total": 24 }, + "files": [{ "filename": "src/counter.ts", "additions": 3, "deletions": 21, "status": "modified" }] }, { "sha": "6789012345678901234567890abcde12f3456789a", @@ -33,6 +37,8 @@ "date": "2026-07-12T08:00:00Z" } }, - "html_url": "https://github.com/demo-engineer/metrics-kit/commit/6789012" + "html_url": "https://github.com/demo-engineer/metrics-kit/commit/6789012", + "stats": { "additions": 27, "deletions": 2, "total": 29 }, + "files": [{ "filename": "test/registry.test.ts", "additions": 27, "deletions": 2, "status": "added" }] } ] diff --git a/fixtures/signal-router-commits.json b/fixtures/signal-router-commits.json index 4d22244..c2d28b4 100644 --- a/fixtures/signal-router-commits.json +++ b/fixtures/signal-router-commits.json @@ -9,7 +9,9 @@ "date": "2026-07-15T14:22:00Z" } }, - "html_url": "https://github.com/demo-engineer/signal-router/commit/a1b2c3d" + "html_url": "https://github.com/demo-engineer/signal-router/commit/a1b2c3d", + "stats": { "additions": 84, "deletions": 12, "total": 96 }, + "files": [{ "filename": "src/router.ts", "additions": 84, "deletions": 12, "status": "modified" }] }, { "sha": "b2c3d4e5f6789012345678901234567890abcde1", @@ -21,7 +23,9 @@ "date": "2026-07-10T09:15:00Z" } }, - "html_url": "https://github.com/demo-engineer/signal-router/commit/b2c3d4e" + "html_url": "https://github.com/demo-engineer/signal-router/commit/b2c3d4e", + "stats": { "additions": 10, "deletions": 4, "total": 14 }, + "files": [{ "filename": "src/backpressure.ts", "additions": 10, "deletions": 4, "status": "modified" }] }, { "sha": "c3d4e5f6789012345678901234567890abcde12f", @@ -33,7 +37,9 @@ "date": "2026-07-05T16:40:00Z" } }, - "html_url": "https://github.com/demo-engineer/signal-router/commit/c3d4e5f" + "html_url": "https://github.com/demo-engineer/signal-router/commit/c3d4e5f", + "stats": { "additions": 32, "deletions": 0, "total": 32 }, + "files": [{ "filename": "docs/topology.md", "additions": 32, "deletions": 0, "status": "added" }] }, { "sha": "d4e5f6789012345678901234567890abcde12f34", @@ -45,6 +51,8 @@ "date": "2026-06-28T11:00:00Z" } }, - "html_url": "https://github.com/demo-engineer/signal-router/commit/d4e5f67" + "html_url": "https://github.com/demo-engineer/signal-router/commit/d4e5f67", + "stats": { "additions": 5, "deletions": 5, "total": 10 }, + "files": [{ "filename": ".github/workflows/ci.yml", "additions": 5, "deletions": 5, "status": "modified" }] } ] diff --git a/package-lock.json b/package-lock.json index 87f5d51..97e90fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ -{ +{ "name": "engineer-profile", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.4.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..6195899 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.4.0", "description": "Self-maintaining engineering portfolio from repository activity", "type": "module", "main": "dist/index.js", @@ -18,10 +18,12 @@ "ingest": "npm run build && node dist/index.js ingest", "publish": "npm run build && node dist/index.js publish", "capture": "npm run build && node dist/index.js capture", - "refresh": "npm run build && node dist/index.js refresh" + "refresh": "npm run build && node dist/index.js refresh", + "deploy": "npm run build && node dist/index.js deploy", + "themes": "npm run build && node dist/index.js themes" }, "engines": { - "node": ">=20" + "node": ">=22" }, "keywords": [ "portfolio", @@ -40,7 +42,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/changelog/generator.ts b/src/changelog/generator.ts index 88bfda7..9e92f4b 100644 --- a/src/changelog/generator.ts +++ b/src/changelog/generator.ts @@ -10,11 +10,14 @@ export interface ParsedCommit { description: string; breaking: boolean; raw: string; + additions?: number; + deletions?: number; } export function parseConventionalCommit( sha: string, - message: string + message: string, + diff?: { additions?: number; deletions?: number } ): ParsedCommit | null { const firstLine = message.split("\n")[0].trim(); const match = firstLine.match(CONVENTIONAL_RE); @@ -26,6 +29,9 @@ export function parseConventionalCommit( description: match[3].trim(), breaking: firstLine.includes("!:") || firstLine.includes("BREAKING CHANGE"), raw: firstLine, + ...(diff + ? { additions: diff.additions ?? 0, deletions: diff.deletions ?? 0 } + : {}), }; } @@ -63,7 +69,15 @@ export function changelogFromCommits( b.commit.author.date.localeCompare(a.commit.author.date) ); const parsed = orderedCommits - .map((commit) => parseConventionalCommit(commit.sha, commit.commit.message)) + .map((commit) => + parseConventionalCommit( + commit.sha, + commit.commit.message, + commit.stats + ? { additions: commit.stats.additions, deletions: commit.stats.deletions } + : undefined + ) + ) .filter((item): item is ParsedCommit => item !== null); if (parsed.length === 0 && orderedCommits.length === 0) return null; @@ -86,7 +100,11 @@ export function changelogFromCommits( for (const item of items) { const scope = item.scope ? `**${item.scope}:** ` : ""; const breaking = item.breaking ? " **BREAKING**" : ""; - lines.push(`- ${scope}${item.description}${breaking} (\`${item.sha.slice(0, 7)}\`)`); + const diff = item.additions !== undefined && item.deletions !== undefined && + (item.additions > 0 || item.deletions > 0) + ? ` (+${item.additions}/-${item.deletions})` + : ""; + lines.push(`- ${scope}${item.description}${breaking}${diff} (\`${item.sha.slice(0, 7)}\`)`); } lines.push(""); } diff --git a/src/config/loader.ts b/src/config/loader.ts index 406896b..f60aad3 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,7 +1,21 @@ import { readFileSync } from "node:fs"; -import type { PortfolioConfig, PrivacyConfig } from "../types.js"; -import { DEFAULT_CONFIG, DEFAULT_PRIVACY } from "../types.js"; +import type { + DeploymentConfig, + DeploymentPlatform, + FeedConfig, + PortfolioConfig, + PrivacyConfig, + ThemeConfig, +} from "../types.js"; +import { + DEFAULT_CONFIG, + DEFAULT_DEPLOYMENT, + DEFAULT_FEED, + DEFAULT_PRIVACY, + DEFAULT_THEME, +} from "../types.js"; import { mergePrivacy } from "../privacy/controls.js"; +import { isBuiltInTheme } from "../theme/resolve.js"; export const DEFAULT_CONFIG_PATH = "engineer-profile.config.json"; @@ -56,6 +70,135 @@ function readPrivacy(source: ConfigValue): PrivacyConfig { }); } +const CNAME_PATTERN = + /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i; + +function readTheme(source: ConfigValue): ThemeConfig { + if (!("theme" in source)) return DEFAULT_THEME; + + if (typeof source.theme === "string") { + const name = source.theme.trim(); + if (!name) { + throw new Error('Configuration field "theme" must be a non-empty theme name.'); + } + if (!isBuiltInTheme(name)) { + throw new Error( + `Configuration field "theme" names an unknown theme "${name}". Add a custom CSS file or use a built-in theme.` + ); + } + return { name, customCss: null }; + } + + if (!isConfigValue(source.theme)) { + throw new Error('Configuration field "theme" must be a theme name or an object.'); + } + + const { name, customCss } = source.theme; + if (typeof name !== "string" || name.trim() === "") { + throw new Error('Configuration field "theme.name" must be a non-empty string.'); + } + if (customCss !== undefined && + (typeof customCss !== "string" || customCss.trim() === "")) { + throw new Error('Configuration field "theme.customCss" must be a non-empty file path.'); + } + if (!isBuiltInTheme(name.trim()) && customCss === undefined) { + throw new Error( + `Configuration field "theme.name" names an unknown theme "${name}". Add a custom CSS file or use a built-in theme.` + ); + } + + return { name: name.trim(), customCss: typeof customCss === "string" ? customCss.trim() : null }; +} + +function readDeployment(source: ConfigValue): DeploymentConfig { + if (!("deployment" in source)) return DEFAULT_DEPLOYMENT; + if (!isConfigValue(source.deployment)) { + throw new Error('Configuration field "deployment" must be an object.'); + } + + const { platform, cname } = source.deployment; + let resolvedPlatform: DeploymentPlatform = DEFAULT_DEPLOYMENT.platform; + if (platform !== undefined) { + if (platform !== "none" && platform !== "github-pages") { + throw new Error('Configuration field "deployment.platform" must be "none" or "github-pages".'); + } + resolvedPlatform = platform; + } + + let resolvedCname: string | null = DEFAULT_DEPLOYMENT.cname; + if (cname !== undefined && cname !== null) { + if (typeof cname !== "string" || cname.trim() === "") { + throw new Error('Configuration field "deployment.cname" must be a domain name.'); + } + const domain = cname.trim(); + if (!CNAME_PATTERN.test(domain)) { + throw new Error( + `Configuration field "deployment.cname" has an invalid domain name "${domain}".` + ); + } + resolvedCname = domain; + } + + return { platform: resolvedPlatform, cname: resolvedCname }; +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value.trim()); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function readFeed(source: ConfigValue): FeedConfig { + if (!("feed" in source)) return DEFAULT_FEED; + if (!isConfigValue(source.feed)) { + throw new Error('Configuration field "feed" must be an object.'); + } + + const { enabled, limit, description, siteUrl } = source.feed; + + let resolvedEnabled = DEFAULT_FEED.enabled; + if (enabled !== undefined) { + if (typeof enabled !== "boolean") { + throw new Error('Configuration field "feed.enabled" must be a boolean.'); + } + resolvedEnabled = enabled; + } + + let resolvedLimit = DEFAULT_FEED.limit; + if (limit !== undefined) { + if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new Error('Configuration field "feed.limit" must be an integer from 1 to 100.'); + } + resolvedLimit = limit; + } + + let resolvedDescription: string | null = DEFAULT_FEED.description; + if (description !== undefined) { + if (typeof description !== "string" || description.trim() === "") { + throw new Error('Configuration field "feed.description" must be a non-empty string.'); + } + resolvedDescription = description.trim(); + } + + let resolvedSiteUrl: string | null = DEFAULT_FEED.siteUrl; + if (siteUrl !== undefined && siteUrl !== null) { + if (typeof siteUrl !== "string" || !isHttpUrl(siteUrl)) { + throw new Error('Configuration field "feed.siteUrl" must be an absolute http or https URL.'); + } + resolvedSiteUrl = siteUrl.trim(); + } + + return { + enabled: resolvedEnabled, + limit: resolvedLimit, + description: resolvedDescription, + siteUrl: resolvedSiteUrl, + }; +} + export function loadPortfolioConfig( path: string = DEFAULT_CONFIG_PATH, clock: () => string = DEFAULT_CONFIG.clock @@ -79,6 +222,9 @@ export function loadPortfolioConfig( dataDir: readString(parsed, "dataDir", DEFAULT_CONFIG.dataDir), outputDir: readString(parsed, "outputDir", DEFAULT_CONFIG.outputDir), privacy: readPrivacy(parsed), + theme: readTheme(parsed), + deployment: readDeployment(parsed), + feed: readFeed(parsed), clock, }; } diff --git a/src/db/client.ts b/src/db/client.ts index eb64f8b..936e192 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -30,6 +30,10 @@ CREATE TABLE IF NOT EXISTS commits ( author_email TEXT, committed_at TEXT NOT NULL, url TEXT NOT NULL, + additions INTEGER DEFAULT 0, + deletions INTEGER DEFAULT 0, + changes INTEGER DEFAULT 0, + files_changed INTEGER DEFAULT 0, FOREIGN KEY (project_id) REFERENCES projects(id), UNIQUE(project_id, sha) ); @@ -71,6 +75,10 @@ export class PortfolioDatabase { this.db.pragma("foreign_keys = ON"); this.db.exec(SCHEMA); this.ensureColumn("changelog", "source_url", "TEXT"); + this.ensureColumn("commits", "additions", "INTEGER DEFAULT 0"); + this.ensureColumn("commits", "deletions", "INTEGER DEFAULT 0"); + this.ensureColumn("commits", "changes", "INTEGER DEFAULT 0"); + this.ensureColumn("commits", "files_changed", "INTEGER DEFAULT 0"); } private ensureColumn(table: string, column: string, definition: string): void { @@ -153,12 +161,17 @@ export class PortfolioDatabase { author_email: string | null; committed_at: string; url: string; + additions?: number; + deletions?: number; + changes?: number; + files_changed?: number; }> ): number { const stmt = this.db.prepare( `INSERT OR IGNORE INTO commits - (project_id, sha, message, author_name, author_email, committed_at, url) - VALUES (?, ?, ?, ?, ?, ?, ?)` + (project_id, sha, message, author_name, author_email, committed_at, url, + additions, deletions, changes, files_changed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ); let inserted = 0; const insertMany = this.db.transaction((rows) => { @@ -170,7 +183,11 @@ export class PortfolioDatabase { commit.author_name, commit.author_email, commit.committed_at, - commit.url + commit.url, + commit.additions ?? 0, + commit.deletions ?? 0, + commit.changes ?? 0, + commit.files_changed ?? 0 ); if (result.changes > 0) inserted++; } @@ -260,10 +277,16 @@ export class PortfolioDatabase { author_name: string | null; committed_at: string; url: string; + additions: number; + deletions: number; + changes: number; + files_changed: number; }> { return this.db .prepare( - "SELECT sha, message, author_name, committed_at, url FROM commits WHERE project_id = ? ORDER BY committed_at DESC, id DESC LIMIT ?" + `SELECT sha, message, author_name, committed_at, url, + additions, deletions, changes, files_changed + FROM commits WHERE project_id = ? ORDER BY committed_at DESC, id DESC LIMIT ?` ) .all(projectId, limit) as Array<{ sha: string; @@ -271,9 +294,39 @@ export class PortfolioDatabase { author_name: string | null; committed_at: string; url: string; + additions: number; + deletions: number; + changes: number; + files_changed: number; }>; } + getDiffSummary(projectId: number): { + commits: number; + additions: number; + deletions: number; + changes: number; + files: number; + } { + const row = this.db + .prepare( + `SELECT COUNT(*) AS commits, + COALESCE(SUM(additions), 0) AS additions, + COALESCE(SUM(deletions), 0) AS deletions, + COALESCE(SUM(changes), 0) AS changes, + COALESCE(SUM(files_changed), 0) AS files + FROM commits WHERE project_id = ?` + ) + .get(projectId) as { + commits: number; + additions: number; + deletions: number; + changes: number; + files: number; + }; + return row; + } + countCommits(projectId: number): number { const row = this.db .prepare("SELECT COUNT(*) AS count FROM commits WHERE project_id = ?") diff --git a/src/deploy/adapters.ts b/src/deploy/adapters.ts new file mode 100644 index 0000000..ca51459 --- /dev/null +++ b/src/deploy/adapters.ts @@ -0,0 +1,44 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { openDatabase } from "../db/client.js"; +import type { DeploymentConfig, PortfolioConfig } from "../types.js"; + +export interface DeploymentResult { + platform: string; + prepared: boolean; + files: string[]; +} + +export function prepareDeployment(config: PortfolioConfig): DeploymentResult { + switch (config.deployment.platform) { + case "github-pages": + return prepareGitHubPages(config, config.deployment); + default: + return { platform: "none", prepared: false, files: [] }; + } +} + +function prepareGitHubPages( + config: PortfolioConfig, + deployment: DeploymentConfig +): DeploymentResult { + mkdirSync(config.outputDir, { recursive: true }); + + const files: string[] = []; + writeFileSync(join(config.outputDir, ".nojekyll"), "", "utf-8"); + files.push(".nojekyll"); + + if (deployment.cname) { + writeFileSync(join(config.outputDir, "CNAME"), `${deployment.cname}\n`, "utf-8"); + files.push("CNAME"); + } + + const db = openDatabase(config.dataDir, config.clock); + try { + db.logIngest("deploy", `github-pages: ${files.join(", ")} -> ${config.outputDir}`); + } finally { + db.close(); + } + + return { platform: "github-pages", prepared: true, files }; +} diff --git a/src/feed/rss.ts b/src/feed/rss.ts new file mode 100644 index 0000000..7627f65 --- /dev/null +++ b/src/feed/rss.ts @@ -0,0 +1,145 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; + +function loadHomepage(): string { + try { + const url = new URL("../../package.json", import.meta.url); + const pkg = JSON.parse(readFileSync(url, "utf-8")) as { homepage?: string }; + return pkg.homepage ?? "https://github.com/"; + } catch { + return "https://github.com/"; + } +} + +const FALLBACK_HOMEPAGE = loadHomepage(); + +export interface FeedItem { + title: string; + link: string; + guid: string; + description: string; + publishedAt: string; +} + +export interface FeedChannel { + title: string; + link: string; + description: string; + lastBuildDate: string; +} + +export interface FeedEntryInput { + project: Pick; + changelog: ChangelogEntry[]; +} + +export interface FeedResult { + path: string; + items: number; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function toRfc1123(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value.slice(0, 10); + return date.toUTCString(); +} + +export function markdownToPlainText(markdown: string): string { + return markdown + .split("\n") + .map((line) => + line + .replace(/^\s*#{1,6}\s*/, "") + .replace(/^\s*[-*]\s+/, "") + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .trim() + ) + .filter(Boolean) + .join(" "); +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith("/") ? value : `${value}/`; +} + +function resolveFeedLink(config: PortfolioConfig): string { + if (config.feed.siteUrl) return ensureTrailingSlash(config.feed.siteUrl); + if (config.deployment.cname) return `https://${config.deployment.cname}/`; + const clean = FALLBACK_HOMEPAGE.split("#")[0]; + return ensureTrailingSlash(clean); +} + +export function buildRssXml(channel: FeedChannel, items: FeedItem[]): string { + const itemXml = items + .map( + (item) => ` + ${escapeXml(item.title)} + ${escapeXml(item.link)} + ${escapeXml(item.guid)} + ${escapeXml(toRfc1123(item.publishedAt))} + ${escapeXml(item.description)} + ` + ) + .join("\n"); + + return ` + + + ${escapeXml(channel.title)} + ${escapeXml(channel.link)} + ${escapeXml(channel.description)} + en + ${escapeXml(toRfc1123(channel.lastBuildDate))} + engineer-profile + +${itemXml} + + +`; +} + +function buildFeedItems(entries: FeedEntryInput[]): FeedItem[] { + const items: FeedItem[] = []; + for (const entry of entries) { + const latest = entry.changelog[0]; + if (!latest) continue; + items.push({ + title: `${entry.project.name}: ${latest.title}`, + link: latest.source_url || entry.project.url, + guid: latest.source_url || `${entry.project.url}#${latest.version}`, + description: markdownToPlainText(latest.body), + publishedAt: latest.published_at, + }); + } + return items.sort( + (a, b) => b.publishedAt.localeCompare(a.publishedAt) || a.guid.localeCompare(b.guid) + ); +} + +export function writeFeed( + config: PortfolioConfig, + entries: FeedEntryInput[] +): FeedResult { + const items = buildFeedItems(entries).slice(0, config.feed.limit); + const channel: FeedChannel = { + title: `${config.title} / release feed`, + link: resolveFeedLink(config), + description: config.feed.description ?? config.tagline, + lastBuildDate: config.clock(), + }; + mkdirSync(config.outputDir, { recursive: true }); + const path = join(config.outputDir, "feed.xml"); + writeFileSync(path, buildRssXml(channel, items), "utf-8"); + return { path, items: items.length }; +} diff --git a/src/index.ts b/src/index.ts index aea5c27..05b9b4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import { Command } from "commander"; import { ingestOwnerRepos, ingestRepository } from "./ingest/orchestrator.js"; import { captureAllProjects, captureLocalHtml, closeBrowser } from "./preview/capture.js"; import { copyScreenshotsToOutput, publishSite } from "./publish/site.js"; +import { prepareDeployment } from "./deploy/adapters.js"; +import { listThemes } from "./theme/registry.js"; import { loadAllFixtures } from "./fixtures/loader.js"; import { openDatabase } from "./db/client.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "./types.js"; @@ -16,7 +18,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.4.0"); function resolveConfig(options: { config?: string; data?: string; output?: string }): PortfolioConfig { const base = options.config @@ -67,6 +69,7 @@ addConfigOption(program const copied = copyScreenshotsToOutput(config); console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); console.log(`Copied ${copied} available preview screenshots.`); + if (result.feed) console.log(`Wrote ${result.feed.path} with ${result.feed.items} entries.`); console.log("Open output/index.html in a browser."); })); @@ -131,6 +134,7 @@ addConfigOption(program const copied = copyScreenshotsToOutput(config); console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); console.log(`Copied ${copied} available preview screenshots.`); + if (result.feed) console.log(`Wrote ${result.feed.path} with ${result.feed.items} entries.`); })); addConfigOption(program @@ -146,11 +150,46 @@ addConfigOption(program console.log(`Captured ${result.captured} project previews.`); console.log(`Published ${result.published.projectCount} projects to ${result.published.indexPath}.`); console.log(`Copied ${result.copiedScreenshots} available preview screenshots.`); + if (result.published.feed) { + console.log(`Wrote ${result.published.feed.path} with ${result.published.feed.items} entries.`); + } + if (result.deployment.prepared) { + console.log(`Staged ${result.deployment.files.join(", ")} for ${result.deployment.platform}.`); + } for (const error of result.captureErrors) { console.warn(`Skipped ${error.slug}: ${error.message}`); } })); +program + .command("themes") + .description("List built-in site themes") + .action(() => { + for (const theme of listThemes()) { + console.log(`${theme.name} - ${theme.description}`); + } + }); + +addConfigOption(program + .command("deploy") + .description("Stage the published site for a deployment platform") + .option("-d, --data ", "Data directory") + .option("-o, --output ", "Output directory") + .action((options) => { + const config = resolveConfig(options); + const result = publishSite(config); + const copied = copyScreenshotsToOutput(config); + const deployment = prepareDeployment(config); + console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); + console.log(`Copied ${copied} available preview screenshots.`); + if (result.feed) console.log(`Wrote ${result.feed.path} with ${result.feed.items} entries.`); + if (deployment.prepared) { + console.log(`Staged ${deployment.files.join(", ")} for ${deployment.platform}.`); + } else { + console.log(`No deployment platform is configured. Set deployment.platform in the configuration file.`); + } + })); + addConfigOption(program .command("status") .description("Show project visibility and recent operations") diff --git a/src/ingest/github.ts b/src/ingest/github.ts index 6ed0fa7..2be4233 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.4.0", }; if (this.token) headers.Authorization = `Bearer ${this.token}`; return headers; @@ -122,6 +122,10 @@ export function commitsToRows( author_email: string | null; committed_at: string; url: string; + additions: number; + deletions: number; + changes: number; + files_changed: number; }> { return filterCommitsByPrivacy(commits, privacy) .map((commit) => ({ @@ -131,5 +135,9 @@ export function commitsToRows( author_email: privacy.redactEmails ? null : commit.commit.author.email, committed_at: commit.commit.author.date, url: commit.html_url, + additions: commit.stats?.additions ?? 0, + deletions: commit.stats?.deletions ?? 0, + changes: commit.stats?.total ?? (commit.stats?.additions ?? 0) + (commit.stats?.deletions ?? 0), + files_changed: commit.files?.length ?? 0, })); -} \ No newline at end of file +} diff --git a/src/publish/site.ts b/src/publish/site.ts index d382163..20b4cae 100644 --- a/src/publish/site.ts +++ b/src/publish/site.ts @@ -2,6 +2,8 @@ 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 { writeFeed } from "../feed/rss.js"; +import { resolveThemeCss } from "../theme/resolve.js"; import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; function escapeHtml(text: string): string { @@ -76,6 +78,13 @@ function displayDate(value: string): string { interface ProjectEvidence { commits: number; releases: number; + diff: { + commits: number; + additions: number; + deletions: number; + changes: number; + files: number; + }; } function projectCard( @@ -97,6 +106,13 @@ function projectCard( const screenshot = project.screenshot_path ? `Preview of ${escapeHtml(project.name)}` : `
Preview pendingCapture with Playwright
`; + const diffSignal = evidence.diff.additions > 0 || evidence.diff.deletions > 0 + ? `
+ +${evidence.diff.additions} added + -${evidence.diff.deletions} removed + ${evidence.diff.commits} commits +
` + : ""; return `
${screenshot}Preview / 1280 x 720
@@ -109,6 +125,7 @@ function projectCard( ${project.forks} forks ${evidence.commits} commits + ${diffSignal} ${topics ? `
${topics}
` : ""}
Latest signal${latest?.source ?? "pending"}
@@ -124,125 +141,11 @@ function projectCard(
`; } -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; generatedAt: string; + feed: { path: string; items: number } | null; } export function publishSite(config: PortfolioConfig): PublishResult { @@ -256,6 +159,7 @@ export function publishSite(config: PortfolioConfig): PublishResult { evidence: { commits: db.countCommits(project.id), releases: db.countReleases(project.id), + diff: db.getDiffSummary(project.id), }, })); const totalStars = projects.reduce((sum, project) => sum + project.stars, 0); @@ -270,16 +174,20 @@ export function publishSite(config: PortfolioConfig): PublishResult { const visibleProjects = projects.length === 0 ? '
Portfolio / 00Empty

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

' : ""; + const { css: themeCss, name: themeName } = resolveThemeCss(config.theme); + const feedLink = config.feed.enabled + ? `\n ` + : ""; const html = ` - + - ${escapeHtml(config.title)} / Portfolio - + ${feedLink}${escapeHtml(config.title)} / Portfolio +
@@ -344,8 +252,10 @@ export function publishSite(config: PortfolioConfig): PublishResult { writeFileSync(join(config.outputDir, `${view.project.slug}-changelog.md`), markdown, "utf-8"); } + const feed = config.feed.enabled ? writeFeed(config, views) : null; + db.logIngest("publish", `${projects.length} projects -> ${indexPath}`); - return { indexPath, projectCount: projects.length, generatedAt }; + return { indexPath, projectCount: projects.length, generatedAt, feed }; } finally { db.close(); } @@ -367,4 +277,4 @@ export function copyScreenshotsToOutput(config: PortfolioConfig): number { db.close(); } return copied; -} \ No newline at end of file +} diff --git a/src/refresh/run.ts b/src/refresh/run.ts index acc9e53..fb5ba97 100644 --- a/src/refresh/run.ts +++ b/src/refresh/run.ts @@ -1,6 +1,7 @@ import { ingestOwnerRepos } from "../ingest/orchestrator.js"; import { captureAllProjects } from "../preview/capture.js"; import { copyScreenshotsToOutput, publishSite, type PublishResult } from "../publish/site.js"; +import { prepareDeployment, type DeploymentResult } from "../deploy/adapters.js"; import type { FixtureData } from "../ingest/orchestrator.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "../types.js"; @@ -15,6 +16,7 @@ export interface RefreshResult { copiedScreenshots: number; captureErrors: Array<{ slug: string; message: string }>; published: PublishResult; + deployment: DeploymentResult; } export async function refreshPortfolio( @@ -36,6 +38,7 @@ export async function refreshPortfolio( (slug, error) => captureErrors.push({ slug, message: error.message }) ); const published = publishSite(config); + const deployment = prepareDeployment(config); return { ingested: ingested.length, @@ -43,5 +46,6 @@ export async function refreshPortfolio( copiedScreenshots: copyScreenshotsToOutput(config), captureErrors, published, + deployment, }; } diff --git a/src/theme/base.ts b/src/theme/base.ts new file mode 100644 index 0000000..f80f30e --- /dev/null +++ b/src/theme/base.ts @@ -0,0 +1,125 @@ +export const BASE_CSS = ` +: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; + --danger: #ff7b72; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.24); + --glow: rgba(70, 148, 232, 0.2); + --visual-bg: #0a1525; + --visual-label-bg: rgba(8, 17, 31, 0.72); + --blue-rgb: 103, 183, 255; + --mint-rgb: 167, 243, 208; + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + --radius: 16px; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); line-height: 1.5; font-family: var(--font-sans); } +a { color: inherit; } +.site-shell { min-height: 100vh; background: radial-gradient(circle at 82% -10%, var(--glow), 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 var(--font-mono); 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 var(--font-mono); 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 var(--font-mono); 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, var(--panel-strong), var(--ink-soft)); box-shadow: var(--shadow); } +.aside-index { display: flex; justify-content: space-between; color: var(--orange); font: 0.68rem/1 var(--font-mono); 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 var(--font-mono); 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: var(--panel); } +.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 var(--font-mono); 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: var(--radius); background: linear-gradient(135deg, var(--panel-strong), var(--ink-soft)); box-shadow: var(--shadow); } +.project-visual { position: relative; min-height: 310px; background: var(--visual-bg); } +.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(var(--blue-rgb), 0.05), rgba(var(--blue-rgb), 0.05) 1px, transparent 1px, transparent 14px); } +.placeholder small { color: var(--muted); font: 0.68rem/1 var(--font-mono); 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: var(--visual-label-bg); 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 var(--font-mono); } +.facts strong { color: var(--text); font-size: 1rem; font-weight: 600; } +.diff-signal { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; } +.diff-signal span { padding: 4px 10px; border: 1px solid var(--line); border-radius: 999px; font: 0.68rem/1 var(--font-mono); } +.diff-added { color: var(--mint); } +.diff-removed { color: var(--danger); } +.diff-files { color: var(--muted); } +.tags { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 26px; } +.tag { padding: 5px 9px; border: 1px solid rgba(var(--mint-rgb), 0.26); border-radius: 999px; color: var(--mint); font: 0.68rem/1 var(--font-mono); } +.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 var(--font-mono); } +.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(var(--mint-rgb), 0.08); font: 0.76rem var(--font-mono); } +.text-link { display: inline-block; margin-top: 14px; color: var(--blue); font: 0.73rem/1 var(--font-mono); 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 var(--font-mono); 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 var(--line); color: var(--muted); font: 0.72rem/1.4 var(--font-mono); } +.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 var(--font-mono); } +.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; } +} +`; diff --git a/src/theme/midnight.ts b/src/theme/midnight.ts new file mode 100644 index 0000000..f90026e --- /dev/null +++ b/src/theme/midnight.ts @@ -0,0 +1,31 @@ +import type { ThemeDefinition } from "./registry.js"; + +export const midnight: ThemeDefinition = { + name: "midnight", + description: "Dark interface with blue and mint accents.", + css: ` +: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; + --danger: #ff7b72; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.24); + --glow: rgba(70, 148, 232, 0.2); + --visual-bg: #0a1525; + --visual-label-bg: rgba(8, 17, 31, 0.72); + --blue-rgb: 103, 183, 255; + --mint-rgb: 167, 243, 208; + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} +`, +}; diff --git a/src/theme/paper.ts b/src/theme/paper.ts new file mode 100644 index 0000000..a448439 --- /dev/null +++ b/src/theme/paper.ts @@ -0,0 +1,33 @@ +import type { ThemeDefinition } from "./registry.js"; + +export const paper: ThemeDefinition = { + name: "paper", + description: "Light interface with serif type and warm paper tones.", + css: ` +:root { + color-scheme: light; + --ink: #f6f3ec; + --ink-soft: #efeae0; + --panel: #ffffff; + --panel-strong: #fbf8f1; + --line: rgba(31, 40, 55, 0.16); + --text: #1f2733; + --muted: #5d6878; + --blue: #1f4d8f; + --blue-soft: #3c5a86; + --mint: #15765a; + --orange: #a5541c; + --danger: #b42318; + --shadow: 0 24px 60px rgba(40, 32, 16, 0.12); + --glow: rgba(31, 77, 143, 0.14); + --visual-bg: #e8e2d6; + --visual-label-bg: rgba(255, 255, 255, 0.78); + --blue-rgb: 31, 77, 143; + --mint-rgb: 21, 118, 90; + --font-sans: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} +.visual-label { border-color: rgba(31, 40, 55, 0.22); } +.button.primary { color: #ffffff; } +`, +}; diff --git a/src/theme/registry.ts b/src/theme/registry.ts new file mode 100644 index 0000000..429ab3c --- /dev/null +++ b/src/theme/registry.ts @@ -0,0 +1,23 @@ +import { midnight } from "./midnight.js"; +import { paper } from "./paper.js"; +import { terminal } from "./terminal.js"; + +export interface ThemeDefinition { + name: string; + description: string; + css: string; +} + +export const BUILT_IN_THEMES: readonly ThemeDefinition[] = [ + midnight, + paper, + terminal, +]; + +export function getTheme(name: string): ThemeDefinition | undefined { + return BUILT_IN_THEMES.find((theme) => theme.name === name); +} + +export function listThemes(): Array<{ name: string; description: string }> { + return BUILT_IN_THEMES.map(({ name, description }) => ({ name, description })); +} diff --git a/src/theme/resolve.ts b/src/theme/resolve.ts new file mode 100644 index 0000000..e81ad36 --- /dev/null +++ b/src/theme/resolve.ts @@ -0,0 +1,48 @@ +import { readFileSync } from "node:fs"; +import { BASE_CSS } from "./base.js"; +import { BUILT_IN_THEMES, getTheme } from "./registry.js"; +import type { ThemeConfig } from "../types.js"; + +export interface ResolvedTheme { + name: string; + css: string; +} + +export const BUILT_IN_THEME_NAMES: readonly string[] = BUILT_IN_THEMES.map( + (theme) => theme.name +); + +export function isBuiltInTheme(name: string): boolean { + return BUILT_IN_THEME_NAMES.includes(name); +} + +function readCustomCss(path: string): string { + let content: string; + try { + content = readFileSync(path, "utf-8"); + } catch (error) { + throw new Error(`Could not read custom theme CSS "${path}": ${(error as Error).message}`); + } + return content.trim(); +} + +export function resolveThemeCss(theme: ThemeConfig): ResolvedTheme { + const builtIn = getTheme(theme.name); + const custom = theme.customCss ? readCustomCss(theme.customCss) : ""; + + if (builtIn) { + return { + name: theme.name, + css: `${BASE_CSS}\n${builtIn.css}\n${custom ? `${custom}\n` : ""}`, + }; + } + + if (!theme.customCss) { + const known = BUILT_IN_THEME_NAMES.join(", "); + throw new Error( + `Unknown theme "${theme.name}". Choose from: ${known}, or provide a custom CSS file.` + ); + } + + return { name: theme.name, css: `${BASE_CSS}\n${custom}\n` }; +} diff --git a/src/theme/terminal.ts b/src/theme/terminal.ts new file mode 100644 index 0000000..12958e5 --- /dev/null +++ b/src/theme/terminal.ts @@ -0,0 +1,36 @@ +import type { ThemeDefinition } from "./registry.js"; + +export const terminal: ThemeDefinition = { + name: "terminal", + description: "Monospace interface inspired by a terminal emulator.", + css: ` +:root { + color-scheme: dark; + --ink: #0b0e13; + --ink-soft: #0f141b; + --panel: #131a23; + --panel-strong: #1a222d; + --line: rgba(126, 231, 135, 0.2); + --text: #d8ffe0; + --muted: #7f9c88; + --blue: #7ee787; + --blue-soft: #a7eeb0; + --mint: #ffd166; + --orange: #ffa657; + --danger: #ff9e64; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.5); + --glow: rgba(126, 231, 135, 0.12); + --visual-bg: #060809; + --visual-label-bg: rgba(11, 14, 19, 0.8); + --blue-rgb: 126, 231, 135; + --mint-rgb: 255, 209, 102; + --font-sans: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} +.brand, .nav-link, .kicker, .eyebrow, .visual-label, .card-topline, .section-heading, .source-badge, .facts, .stat span, .change-date, .text-link, .button, .site-footer { letter-spacing: 0.02em; text-transform: none; } +h1 { font-weight: 700; letter-spacing: -0.04em; } +.project-card { border-radius: 0; } +.hero-aside { border-radius: 0; } +.audit-panel { border-radius: 0; } +`, +}; diff --git a/src/types.ts b/src/types.ts index 92c30da..0daae9e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,17 @@ export interface GitHubCommit { }; }; html_url: string; + stats?: { + additions: number; + deletions: number; + total: number; + }; + files?: Array<{ + filename: string; + additions: number; + deletions: number; + status: string; + }>; } export interface GitHubRelease { @@ -78,6 +89,25 @@ export interface PrivacyConfig { maxCommitsPerProject: number; } +export interface ThemeConfig { + name: string; + customCss: string | null; +} + +export type DeploymentPlatform = "none" | "github-pages"; + +export interface DeploymentConfig { + platform: DeploymentPlatform; + cname: string | null; +} + +export interface FeedConfig { + enabled: boolean; + limit: number; + description: string | null; + siteUrl: string | null; +} + export interface PortfolioConfig { owner: string; title: string; @@ -86,6 +116,9 @@ export interface PortfolioConfig { dataDir: string; outputDir: string; privacy: PrivacyConfig; + theme: ThemeConfig; + deployment: DeploymentConfig; + feed: FeedConfig; clock: () => string; } @@ -95,6 +128,23 @@ export const DEFAULT_PRIVACY: PrivacyConfig = { maxCommitsPerProject: 50, }; +export const DEFAULT_THEME: ThemeConfig = { + name: "midnight", + customCss: null, +}; + +export const DEFAULT_DEPLOYMENT: DeploymentConfig = { + platform: "none", + cname: null, +}; + +export const DEFAULT_FEED: FeedConfig = { + enabled: true, + limit: 20, + description: null, + siteUrl: null, +}; + export const DEFAULT_CONFIG = { owner: "demo-engineer", title: "EngineerProfile", @@ -103,5 +153,8 @@ export const DEFAULT_CONFIG = { dataDir: "data", outputDir: "output", privacy: DEFAULT_PRIVACY, + theme: DEFAULT_THEME, + deployment: DEFAULT_DEPLOYMENT, + feed: DEFAULT_FEED, clock: () => new Date().toISOString(), } satisfies PortfolioConfig; diff --git a/tests/changelog.test.ts b/tests/changelog.test.ts index 22c40c0..eb93388 100644 --- a/tests/changelog.test.ts +++ b/tests/changelog.test.ts @@ -82,6 +82,38 @@ describe("changelogFromCommits", () => { it("returns null for empty commits", () => { expect(changelogFromCommits([])).toBeNull(); }); + + it("appends diff totals to conventional commit bullets", () => { + const withStats: GitHubCommit[] = [ + { + sha: "abc123def456", + commit: { + message: "feat(api): add health endpoint", + author: { name: "Dev", email: "dev@test.com", date: "2026-01-15T10:00:00Z" }, + }, + html_url: "https://github.com/o/r/commit/abc123", + stats: { additions: 42, deletions: 7, total: 49 }, + }, + { + sha: "def456abc789", + commit: { + message: "fix(db): close connection on shutdown", + author: { name: "Dev", email: "dev@test.com", date: "2026-01-14T10:00:00Z" }, + }, + html_url: "https://github.com/o/r/commit/def456", + stats: { additions: 2, deletions: 11, total: 13 }, + }, + ]; + const section = changelogFromCommits(withStats); + expect(section).not.toBeNull(); + expect(section!.body).toContain("add health endpoint (+42/-7)"); + expect(section!.body).toContain("close connection on shutdown (+2/-11)"); + }); + + it("omits diff markers when a commit has no stats", () => { + const section = changelogFromCommits(sampleCommits); + expect(section!.body).not.toMatch(/\(\+\d+\/-\d+\)/); + }); }); describe("buildChangelog", () => { diff --git a/tests/config.test.ts b/tests/config.test.ts index 44e2720..7302993 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -37,4 +37,124 @@ describe("portfolio configuration", () => { 'Configuration field "repositoryLimit" must be an integer from 1 to 100.' ); }); + + it("loads a built-in theme by name", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: "paper" })); + + const config = loadPortfolioConfig(TEST_FILE, () => "2026-07-31T00:00:00.000Z"); + expect(config.theme).toEqual({ name: "paper", customCss: null }); + }); + + it("loads a custom theme with a CSS overlay", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync( + TEST_FILE, + JSON.stringify({ theme: { name: "studio", customCss: "themes/studio.css" } }) + ); + + const config = loadPortfolioConfig(TEST_FILE, () => "2026-07-31T00:00:00.000Z"); + expect(config.theme).toEqual({ name: "studio", customCss: "themes/studio.css" }); + }); + + it("rejects an unknown theme without a CSS overlay", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: "candy" })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow(/unknown theme "candy"/); + }); + + it("rejects a theme object without a name", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: { customCss: "themes/studio.css" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme.name" must be a non-empty string.' + ); + }); + + it("loads a GitHub Pages deployment with a custom domain", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync( + TEST_FILE, + JSON.stringify({ deployment: { platform: "github-pages", cname: "engineering.example.com" } }) + ); + + const config = loadPortfolioConfig(TEST_FILE, () => "2026-07-31T00:00:00.000Z"); + expect(config.deployment).toEqual({ + platform: "github-pages", + cname: "engineering.example.com", + }); + }); + + it("rejects an unknown deployment platform", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ deployment: { platform: "ftp" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "deployment.platform" must be "none" or "github-pages".' + ); + }); + + it("rejects an invalid custom domain", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync( + TEST_FILE, + JSON.stringify({ deployment: { platform: "github-pages", cname: "not a domain" } }) + ); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "deployment.cname" has an invalid domain name "not a domain".' + ); + }); + + it("loads feed settings from the configuration", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync( + TEST_FILE, + JSON.stringify({ + feed: { + enabled: false, + limit: 5, + description: "Release notes from public repositories.", + siteUrl: "https://engineering.example.com", + }, + }) + ); + + const config = loadPortfolioConfig(TEST_FILE, () => "2026-07-31T00:00:00.000Z"); + expect(config.feed).toEqual({ + enabled: false, + limit: 5, + description: "Release notes from public repositories.", + siteUrl: "https://engineering.example.com", + }); + }); + + it("rejects a feed object that is not an object", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ feed: "on" })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "feed" must be an object.' + ); + }); + + it("rejects an invalid feed limit", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ feed: { limit: 0 } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "feed.limit" must be an integer from 1 to 100.' + ); + }); + + it("rejects an invalid feed site URL", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ feed: { siteUrl: "not-a-url" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "feed.siteUrl" must be an absolute http or https URL.' + ); + }); }); diff --git a/tests/db.test.ts b/tests/db.test.ts index 87d5c3d..30d266c 100644 --- a/tests/db.test.ts +++ b/tests/db.test.ts @@ -94,6 +94,84 @@ describe("PortfolioDatabase", () => { db.close(); }); + it("aggregates commit diff totals per project", () => { + const db = openDatabase(TEST_DATA); + const project = db.upsertProject({ + slug: "diff", + name: "Diff", + description: null, + url: "https://github.com/o/diff", + homepage: null, + language: null, + stars: 0, + forks: 0, + topics: "[]", + last_pushed: "2026-01-01T00:00:00Z", + visible: 1, + screenshot_path: null, + ingested_at: "2026-01-01T00:00:00Z", + }); + + db.upsertCommits(project.id, [ + { + sha: "one", + message: "feat: a", + author_name: "Dev", + author_email: null, + committed_at: "2026-01-02T00:00:00Z", + url: "https://github.com/o/diff/commit/one", + additions: 84, + deletions: 12, + changes: 96, + files_changed: 3, + }, + { + sha: "two", + message: "fix: b", + author_name: "Dev", + author_email: null, + committed_at: "2026-01-01T00:00:00Z", + url: "https://github.com/o/diff/commit/two", + additions: 5, + deletions: 9, + changes: 14, + files_changed: 1, + }, + ]); + + const summary = db.getDiffSummary(project.id); + expect(summary).toEqual({ commits: 2, additions: 89, deletions: 21, changes: 110, files: 4 }); + db.close(); + }); + + it("returns zero totals when no commits exist", () => { + const db = openDatabase(TEST_DATA); + const project = db.upsertProject({ + slug: "empty", + name: "Empty", + description: null, + url: "https://github.com/o/empty", + homepage: null, + language: null, + stars: 0, + forks: 0, + topics: "[]", + last_pushed: "2026-01-01T00:00:00Z", + visible: 1, + screenshot_path: null, + ingested_at: "2026-01-01T00:00:00Z", + }); + + expect(db.getDiffSummary(project.id)).toEqual({ + commits: 0, + additions: 0, + deletions: 0, + changes: 0, + files: 0, + }); + db.close(); + }); + it("filters visible projects only", () => { const db = openDatabase(TEST_DATA); const visible = { diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts new file mode 100644 index 0000000..8468976 --- /dev/null +++ b/tests/deploy.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { prepareDeployment } from "../src/deploy/adapters.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = join("data", "test-deploy"); +const TEST_OUTPUT = join("output", "test-deploy"); + +afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +describe("deployment preparation", () => { + it("writes no-jekyll markers for GitHub Pages", () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deployment: { platform: "github-pages" as const, cname: null }, + }; + + const result = prepareDeployment(config); + + expect(result.prepared).toBe(true); + expect(result.platform).toBe("github-pages"); + expect(result.files).toEqual([".nojekyll"]); + expect(existsSync(join(TEST_OUTPUT, ".nojekyll"))).toBe(true); + }); + + it("writes a CNAME file when a custom domain is configured", () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deployment: { platform: "github-pages" as const, cname: "engineering.example.com" }, + }; + + const result = prepareDeployment(config); + + expect(result.files).toContain("CNAME"); + const cname = readFileSync(join(TEST_OUTPUT, "CNAME"), "utf-8").trim(); + expect(cname).toBe("engineering.example.com"); + }); + + it("does nothing when no platform is configured", () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deployment: { platform: "none" as const, cname: null }, + }; + + const result = prepareDeployment(config); + + expect(result.prepared).toBe(false); + expect(result.files).toEqual([]); + expect(existsSync(join(TEST_OUTPUT, ".nojekyll"))).toBe(false); + }); +}); diff --git a/tests/feed.test.ts b/tests/feed.test.ts new file mode 100644 index 0000000..58d4352 --- /dev/null +++ b/tests/feed.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { loadAllFixtures } from "../src/fixtures/loader.js"; +import { ingestOwnerRepos } from "../src/ingest/orchestrator.js"; +import { openDatabase } from "../src/db/client.js"; +import { publishSite } from "../src/publish/site.js"; +import { buildRssXml, markdownToPlainText, toRfc1123, type FeedItem } from "../src/feed/rss.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = join("data", "test-feed"); +const TEST_OUTPUT = join("output", "test-feed"); + +afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +function channel() { + return { + title: "EngineerProfile / release feed", + link: "https://example.com/", + description: "A living index of shipped systems", + lastBuildDate: "2026-07-31T00:00:00.000Z", + }; +} + +function sampleItem(): FeedItem { + return { + title: "Signal Router: Priority queues", + link: "https://github.com/demo-engineer/signal-router/releases/tag/v0.3.0", + guid: "https://github.com/demo-engineer/signal-router/releases/tag/v0.3.0", + description: "Channel priority queues", + publishedAt: "2026-07-14T10:00:00Z", + }; +} + +describe("RSS feed helpers", () => { + it("formats dates as RFC 1123 in UTC", () => { + expect(toRfc1123("2026-07-14T10:00:00Z")).toBe("Tue, 14 Jul 2026 10:00:00 GMT"); + }); + + it("keeps a plain string when the timestamp is invalid", () => { + expect(toRfc1123("not-a-date")).toBe("not-a-date"); + }); + + it("converts markdown to plain text", () => { + expect(markdownToPlainText("## Features\n- one **two** `three`")).toBe("Features one two three"); + }); +}); + +describe("buildRssXml", () => { + it("produces deterministic RSS 2.0 output", () => { + const first = buildRssXml(channel(), [sampleItem()]); + const second = buildRssXml(channel(), [sampleItem()]); + expect(first).toBe(second); + expect(first).toContain('Signal Router: Priority queues"); + expect(first).toContain("Tue, 14 Jul 2026 10:00:00 GMT"); + expect(first).toContain(''); + }); + + it("escapes XML metacharacters in item content", () => { + const item: FeedItem = { + ...sampleItem(), + title: "A & B ", + link: "https://x.com/?a=1&b=2", + description: "1 < 2 && 3 > 0", + }; + const xml = buildRssXml(channel(), [item]); + expect(xml).toContain("A & B <release>"); + expect(xml).toContain("1 < 2 && 3 > 0"); + }); +}); + +describe("feed publishing", () => { + it("writes feed.xml with one entry per visible project", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + clock: () => "2026-07-31T00:00:00.000Z", + }; + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + + const result = publishSite(config); + expect(result.feed).not.toBeNull(); + expect(result.feed!.items).toBe(2); + expect(existsSync(join(TEST_OUTPUT, "feed.xml"))).toBe(true); + + const xml = readFileSync(join(TEST_OUTPUT, "feed.xml"), "utf-8"); + expect(xml).toContain("signal-router: Priority queues"); + expect(xml).toContain("metrics-kit: Unreleased changes"); + }); + + it("excludes hidden projects from the feed", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + }; + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + const db = openDatabase(TEST_DATA); + db.setVisibility("demo-engineer-metrics-kit", false); + db.close(); + + publishSite(config); + const xml = readFileSync(join(TEST_OUTPUT, "feed.xml"), "utf-8"); + expect(xml).toContain("signal-router"); + expect(xml).not.toContain("metrics-kit"); + }); + + it("caps the number of feed entries", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + feed: { ...DEFAULT_CONFIG.feed, limit: 1 }, + }; + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + + publishSite(config); + const xml = readFileSync(join(TEST_OUTPUT, "feed.xml"), "utf-8"); + expect(xml.match(//g)).toHaveLength(1); + }); + + it("writes identical feed bytes for the same snapshot", async () => { + const makeFeed = async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + clock: () => "2026-07-31T00:00:00.000Z", + }; + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + publishSite(config); + return readFileSync(join(TEST_OUTPUT, "feed.xml"), "utf-8"); + }; + + const first = await makeFeed(); + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + const second = await makeFeed(); + expect(first).toBe(second); + }); +}); diff --git a/tests/github.test.ts b/tests/github.test.ts index dd10730..ce9a7d1 100644 --- a/tests/github.test.ts +++ b/tests/github.test.ts @@ -107,4 +107,35 @@ describe("commitsToRows", () => { expect(rows).toHaveLength(1); expect(rows[0].sha).toBe("abc123"); }); + + it("maps diff stats onto commit rows", () => { + const withStats: GitHubCommit[] = [ + { + sha: "abc123", + commit: { + message: "feat: add feature", + author: { name: "Dev", email: "dev@test.com", date: "2026-01-01T00:00:00Z" }, + }, + html_url: "https://github.com/o/r/commit/abc123", + stats: { additions: 84, deletions: 12, total: 96 }, + files: [ + { filename: "src/app.ts", additions: 84, deletions: 12, status: "modified" }, + { filename: "src/main.ts", additions: 0, deletions: 0, status: "renamed" }, + ], + }, + ]; + const rows = commitsToRows(withStats, DEFAULT_PRIVACY); + expect(rows[0].additions).toBe(84); + expect(rows[0].deletions).toBe(12); + expect(rows[0].changes).toBe(96); + expect(rows[0].files_changed).toBe(2); + }); + + it("defaults missing stats to zero", () => { + const rows = commitsToRows(commits, DEFAULT_PRIVACY); + expect(rows[0].additions).toBe(0); + expect(rows[0].deletions).toBe(0); + expect(rows[0].changes).toBe(0); + expect(rows[0].files_changed).toBe(0); + }); }); diff --git a/tests/site.test.ts b/tests/site.test.ts index 03061cf..efefcfe 100644 --- a/tests/site.test.ts +++ b/tests/site.test.ts @@ -87,6 +87,29 @@ describe("database and publish pipeline", () => { expect(html).toContain("total stars"); }); + it("marks the published page with the configured theme", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + theme: { name: "terminal", customCss: null }, + }; + 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); + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain('data-theme="terminal"'); + expect(html).toContain("--font-mono"); + }); + it("respects visibility when publishing", async () => { const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; await ingestRepository( @@ -127,4 +150,65 @@ describe("database and publish pipeline", () => { const md = readFileSync(changelogPath, "utf-8"); expect(md).toContain("v0.3.0"); }); + + it("publishes commit diff totals on project cards", 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); + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain("+131 added"); + expect(html).toContain("-21 removed"); + expect(html).toContain("diff-signal"); + }); + + it("links the RSS feed from the page head", 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); + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain('rel="alternate" type="application/rss+xml"'); + expect(html).toContain('href="feed.xml"'); + }); + + it("omits the feed link when the feed is disabled", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + feed: { ...DEFAULT_CONFIG.feed, enabled: false }, + }; + 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.feed).toBeNull(); + expect(existsSync(join(TEST_OUTPUT, "feed.xml"))).toBe(false); + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).not.toContain('type="application/rss+xml"'); + }); }); diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..8e9b12b --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { resolveThemeCss, isBuiltInTheme, BUILT_IN_THEME_NAMES } from "../src/theme/resolve.js"; +import { listThemes } from "../src/theme/registry.js"; +import type { ThemeConfig } from "../src/types.js"; + +const TEST_DIR = join("data", "test-theme"); +const CUSTOM_CSS = join(TEST_DIR, "custom.css"); + +afterEach(() => rmSync(TEST_DIR, { recursive: true, force: true })); + +describe("theme resolution", () => { + it("resolves the default theme to the midnight stylesheet", () => { + const theme: ThemeConfig = { name: "midnight", customCss: null }; + const resolved = resolveThemeCss(theme); + + expect(resolved.name).toBe("midnight"); + expect(resolved.css).toContain(".site-shell"); + expect(resolved.css).toContain("--blue: #67b7ff"); + }); + + it("builds a distinct stylesheet for each built-in theme", () => { + const themes = listThemes(); + expect(themes.map((theme) => theme.name)).toEqual(["midnight", "paper", "terminal"]); + + const stylesheets = new Set(themes.map((theme) => resolveThemeCss({ name: theme.name, customCss: null }).css)); + expect(stylesheets.size).toBe(themes.length); + expect(BUILT_IN_THEME_NAMES).toEqual(["midnight", "paper", "terminal"]); + }); + + it("marks built-in theme names and rejects unknown ones", () => { + expect(isBuiltInTheme("paper")).toBe(true); + expect(isBuiltInTheme("candy")).toBe(false); + }); + + it("throws a clear error for an unknown theme without custom CSS", () => { + const theme: ThemeConfig = { name: "candy", customCss: null }; + expect(() => resolveThemeCss(theme)).toThrow(/Unknown theme "candy"/); + }); + + it("appends custom CSS after a built-in theme", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(CUSTOM_CSS, ":root { --blue: #ff0000; }"); + + const resolved = resolveThemeCss({ name: "midnight", customCss: CUSTOM_CSS }); + expect(resolved.css.indexOf("--blue: #ff0000")).toBeGreaterThan( + resolved.css.indexOf("--blue: #67b7ff") + ); + }); + + it("supports a custom theme name backed by a custom CSS file", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(CUSTOM_CSS, ":root { --ink: #fff; }"); + + const resolved = resolveThemeCss({ name: "studio", customCss: CUSTOM_CSS }); + expect(resolved.name).toBe("studio"); + expect(resolved.css).toContain("--ink: #fff"); + }); + + it("throws a clear error when the custom CSS file is missing", () => { + const theme: ThemeConfig = { name: "studio", customCss: join(TEST_DIR, "missing.css") }; + expect(() => resolveThemeCss(theme)).toThrow(/Could not read custom theme CSS/); + }); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs index b699bb5..e495e59 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -4,5 +4,11 @@ export default { environment: "node", include: ["tests/**/*.test.ts"], testTimeout: 30000, + pool: "forks", + poolOptions: { + forks: { + singleFork: true, + }, + }, }, -}; \ No newline at end of file +}; diff --git a/vitest.config.ts b/vitest.config.ts index 66e7fb6..6ef0fc1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,11 @@ export default defineConfig({ environment: "node", include: ["tests/**/*.test.ts"], testTimeout: 30000, + pool: "forks", + poolOptions: { + forks: { + singleFork: true, + }, + }, }, });