diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae27d9..527ab04 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 @@ -44,6 +44,9 @@ jobs: - name: Run fixture demo run: node dist/index.js demo + - name: Verify deployment preparation + run: node dist/index.js deploy --adapter netlify + - name: Store demo output if: success() uses: actions/upload-artifact@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..0fc299a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ 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. +- Switch the published presentation between built-in themes. +- Prepare the published site for GitHub Pages, Netlify, or Vercel. - Run one configured refresh from a scheduled workflow. The fixture demo runs without secrets and without network access. @@ -31,12 +33,16 @@ flowchart LR P --> W S --> W W --> O[Static output] + O --> E[Deploy] + E --> H[Host files] ``` | Area | Responsibility | | --- | --- | -| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, and privacy settings. | +| `engineer-profile.config.json` | Store owner, presentation, refresh, theme, deploy, and privacy settings. | | `src/config/` | Validate checked-in JSON and merge safe defaults. | +| `src/theme/` | Provide the palette and typography for each published theme. | +| `src/deploy/` | Prepare the published site for a static host. | | `src/refresh/` | Coordinate ingest, best-effort capture, and static publishing. | | `src/ingest/` | Fetch public GitHub data and map it to records. | | `src/db/` | Store projects, commits, changelogs, and audit events. | @@ -51,7 +57,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,12 +74,24 @@ 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, theme, deploy targets, and privacy controls. The loader accepts repository limits from 1 through 100. It rejects malformed values before network access. +It rejects unknown theme and deploy adapter names. CLI `--config`, `--data`, and `--output` options override file values. +```json +{ + "owner": "your-name", + "theme": "paper", + "deploy": { + "adapter": "github-pages", + "siteUrl": "https://your-name.github.io/portfolio" + } +} +``` + Run a network-backed refresh with the checked-in settings: ```bash @@ -94,6 +112,51 @@ npm run refresh Do not put a token in repository files. Use `.env.example` as a variable reference. +## Themes + +Each theme changes the palette and light mode of the published site. +Set `theme` in the configuration file. +The default theme is `midnight`. + +| Theme | Result | +| --- | --- | +| `midnight` | Dark blue technical layout. | +| `paper` | Light editorial layout. | +| `terminal` | Green-on-black monospace layout. | + +Publish with a different theme: + +```bash +npm run publish -- --config engineer-profile.config.json +``` + +Change `theme` to `paper` or `terminal`, then run the command again. +Themes apply to `index.html` and all copied screenshots. +Unknown theme names fail validation before publishing. + +## Deployment + +The `deploy` command prepares the published site for one static host. +It writes the platform file into `output/`. +It does not push or upload anything. + +| Adapter | Files written | Result | +| --- | --- | --- | +| `github-pages` | `.nojekyll`, optional `CNAME` | Disable Jekyll and set the custom domain. | +| `netlify` | `netlify.toml` | Set publish directory and screenshot caching. | +| `vercel` | `vercel.json` | Set clean URLs and screenshot caching. | + +Set the adapter in the configuration file, or pass `--adapter`. +Run `deploy` after `publish` or `refresh`. + +```bash +npm run deploy -- --adapter netlify +``` + +Pass `--site-url` to set a custom domain for GitHub Pages. +Deploy adapters create local files only. +Push the output directory with your chosen host's own tooling. + ## Sample output The fixture set contains `signal-router` and `metrics-kit`. @@ -112,6 +175,16 @@ Open output/index.html in a browser. The site shows project facts, source links, changelog previews, and screenshots. The totals come from fixture fields and stored commit records. +A deployment prepares the same snapshot for a host: + +```text +Prepared Netlify deployment in output. +Wrote output/netlify.toml. +Next steps: + Link the output directory as the publish directory in Netlify. + Deploy with the Netlify CLI or a connected repository. +``` + ## Commands Build before direct CLI commands. @@ -124,6 +197,7 @@ Build before direct CLI commands. | `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 deploy -- --adapter netlify` | Prepare the site for a static host. | | `node dist/index.js status` | Show visibility and recent operations. | | `npm test` | Run deterministic unit and integration tests. | | `npm run typecheck` | Validate TypeScript types. | @@ -168,6 +242,8 @@ The test suite covers these core behaviors: - Release-first changelog generation. - SQLite upserts and changelog replacement. - Privacy filtering and email redaction. +- Theme registry selection and theme-aware publishing. +- Deployment adapter file generation and validation. - Fixture ingestion and static publishing. - Configured refresh orchestration. - Release source links. @@ -184,7 +260,7 @@ npm test ### Validation status -Typecheck and build pass locally. +Typecheck and build pass locally on Node 22. CI runs the complete test suite on Ubuntu with Chromium installed. The fixture pipeline provides deterministic data for repeatable checks. @@ -196,6 +272,8 @@ The fixture pipeline provides deterministic data for repeatable checks. - Changelog quality depends on releases or conventional commits. - External pages can fail during capture. - Capture failures are reported and do not stop publishing. +- Themes change local styling only. They cannot change site structure. +- Deploy adapters write local files. They do not upload them. - Publishing creates local files. It does not deploy them. - Scheduled runs upload artifacts. They do not commit generated output. @@ -205,8 +283,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 for GitHub Pages, Netlify, and Vercel. | +| v0.4 | Next | Commit-diff summaries and an RSS feed. | +| v0.5 | Later | Publishing to branches from the scheduled workflow. | ## License diff --git a/engineer-profile.config.json b/engineer-profile.config.json index 81008fb..4a46aba 100644 --- a/engineer-profile.config.json +++ b/engineer-profile.config.json @@ -3,6 +3,7 @@ "title": "EngineerProfile", "tagline": "A living index of shipped systems, maintained from repository evidence", "repositoryLimit": 5, + "theme": "midnight", "dataDir": "data", "outputDir": "output", "privacy": { diff --git a/package-lock.json b/package-lock.json index 87f5d51..3b0753c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { - "better-sqlite3": "^11.8.1", + "better-sqlite3": "^13.0.2", "commander": "^15.0.0" }, "bin": { @@ -25,7 +25,7 @@ "vitest": "^3.0.5" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1353,79 +1353,16 @@ "node": ">=12" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", - "hasInstallScript": true, + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" } }, "node_modules/cac": { @@ -1465,12 +1402,6 @@ "node": ">= 16" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -1498,21 +1429,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1523,33 +1439,6 @@ "node": ">=6" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1609,15 +1498,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1646,18 +1526,6 @@ } } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -1673,44 +1541,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1735,33 +1565,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1788,31 +1591,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "node_modules/node-addon-api": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, "engines": { - "node": ">=10" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" + "node": "^18 || ^20 || >= 21" } }, "node_modules/pathe": { @@ -1845,6 +1630,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1913,72 +1699,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/rollup": { "version": "4.62.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", @@ -2024,38 +1744,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2063,51 +1751,6 @@ "dev": true, "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2132,24 +1775,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -2163,34 +1788,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2286,18 +1883,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -2340,18 +1925,13 @@ "dev": true, "license": "MIT" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -2548,12 +2128,6 @@ "engines": { "node": ">=8" } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" } } } diff --git a/package.json b/package.json index d36edbe..ce5bc49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "description": "Self-maintaining engineering portfolio from repository activity", "type": "module", "main": "dist/index.js", @@ -18,10 +18,11 @@ "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", + "deploy": "npm run build && node dist/index.js deploy", "refresh": "npm run build && node dist/index.js refresh" }, "engines": { - "node": ">=20" + "node": ">=22" }, "keywords": [ "portfolio", @@ -40,7 +41,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/config/loader.ts b/src/config/loader.ts index 406896b..cd75229 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,7 +1,9 @@ import { readFileSync } from "node:fs"; -import type { PortfolioConfig, PrivacyConfig } from "../types.js"; +import type { DeploymentConfig, PortfolioConfig, PrivacyConfig } from "../types.js"; import { DEFAULT_CONFIG, DEFAULT_PRIVACY } from "../types.js"; import { mergePrivacy } from "../privacy/controls.js"; +import { isValidThemeId, themeIds } from "../theme/registry.js"; +import { isValidAdapterId, deployAdapterIds } from "../deploy/adapters.js"; export const DEFAULT_CONFIG_PATH = "engineer-profile.config.json"; @@ -20,6 +22,43 @@ function readString(source: ConfigValue, key: string, fallback: string): string return value.trim(); } +function readOptionalString(source: ConfigValue, key: string): string | undefined { + if (!(key in source)) return undefined; + const value = source[key]; + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`Configuration field "${key}" must be a non-empty string.`); + } + return value.trim(); +} + +function readTheme(source: ConfigValue): string { + const theme = readString(source, "theme", DEFAULT_CONFIG.theme); + if (!isValidThemeId(theme)) { + throw new Error( + `Configuration field "theme" must be one of: ${themeIds().join(", ")}.` + ); + } + return theme; +} + +function readDeployment(source: ConfigValue): DeploymentConfig { + if (!("deploy" in source)) return {}; + if (!isConfigValue(source.deploy)) { + throw new Error('Configuration field "deploy" must be an object.'); + } + + const adapter = readOptionalString(source.deploy, "adapter"); + if (adapter !== undefined && !isValidAdapterId(adapter)) { + throw new Error( + `Configuration field "deploy.adapter" must be one of: ${deployAdapterIds().join(", ")}.` + ); + } + return { + adapter, + siteUrl: readOptionalString(source.deploy, "siteUrl"), + }; +} + function readLimit(source: ConfigValue, key: string, fallback: number): number { if (!(key in source)) return fallback; const value = source[key]; @@ -78,6 +117,8 @@ export function loadPortfolioConfig( repositoryLimit: readLimit(parsed, "repositoryLimit", DEFAULT_CONFIG.repositoryLimit), dataDir: readString(parsed, "dataDir", DEFAULT_CONFIG.dataDir), outputDir: readString(parsed, "outputDir", DEFAULT_CONFIG.outputDir), + theme: readTheme(parsed), + deploy: readDeployment(parsed), privacy: readPrivacy(parsed), clock, }; diff --git a/src/deploy/adapters.ts b/src/deploy/adapters.ts new file mode 100644 index 0000000..130767d --- /dev/null +++ b/src/deploy/adapters.ts @@ -0,0 +1,140 @@ +import type { DeploymentConfig } from "../types.js"; + +export interface DeployFile { + path: string; + contents: string; +} + +export interface DeployPlan { + adapterId: string; + label: string; + files: DeployFile[]; + instructions: string[]; +} + +export interface DeployAdapter { + id: string; + label: string; + files: (deploy: DeploymentConfig) => DeployFile[]; + instructions: (deploy: DeploymentConfig) => string[]; +} + +function domainFromSiteUrl(siteUrl: string | undefined): string | undefined { + if (!siteUrl) return undefined; + try { + const url = new URL(siteUrl); + return url.host || undefined; + } catch { + return undefined; + } +} + +const SCREENSHOT_CACHE = + "public, max-age=31536000, immutable"; + +const githubPages: DeployAdapter = { + id: "github-pages", + label: "GitHub Pages", + files: (deploy) => { + const files: DeployFile[] = [{ path: ".nojekyll", contents: "" }]; + const domain = domainFromSiteUrl(deploy.siteUrl); + if (domain) files.push({ path: "CNAME", contents: `${domain}\n` }); + return files; + }, + instructions: () => [ + "Push the output directory to the branch that serves GitHub Pages.", + "Enable Pages from the settings page of that branch.", + ], +}; + +const netlify: DeployAdapter = { + id: "netlify", + label: "Netlify", + files: () => [ + { + path: "netlify.toml", + contents: [ + "[build]", + ' publish = "."', + "", + "[[headers]]", + ' for = "/assets/screenshots/*"', + " [headers.values]", + ` Cache-Control = "${SCREENSHOT_CACHE}"`, + "", + ].join("\n"), + }, + ], + instructions: () => [ + "Link the output directory as the publish directory in Netlify.", + "Deploy with the Netlify CLI or a connected repository.", + ], +}; + +const vercel: DeployAdapter = { + id: "vercel", + label: "Vercel", + files: () => [ + { + path: "vercel.json", + contents: [ + "{", + ' "cleanUrls": true,', + ' "headers": [', + " {", + ' "source": "/assets/screenshots/(.*)",', + ' "headers": [', + " {", + ' "key": "Cache-Control",', + ` "value": "${SCREENSHOT_CACHE}"`, + " }", + " ]", + " }", + " ]", + "}", + "", + ].join("\n"), + }, + ], + instructions: () => [ + "Set the output directory as the Vercel project root.", + "Deploy with the Vercel CLI or a connected repository.", + ], +}; + +const ADAPTERS: Record = { + [githubPages.id]: githubPages, + [netlify.id]: netlify, + [vercel.id]: vercel, +}; + +export function isValidAdapterId(id: string): boolean { + return Object.prototype.hasOwnProperty.call(ADAPTERS, id); +} + +export function deployAdapterIds(): string[] { + return Object.keys(ADAPTERS); +} + +export function getAdapter(id: string): DeployAdapter { + const adapter = ADAPTERS[id]; + if (!adapter) { + throw new Error( + `Unknown deployment adapter "${id}". Use one of: ${deployAdapterIds().join(", ")}.` + ); + } + return adapter; +} + +export function buildDeployPlan( + adapterId: string, + deploy: DeploymentConfig +): DeployPlan { + const adapter = getAdapter(adapterId); + return { + adapterId: adapter.id, + label: adapter.label, + files: adapter.files(deploy), + instructions: adapter.instructions(deploy), + }; +} diff --git a/src/deploy/run.ts b/src/deploy/run.ts new file mode 100644 index 0000000..a86c033 --- /dev/null +++ b/src/deploy/run.ts @@ -0,0 +1,65 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { openDatabase } from "../db/client.js"; +import type { DeploymentConfig, PortfolioConfig } from "../types.js"; +import { buildDeployPlan, getAdapter } from "./adapters.js"; + +export { deployAdapterIds } from "./adapters.js"; + +export interface DeployOptions extends DeploymentConfig { + adapter?: string; +} + +export interface DeployResult { + adapterId: string; + label: string; + written: string[]; + instructions: string[]; +} + +export function prepareDeployment( + config: PortfolioConfig, + options: DeployOptions = {} +): DeployResult { + const adapterId = options.adapter ?? config.deploy.adapter; + if (!adapterId) { + throw new Error( + "No deployment adapter was configured. Pass --adapter or set deploy.adapter in the configuration file." + ); + } + getAdapter(adapterId); + + const indexPath = join(config.outputDir, "index.html"); + if (!existsSync(indexPath)) { + throw new Error( + `No published site was found at "${config.outputDir}". Run publish or refresh first.` + ); + } + + const deployConfig: DeploymentConfig = { + adapter: adapterId, + siteUrl: options.siteUrl ?? config.deploy.siteUrl, + }; + const plan = buildDeployPlan(adapterId, deployConfig); + + mkdirSync(config.outputDir, { recursive: true }); + const written = plan.files.map((file) => { + const target = join(config.outputDir, file.path); + writeFileSync(target, file.contents, "utf-8"); + return target; + }); + + const db = openDatabase(config.dataDir, config.clock); + try { + db.logIngest("deploy", `${adapterId} -> ${config.outputDir}`); + } finally { + db.close(); + } + + return { + adapterId: plan.adapterId, + label: plan.label, + written, + instructions: plan.instructions, + }; +} diff --git a/src/index.ts b/src/index.ts index aea5c27..69c5a0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,14 @@ import { openDatabase } from "./db/client.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "./types.js"; import { DEFAULT_CONFIG_PATH, loadPortfolioConfig } from "./config/loader.js"; import { refreshPortfolio } from "./refresh/run.js"; +import { prepareDeployment, deployAdapterIds } from "./deploy/run.js"; const program = new Command(); program .name("engineer-profile") .description("Build a local engineering portfolio from public repository evidence") - .version("0.2.0"); + .version("0.3.0"); function resolveConfig(options: { config?: string; data?: string; output?: string }): PortfolioConfig { const base = options.config @@ -151,6 +152,25 @@ addConfigOption(program } })); +addConfigOption(program + .command("deploy") + .description("Prepare the published site for a static host") + .option("--adapter ", `Deployment adapter: ${deployAdapterIds().join(", ")}`) + .option("--site-url ", "Public URL for the deployed site") + .option("-d, --data ", "Data directory") + .option("-o, --output ", "Output directory") + .action((options) => { + const config = resolveConfig(options); + const result = prepareDeployment(config, { + adapter: options.adapter, + siteUrl: options.siteUrl, + }); + console.log(`Prepared ${result.label} deployment in ${config.outputDir}.`); + for (const file of result.written) console.log(`Wrote ${file}.`); + console.log("Next steps:"); + for (const instruction of result.instructions) console.log(` ${instruction}`); + })); + 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..4b1ee2d 100644 --- a/src/ingest/github.ts +++ b/src/ingest/github.ts @@ -15,7 +15,7 @@ export class GitHubClient { private headers(): Record { const headers: Record = { Accept: "application/vnd.github+json", - "User-Agent": "engineer-profile/0.1.0", + "User-Agent": "engineer-profile/0.3.0", }; if (this.token) headers.Authorization = `Bearer ${this.token}`; return headers; diff --git a/src/publish/site.ts b/src/publish/site.ts index d382163..0ddec9f 100644 --- a/src/publish/site.ts +++ b/src/publish/site.ts @@ -2,6 +2,7 @@ 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 { getTheme, type ThemeDefinition } from "../theme/registry.js"; import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; function escapeHtml(text: string): string { @@ -124,96 +125,105 @@ function projectCard( `; } -function siteCss(): string { +function themeCss(theme: ThemeDefinition): string { + const tokens = theme.tokens; 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); + color-scheme: ${tokens.colorScheme}; + --ink: ${tokens.ink}; + --ink-soft: ${tokens.inkSoft}; + --panel: ${tokens.panel}; + --panel-strong: ${tokens.panelStrong}; + --line: ${tokens.line}; + --text: ${tokens.text}; + --muted: ${tokens.muted}; + --accent: ${tokens.accent}; + --accent-soft: ${tokens.accentSoft}; + --accent-2: ${tokens.accent2}; + --accent-3: ${tokens.accent3}; + --glow: ${tokens.glow}; + --visual-bg: ${tokens.visualBg}; + --shadow: ${tokens.shadow}; + --font-mono: "SFMono-Regular", Consolas, monospace; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +`; +} + +function siteCss(): string { + return ` * { 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); } +.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 "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); } +.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(--accent); color: var(--accent); 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(--accent); } .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; } +.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(--accent-2); 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-copy { max-width: 530px; margin: 28px 0 0; color: var(--accent-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(--accent-3); 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 "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); } +.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: color-mix(in srgb, var(--panel) 70%, transparent); } .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; } +.audit-time { color: var(--accent); 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: 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; } +.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, 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(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); } +.placeholder { display: flex; min-height: 310px; align-items: center; justify-content: center; flex-direction: column; gap: 7px; color: var(--accent); background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--accent) 8%, transparent), color-mix(in srgb, var(--accent) 8%, transparent) 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 var(--line); border-radius: 5px; background: color-mix(in srgb, var(--ink) 72%, transparent); color: var(--accent-soft); } .project-body { padding: 30px 34px 32px; } -.card-topline { display: flex; justify-content: space-between; gap: 12px; color: var(--blue); } +.card-topline { display: flex; justify-content: space-between; gap: 12px; color: var(--accent); } .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; } +.description { max-width: 620px; margin: 0; color: var(--accent-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; } .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; } +.tag { padding: 5px 9px; border: 1px solid color-mix(in srgb, var(--accent-2) 26%, transparent); border-radius: 999px; color: var(--accent-2); 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); } +.section-heading { display: flex; justify-content: space-between; color: var(--accent-3); } .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-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(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; } +.change-preview code { padding: 2px 4px; border-radius: 3px; color: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 8%, transparent); font: 0.76rem var(--font-mono); } +.text-link { display: inline-block; margin-top: 14px; color: var(--accent); 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 "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 { 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(--accent); color: var(--ink); } +.button.primary:hover { background: var(--accent-soft); } .button.secondary { border: 1px solid var(--line); color: var(--text); } -.button.secondary:hover { border-color: var(--blue); color: var(--blue); } +.button.secondary:hover { border-color: var(--accent); color: var(--accent); } .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; } +.audit-list li { display: grid; grid-template-columns: 150px 72px 1fr; gap: 12px; padding: 8px 0; border-bottom: 1px solid color-mix(in srgb, var(--line) 55%, transparent); color: var(--muted); font: 0.72rem/1.4 var(--font-mono); } +.audit-list time { color: var(--accent); } +.audit-list strong { color: var(--accent-3); 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; } @@ -248,6 +258,7 @@ export interface PublishResult { export function publishSite(config: PortfolioConfig): PublishResult { const db = openDatabase(config.dataDir, config.clock); try { + const theme = getTheme(config.theme); const projects = db.listProjects(true); const generatedAt = config.clock(); const views = projects.map((project) => ({ @@ -277,9 +288,10 @@ export function publishSite(config: PortfolioConfig): PublishResult { + ${escapeHtml(config.title)} / Portfolio - +
diff --git a/src/theme/registry.ts b/src/theme/registry.ts new file mode 100644 index 0000000..7657285 --- /dev/null +++ b/src/theme/registry.ts @@ -0,0 +1,113 @@ +export interface ThemeTokens { + colorScheme: "dark" | "light"; + ink: string; + inkSoft: string; + panel: string; + panelStrong: string; + line: string; + text: string; + muted: string; + accent: string; + accentSoft: string; + accent2: string; + accent3: string; + glow: string; + shadow: string; + visualBg: string; +} + +export interface ThemeDefinition { + id: string; + label: string; + description: string; + tokens: ThemeTokens; +} + +export const THEMES: Record = { + midnight: { + id: "midnight", + label: "Midnight", + description: "Dark blue interface for a technical profile.", + tokens: { + colorScheme: "dark", + ink: "#08111f", + inkSoft: "#0d1a2d", + panel: "#112139", + panelStrong: "#172a46", + line: "rgba(169, 195, 222, 0.18)", + text: "#f3f7fb", + muted: "#9db0c7", + accent: "#67b7ff", + accentSoft: "#b8dcff", + accent2: "#a7f3d0", + accent3: "#ffb86b", + glow: "rgba(70, 148, 232, 0.2)", + shadow: "0 24px 60px rgba(0, 0, 0, 0.24)", + visualBg: "#0a1525", + }, + }, + paper: { + id: "paper", + label: "Paper", + description: "Light editorial layout for a readable profile.", + tokens: { + colorScheme: "light", + ink: "#f6f3ec", + inkSoft: "#efe9dc", + panel: "#fffdf7", + panelStrong: "#f3ecdd", + line: "rgba(58, 46, 30, 0.18)", + text: "#241c12", + muted: "#6f6352", + accent: "#9a3b2e", + accentSoft: "#7a2f25", + accent2: "#2f6b4f", + accent3: "#a15c17", + glow: "rgba(154, 59, 46, 0.12)", + shadow: "0 24px 60px rgba(58, 46, 30, 0.16)", + visualBg: "#ece2cf", + }, + }, + terminal: { + id: "terminal", + label: "Terminal", + description: "Monochrome green layout inspired by a terminal.", + tokens: { + colorScheme: "dark", + ink: "#070a07", + inkSoft: "#0a0f0a", + panel: "#0d140d", + panelStrong: "#111b11", + line: "rgba(120, 220, 120, 0.28)", + text: "#c8ffc8", + muted: "#7fb98f", + accent: "#66ff99", + accentSoft: "#aaffcc", + accent2: "#ffd166", + accent3: "#ff6b6b", + glow: "rgba(102, 255, 153, 0.12)", + shadow: "0 24px 60px rgba(0, 0, 0, 0.5)", + visualBg: "#040604", + }, + }, +}; + +export const DEFAULT_THEME_ID = "midnight"; + +export function isValidThemeId(id: string): boolean { + return Object.prototype.hasOwnProperty.call(THEMES, id); +} + +export function themeIds(): string[] { + return Object.keys(THEMES); +} + +export function getTheme(id: string): ThemeDefinition { + const theme = THEMES[id]; + if (!theme) { + throw new Error( + `Unknown theme "${id}". Use one of: ${themeIds().join(", ")}.` + ); + } + return theme; +} diff --git a/src/types.ts b/src/types.ts index 92c30da..1198394 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,11 @@ export interface PrivacyConfig { maxCommitsPerProject: number; } +export interface DeploymentConfig { + adapter?: string; + siteUrl?: string; +} + export interface PortfolioConfig { owner: string; title: string; @@ -85,6 +90,8 @@ export interface PortfolioConfig { repositoryLimit?: number; dataDir: string; outputDir: string; + theme: string; + deploy: DeploymentConfig; privacy: PrivacyConfig; clock: () => string; } @@ -102,6 +109,8 @@ export const DEFAULT_CONFIG = { repositoryLimit: 5, dataDir: "data", outputDir: "output", + theme: "midnight", + deploy: {}, privacy: DEFAULT_PRIVACY, clock: () => new Date().toISOString(), } satisfies PortfolioConfig; diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts new file mode 100644 index 0000000..183825a --- /dev/null +++ b/tests/deploy.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, afterEach, beforeEach } from "vitest"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { ingestOwnerRepos } from "../src/ingest/orchestrator.js"; +import { publishSite } from "../src/publish/site.js"; +import { loadAllFixtures } from "../src/fixtures/loader.js"; +import { prepareDeployment } from "../src/deploy/run.js"; +import { buildDeployPlan, deployAdapterIds, getAdapter, isValidAdapterId } from "../src/deploy/adapters.js"; +import { loadPortfolioConfig } from "../src/config/loader.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = join("data", "test-deploy"); +const TEST_OUTPUT = join("output", "test-deploy"); + +beforeEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +describe("deployment adapter registry", () => { + it("exposes the three built-in adapters", () => { + expect(deployAdapterIds().sort()).toEqual(["github-pages", "netlify", "vercel"]); + expect(isValidAdapterId("github-pages")).toBe(true); + expect(getAdapter("vercel").label).toBe("Vercel"); + }); + + it("rejects unknown adapters with a list of choices", () => { + expect(() => getAdapter("surge")).toThrow( + 'Unknown deployment adapter "surge". Use one of: github-pages, netlify, vercel.' + ); + }); + + it("builds deterministic files for each adapter", () => { + const netlify = buildDeployPlan("netlify", {}); + expect(netlify.files).toHaveLength(1); + expect(netlify.files[0].path).toBe("netlify.toml"); + expect(netlify.files[0].contents).toContain("publish = \".\""); + + const vercel = buildDeployPlan("vercel", {}); + expect(vercel.files[0].path).toBe("vercel.json"); + expect(vercel.files[0].contents).toContain('"cleanUrls": true'); + + const pages = buildDeployPlan("github-pages", {}); + expect(pages.files[0].path).toBe(".nojekyll"); + }); + + it("writes a CNAME when a site URL is provided", () => { + const plan = buildDeployPlan("github-pages", { siteUrl: "https://example.dev" }); + expect(plan.files.map((file) => file.path)).toEqual([".nojekyll", "CNAME"]); + expect(plan.files[1].contents).toBe("example.dev\n"); + }); + + it("provides instructions for every adapter", () => { + for (const id of deployAdapterIds()) { + expect(buildDeployPlan(id, {}).instructions.length).toBeGreaterThan(0); + } + }); +}); + +describe("deployment preparation", () => { + it("writes adapter files into the published output", async () => { + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; + await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures()); + publishSite(config); + + const result = prepareDeployment(config, { adapter: "netlify" }); + expect(result.label).toBe("Netlify"); + expect(result.written).toHaveLength(1); + expect(existsSync(join(TEST_OUTPUT, "netlify.toml"))).toBe(true); + expect(readFileSync(join(TEST_OUTPUT, "netlify.toml"), "utf-8")).toContain("[build]"); + }); + + it("uses the adapter configured in the checked-in file", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deploy: { adapter: "vercel" }, + }; + await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures()); + publishSite(config); + + const result = prepareDeployment(config); + expect(result.adapterId).toBe("vercel"); + expect(existsSync(join(TEST_OUTPUT, "vercel.json"))).toBe(true); + }); + + it("fails before writing when no site has been published", () => { + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; + expect(() => prepareDeployment(config, { adapter: "github-pages" })).toThrow( + "No published site was found" + ); + }); + + it("fails when no adapter is selected", () => { + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; + expect(() => prepareDeployment(config)).toThrow("No deployment adapter was configured"); + }); +}); + +describe("deployment configuration validation", () => { + function writeConfig(deploy: Record): string { + const dir = join("data", "test-deploy-config"); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "portfolio.json"); + writeFileSync(path, JSON.stringify({ deploy })); + return path; + } + + it("loads a configured adapter and site URL", () => { + const config = loadPortfolioConfig( + writeConfig({ adapter: "github-pages", siteUrl: "https://docs.example.dev" }), + () => "2026-07-31T00:00:00.000Z" + ); + expect(config.deploy.adapter).toBe("github-pages"); + expect(config.deploy.siteUrl).toBe("https://docs.example.dev"); + }); + + it("rejects an unknown adapter", () => { + expect(() => loadPortfolioConfig(writeConfig({ adapter: "surge" }))).toThrow( + 'Configuration field "deploy.adapter" must be one of: github-pages, netlify, vercel.' + ); + }); +}); diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..4abbe21 --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, afterEach, beforeEach } from "vitest"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { ingestOwnerRepos } from "../src/ingest/orchestrator.js"; +import { publishSite } from "../src/publish/site.js"; +import { loadAllFixtures } from "../src/fixtures/loader.js"; +import { loadPortfolioConfig } from "../src/config/loader.js"; +import { getTheme, isValidThemeId, themeIds, THEMES } from "../src/theme/registry.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = join("data", "test-theme"); +const TEST_OUTPUT = join("output", "test-theme"); + +beforeEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +describe("theme registry", () => { + it("exposes the default theme and valid ids", () => { + expect(isValidThemeId("midnight")).toBe(true); + expect(themeIds().sort()).toEqual(["midnight", "paper", "terminal"]); + expect(getTheme("midnight").label).toBe("Midnight"); + }); + + it("defines distinct palettes for every theme", () => { + const palettes = themeIds().map((id) => getTheme(id).tokens.accent); + expect(new Set(palettes).size).toBe(themeIds().length); + expect(themeIds().every((id) => THEMES[id].tokens.colorScheme.length > 0)).toBe(true); + }); + + it("rejects unknown theme ids", () => { + expect(() => getTheme("missing")).toThrow("Unknown theme"); + expect(isValidThemeId("missing")).toBe(false); + }); +}); + +describe("theme-aware publishing", () => { + async function publishWithTheme(theme: string): Promise { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + theme, + clock: () => "2026-07-31T00:00:00.000Z", + }; + await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures()); + const result = publishSite(config); + return readFileSync(result.indexPath, "utf-8"); + } + + it("injects the selected theme tokens into the page", async () => { + const html = await publishWithTheme("paper"); + expect(html).toContain("--ink: #f6f3ec"); + expect(html).toContain("color-scheme: light"); + expect(html).toContain(''); + }); + + it("keeps publishing deterministic for each theme", async () => { + const first = await publishWithTheme("terminal"); + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + const second = await publishWithTheme("terminal"); + expect(first).toBe(second); + }); +}); + +describe("theme configuration validation", () => { + it("loads a configured theme", () => { + const config = loadPortfolioConfig( + fixtureConfig({ theme: "paper" }), + () => "2026-07-31T00:00:00.000Z" + ); + expect(config.theme).toBe("paper"); + }); + + it("rejects an unknown theme with a list of choices", () => { + expect(() => loadPortfolioConfig(fixtureConfig({ theme: "neon" }))) + .toThrow('Configuration field "theme" must be one of: midnight, paper, terminal.'); + }); + + it("defaults to the built-in theme", () => { + const config = loadPortfolioConfig(fixtureConfig({}), () => "2026-07-31T00:00:00.000Z"); + expect(config.theme).toBe("midnight"); + }); +}); + +function fixtureConfig(overrides: Record): string { + const dir = join("data", "test-theme-config"); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "portfolio.json"); + writeFileSync(path, JSON.stringify(overrides)); + return path; +}