From 92524d73ed9e5f86d5ea040701f6ecc88c66be45 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:04:30 -0700 Subject: [PATCH] feat: extend engineer profile --- .github/workflows/ci.yml | 2 +- .github/workflows/refresh.yml | 2 +- .nvmrc | 2 +- CONTRIBUTING.md | 2 +- README.md | 60 ++++++++++++-- engineer-profile.config.json | 5 ++ package-lock.json | 8 +- package.json | 4 +- src/config/loader.ts | 42 +++++++++- src/deploy/index.ts | 152 ++++++++++++++++++++++++++++++++++ src/index.ts | 47 ++++++++++- src/ingest/github.ts | 2 +- src/publish/site.ts | 37 +++------ src/refresh/run.ts | 4 + src/theme/index.ts | 136 ++++++++++++++++++++++++++++++ src/types.ts | 15 ++++ tests/config.test.ts | 36 ++++++++ tests/deploy.test.ts | 99 ++++++++++++++++++++++ tests/site.test.ts | 18 ++++ tests/theme.test.ts | 34 ++++++++ 20 files changed, 661 insertions(+), 46 deletions(-) create mode 100644 src/deploy/index.ts create mode 100644 src/theme/index.ts create mode 100644 tests/deploy.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/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..c93b340 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. +- Select a built-in theme for the published page. +- Stamp deployment metadata for a chosen host. - Run one configured refresh from a scheduled workflow. The fixture demo runs without secrets and without network access. @@ -30,20 +32,24 @@ flowchart LR L --> W[Publisher] P --> W S --> W + T[Theme] --> W W --> O[Static output] + O --> A[Deploy adapter] ``` | Area | Responsibility | | --- | --- | -| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, and privacy settings. | +| `engineer-profile.config.json` | Store owner, theme, deploy, 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 deploy. | | `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/` | Resolve named palettes into CSS custom properties. | | `src/publish/` | Render HTML, changelog files, and preview assets. | +| `src/deploy/` | Write host metadata into the static output. | | `fixtures/` | Provide deterministic demo data and local preview pages. | The refresh command runs each stage in a fixed order. @@ -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,10 +74,11 @@ 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 theme, deployment adapter, paths, and privacy controls. The loader accepts repository limits from 1 through 100. It rejects malformed values before network access. +The loader rejects unknown theme names and unknown deployment adapters. CLI `--config`, `--data`, and `--output` options override file values. Run a network-backed refresh with the checked-in settings: @@ -83,6 +90,34 @@ npm run refresh The refresh command reads public repositories, captures previews, publishes HTML, and reports skipped captures. +### Themes + +Each theme stores a named palette of CSS custom properties. +The publisher injects the palette into the page. + +Built-in themes: `dark`, `light`, and `paper`. + +List the themes with `node dist/index.js theme`. +Switch themes by editing the `theme` field, then run `npm run publish`. + +### Deployment adapters + +An adapter writes host-specific metadata into `output/`. + +| Adapter | Metadata | +| --- | --- | +| `gh-pages` | `.nojekyll`, plus `CNAME` when a domain is set. | +| `vercel` | `vercel.json` with clean URLs and screenshot caching. | +| `netlify` | `netlify.toml` with static publishing and screenshot caching. | +| `surge` | `CNAME` with the configured domain. | +| `none` | No metadata files. | + +Set the adapter in the `deploy` field. +Set the optional `deploy.domain` field for custom domains. +Adapters write local files. They do not upload anything. +List adapters with `node dist/index.js deploy --list`. +Apply the configured adapter with `node dist/index.js deploy`. + GitHub ingestion uses the public API. Set `GITHUB_TOKEN` for a higher rate limit. @@ -106,6 +141,7 @@ Captured demo-engineer-signal-router. Captured demo-engineer-metrics-kit. Published 2 projects to output/index.html. Copied 2 available preview screenshots. +Prepared deployment for gh-pages (danielcuevas1208.github.io). Open output/index.html in a browser. ``` @@ -122,9 +158,12 @@ 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 deployment metadata from SQLite. | +| `npm run refresh` | Run configured ingest, capture, publish, and deploy stages. | | `node dist/index.js status` | Show visibility and recent operations. | +| `node dist/index.js theme` | List available site themes. | +| `node dist/index.js deploy` | Apply the configured deployment adapter. | +| `node dist/index.js deploy --list` | List available deployment adapters. | | `npm test` | Run deterministic unit and integration tests. | | `npm run typecheck` | Validate TypeScript types. | | `npm run build` | Compile the CLI to `dist/`. | @@ -172,6 +211,8 @@ The test suite covers these core behaviors: - Configured refresh orchestration. - Release source links. - Deterministic HTML output. +- Theme resolution and token rendering. +- Deployment adapter output files. - Playwright screenshot capture. Run the local checks: @@ -186,6 +227,7 @@ npm test Typecheck and build pass locally. CI runs the complete test suite on Ubuntu with Chromium installed. +CI uses Node.js 22 LTS. The fixture pipeline provides deterministic data for repeatable checks. ## Limitations @@ -197,6 +239,7 @@ The fixture pipeline provides deterministic data for repeatable checks. - External pages can fail during capture. - Capture failures are reported and do not stop publishing. - Publishing creates local files. It does not deploy them. +- Deployment adapters write local metadata. They do not upload anything. - Scheduled runs upload artifacts. They do not commit generated output. ## Roadmap @@ -205,8 +248,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 | Built-in themes and deployment adapters. | +| v0.4 | Next | Commit-diff summaries and an RSS feed. | +| v0.5 | Later | Portfolio search and topic filters. | ## License diff --git a/engineer-profile.config.json b/engineer-profile.config.json index 81008fb..2919522 100644 --- a/engineer-profile.config.json +++ b/engineer-profile.config.json @@ -2,9 +2,14 @@ "owner": "DanielCuevas1208", "title": "EngineerProfile", "tagline": "A living index of shipped systems, maintained from repository evidence", + "theme": "dark", "repositoryLimit": 5, "dataDir": "data", "outputDir": "output", + "deploy": { + "adapter": "gh-pages", + "domain": "danielcuevas1208.github.io" + }, "privacy": { "hiddenProjects": [], "redactEmails": true, diff --git a/package-lock.json b/package-lock.json index 87f5d51..6233203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "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", @@ -25,7 +25,7 @@ "vitest": "^3.0.5" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1845,6 +1845,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2352,6 +2353,7 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index d36edbe..8064f0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "description": "Self-maintaining engineering portfolio from repository activity", "type": "module", "main": "dist/index.js", @@ -21,7 +21,7 @@ "refresh": "npm run build && node dist/index.js refresh" }, "engines": { - "node": ">=20" + "node": ">=22" }, "keywords": [ "portfolio", diff --git a/src/config/loader.ts b/src/config/loader.ts index 406896b..8a34321 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 { DEFAULT_CONFIG, DEFAULT_PRIVACY } from "../types.js"; +import type { DeployConfig, PortfolioConfig, PrivacyConfig } from "../types.js"; +import { DEFAULT_CONFIG, DEFAULT_DEPLOY, DEFAULT_PRIVACY, DEFAULT_THEME } from "../types.js"; import { mergePrivacy } from "../privacy/controls.js"; +import { listThemes } from "../theme/index.js"; +import { listDeployAdapters } from "../deploy/index.js"; export const DEFAULT_CONFIG_PATH = "engineer-profile.config.json"; @@ -56,6 +58,40 @@ function readPrivacy(source: ConfigValue): PrivacyConfig { }); } +function readTheme(source: ConfigValue): string { + if (!("theme" in source)) return DEFAULT_THEME; + const value = source.theme; + if (typeof value !== "string" || !listThemes().some((theme) => theme.name === value)) { + const names = listThemes().map((theme) => theme.name).join(", "); + throw new Error(`Configuration field "theme" must be one of: ${names}.`); + } + return value; +} + +function readDeploy(source: ConfigValue): DeployConfig { + if (!("deploy" in source)) return DEFAULT_DEPLOY; + if (!isConfigValue(source.deploy)) { + throw new Error('Configuration field "deploy" must be an object.'); + } + + const adapter = "adapter" in source.deploy ? source.deploy.adapter : DEFAULT_DEPLOY.adapter; + if (typeof adapter !== "string" || !listDeployAdapters().some((candidate) => candidate.name === adapter)) { + const names = listDeployAdapters().map((candidate) => candidate.name).join(", "); + throw new Error(`Configuration field "deploy.adapter" must be one of: ${names}.`); + } + + let domain: string | undefined; + if ("domain" in source.deploy) { + const value = source.deploy.domain; + if (typeof value !== "string" || value.trim() === "") { + throw new Error('Configuration field "deploy.domain" must be a non-empty string.'); + } + domain = value.trim(); + } + + return { adapter, domain }; +} + export function loadPortfolioConfig( path: string = DEFAULT_CONFIG_PATH, clock: () => string = DEFAULT_CONFIG.clock @@ -75,10 +111,12 @@ export function loadPortfolioConfig( owner: readString(parsed, "owner", DEFAULT_CONFIG.owner), title: readString(parsed, "title", DEFAULT_CONFIG.title), tagline: readString(parsed, "tagline", DEFAULT_CONFIG.tagline), + theme: readTheme(parsed), repositoryLimit: readLimit(parsed, "repositoryLimit", DEFAULT_CONFIG.repositoryLimit), dataDir: readString(parsed, "dataDir", DEFAULT_CONFIG.dataDir), outputDir: readString(parsed, "outputDir", DEFAULT_CONFIG.outputDir), privacy: readPrivacy(parsed), + deploy: readDeploy(parsed), clock, }; } diff --git a/src/deploy/index.ts b/src/deploy/index.ts new file mode 100644 index 0000000..3e9558f --- /dev/null +++ b/src/deploy/index.ts @@ -0,0 +1,152 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { openDatabase } from "../db/client.js"; +import type { PortfolioConfig } from "../types.js"; + +export interface DeployConfig { + adapter: string; + domain?: string; +} + +export interface DeployFile { + path: string; + content: string; +} + +export interface DeployAdapter { + name: string; + label: string; + files: (domain: string | undefined) => DeployFile[]; +} + +const GITHUB_PAGES: DeployAdapter = { + name: "gh-pages", + label: "GitHub Pages", + files: (domain) => { + const files: DeployFile[] = [{ path: ".nojekyll", content: "" }]; + if (domain) files.push({ path: "CNAME", content: `${domain}\n` }); + return files; + }, +}; + +const VERCEL: DeployAdapter = { + name: "vercel", + label: "Vercel", + files: () => [ + { + path: "vercel.json", + content: `${JSON.stringify( + { + cleanUrls: true, + headers: [ + { + source: "/assets/screenshots/(.*)", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=31536000, immutable", + }, + ], + }, + ], + }, + null, + 2 + )}\n`, + }, + ], +}; + +const NETLIFY: DeployAdapter = { + name: "netlify", + label: "Netlify", + files: () => [ + { + path: "netlify.toml", + content: `[build] +publish = "." + +[[headers]] + for = "/assets/screenshots/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" +`, + }, + ], +}; + +const SURGE: DeployAdapter = { + name: "surge", + label: "Surge", + files: (domain) => { + if (!domain) return []; + return [{ path: "CNAME", content: `${domain}\n` }]; + }, +}; + +const NONE: DeployAdapter = { + name: "none", + label: "Local files only", + files: () => [], +}; + +export const DEPLOY_ADAPTERS: DeployAdapter[] = [ + GITHUB_PAGES, + VERCEL, + NETLIFY, + SURGE, + NONE, +]; + +export function resolveAdapter(name: string): DeployAdapter { + const adapter = DEPLOY_ADAPTERS.find((candidate) => candidate.name === name); + if (!adapter) { + const names = DEPLOY_ADAPTERS.map((candidate) => candidate.name).join(", "); + throw new Error(`Unknown deployment adapter "${name}". Available adapters: ${names}.`); + } + return adapter; +} + +export function listDeployAdapters(): DeployAdapter[] { + return DEPLOY_ADAPTERS.map((adapter) => ({ ...adapter, files: adapter.files })); +} + +export interface DeployResult { + adapter: string; + domain?: string; + files: string[]; +} + +export function applyDeployAdapter( + config: PortfolioConfig, + adapterOverride?: string, + domainOverride?: string +): DeployResult { + const adapterName = adapterOverride ?? config.deploy.adapter; + const domain = domainOverride ?? config.deploy.domain; + const adapter = resolveAdapter(adapterName); + const files = adapter.files(domain); + + mkdirSync(config.outputDir, { recursive: true }); + for (const file of files) { + writeFileSync(join(config.outputDir, file.path), file.content, "utf-8"); + } + + if (adapterName !== "none") { + const db = openDatabase(config.dataDir, config.clock); + try { + db.logIngest( + "deploy", + `${adapterName}${domain ? ` -> ${domain}` : ""}` + ); + } finally { + db.close(); + } + } + + return { + adapter: adapterName, + domain, + files: files.map((file) => file.path), + }; +} diff --git a/src/index.ts b/src/index.ts index aea5c27..20c275e 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 { applyDeployAdapter, listDeployAdapters } from "./deploy/index.js"; +import { listThemes } from "./theme/index.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.3.0"); function resolveConfig(options: { config?: string; data?: string; output?: string }): PortfolioConfig { const base = options.config @@ -65,8 +67,10 @@ addConfigOption(program const result = publishSite(config); const copied = copyScreenshotsToOutput(config); + const deploy = applyDeployAdapter(config); console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); console.log(`Copied ${copied} available preview screenshots.`); + console.log(`Prepared deployment for ${deploy.adapter}${deploy.domain ? ` (${deploy.domain})` : ""}.`); console.log("Open output/index.html in a browser."); })); @@ -129,8 +133,10 @@ addConfigOption(program const config = resolveConfig(options); const result = publishSite(config); const copied = copyScreenshotsToOutput(config); + const deploy = applyDeployAdapter(config); console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); console.log(`Copied ${copied} available preview screenshots.`); + console.log(`Prepared deployment for ${deploy.adapter}${deploy.domain ? ` (${deploy.domain})` : ""}.`); })); addConfigOption(program @@ -146,6 +152,7 @@ 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.`); + console.log(`Prepared deployment for ${result.deployment.adapter}${result.deployment.domain ? ` (${result.deployment.domain})` : ""}.`); for (const error of result.captureErrors) { console.warn(`Skipped ${error.slug}: ${error.message}`); } @@ -200,4 +207,42 @@ addConfigOption(program } })); +program + .command("theme") + .description("List available site themes") + .action(() => { + for (const theme of listThemes()) { + console.log(`${theme.name} - ${theme.label}`); + } + }); + +addConfigOption(program + .command("deploy") + .description("Write deployment metadata for the configured adapter") + .option("-a, --adapter ", "Deployment adapter") + .option("--domain ", "Custom domain for the adapter") + .option("--list", "List available adapters") + .option("-d, --data ", "Data directory") + .option("-o, --output ", "Output directory") + .action((options) => { + if (options.list) { + for (const adapter of listDeployAdapters()) { + console.log(`${adapter.name} - ${adapter.label}`); + } + return; + } + const config = resolveConfig(options); + const result = applyDeployAdapter(config, options.adapter, options.domain); + console.log(`Prepared deployment for ${result.adapter}${result.domain ? ` (${result.domain})` : ""}.`); + if (result.files.length === 0) { + console.log("No metadata files were required."); + } + for (const file of result.files) { + console.log(` Wrote ${file}.`); + } + if (result.adapter === "surge" && !result.domain) { + console.log("Surge needs a domain. Set deploy.domain or pass --domain."); + } + })); + await program.parseAsync(); 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..a9ef364 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 { resolveTheme, themeRootCss, type SiteTheme } from "../theme/index.js"; import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; function escapeHtml(text: string): string { @@ -124,24 +125,9 @@ function projectCard( `; } -function siteCss(): string { +function siteCss(theme: SiteTheme): 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; -} +${themeRootCss(theme)} * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); line-height: 1.5; } @@ -158,7 +144,7 @@ a { color: inherit; } .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); } +.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, var(--panel-from), var(--panel-to)); 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); } @@ -166,19 +152,19 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we .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-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(--ink-soft); } .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; } +.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-from), var(--panel-to)); box-shadow: var(--shadow); } +.project-visual { position: relative; min-height: 310px; background: var(--visual); } .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 { display: flex; min-height: 310px; align-items: center; justify-content: center; flex-direction: column; gap: 7px; color: var(--blue); background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--blue) 7%, transparent), color-mix(in srgb, var(--blue) 7%, transparent) 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); } +.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(--overlay); 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; } @@ -210,7 +196,7 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we .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 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 "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; } @@ -250,6 +236,7 @@ export function publishSite(config: PortfolioConfig): PublishResult { try { const projects = db.listProjects(true); const generatedAt = config.clock(); + const theme = resolveTheme(config.theme); const views = projects.map((project) => ({ project, changelog: db.getChangelog(project.id), @@ -279,7 +266,7 @@ export function publishSite(config: PortfolioConfig): PublishResult { ${escapeHtml(config.title)} / Portfolio - +
diff --git a/src/refresh/run.ts b/src/refresh/run.ts index acc9e53..55e27a4 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 { applyDeployAdapter, type DeployResult } from "../deploy/index.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: DeployResult; } 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 = applyDeployAdapter(config); return { ingested: ingested.length, @@ -43,5 +46,6 @@ export async function refreshPortfolio( copiedScreenshots: copyScreenshotsToOutput(config), captureErrors, published, + deployment, }; } diff --git a/src/theme/index.ts b/src/theme/index.ts new file mode 100644 index 0000000..f61ec46 --- /dev/null +++ b/src/theme/index.ts @@ -0,0 +1,136 @@ +export interface ThemeTokens { + ink: string; + inkSoft: string; + panel: string; + panelStrong: string; + panelFrom: string; + panelTo: string; + visual: string; + overlay: string; + line: string; + text: string; + muted: string; + blue: string; + blueSoft: string; + mint: string; + orange: string; + shadow: string; +} + +export interface SiteTheme { + name: string; + label: string; + colorScheme: "dark" | "light"; + tokens: ThemeTokens; +} + +const DARK: SiteTheme = { + name: "dark", + label: "Night field", + colorScheme: "dark", + tokens: { + ink: "#08111f", + inkSoft: "#0d1a2d", + panel: "#112139", + panelStrong: "#172a46", + panelFrom: "#172a46", + panelTo: "#0d1a2d", + visual: "#0a1525", + overlay: "rgba(8, 17, 31, 0.72)", + line: "rgba(169, 195, 222, 0.18)", + text: "#f3f7fb", + muted: "#9db0c7", + blue: "#67b7ff", + blueSoft: "#b8dcff", + mint: "#a7f3d0", + orange: "#ffb86b", + shadow: "0 24px 60px rgba(0, 0, 0, 0.24)", + }, +}; + +const LIGHT: SiteTheme = { + name: "light", + label: "Daylight", + colorScheme: "light", + tokens: { + ink: "#f4f7fb", + inkSoft: "#e9eef5", + panel: "#ffffff", + panelStrong: "#fbfdff", + panelFrom: "#ffffff", + panelTo: "#e9eef5", + visual: "#dde6f0", + overlay: "rgba(255, 255, 255, 0.86)", + line: "rgba(13, 26, 45, 0.14)", + text: "#101828", + muted: "#5b6b80", + blue: "#1d63d8", + blueSoft: "#2f6fe4", + mint: "#0e7a57", + orange: "#a34e0e", + shadow: "0 24px 60px rgba(16, 24, 40, 0.12)", + }, +}; + +const PAPER: SiteTheme = { + name: "paper", + label: "Archive paper", + colorScheme: "light", + tokens: { + ink: "#fbfaf6", + inkSoft: "#f1efe8", + panel: "#ffffff", + panelStrong: "#f7f5ef", + panelFrom: "#ffffff", + panelTo: "#f1efe8", + visual: "#eae5da", + overlay: "rgba(251, 250, 246, 0.9)", + line: "rgba(35, 31, 26, 0.18)", + text: "#231f19", + muted: "#6c6659", + blue: "#3f5d7d", + blueSoft: "#4a6b8c", + mint: "#5f6f3a", + orange: "#8a5a2b", + shadow: "0 24px 60px rgba(35, 31, 26, 0.14)", + }, +}; + +export const THEMES: SiteTheme[] = [DARK, LIGHT, PAPER]; + +export function resolveTheme(name: string): SiteTheme { + const theme = THEMES.find((candidate) => candidate.name === name); + if (!theme) { + const names = THEMES.map((candidate) => candidate.name).join(", "); + throw new Error(`Unknown theme "${name}". Available themes: ${names}.`); + } + return theme; +} + +export function listThemes(): SiteTheme[] { + return THEMES.map((theme) => ({ ...theme, tokens: { ...theme.tokens } })); +} + +export function themeRootCss(theme: SiteTheme): string { + const t = theme.tokens; + return `:root { + color-scheme: ${theme.colorScheme}; + --ink: ${t.ink}; + --ink-soft: ${t.inkSoft}; + --panel: ${t.panel}; + --panel-strong: ${t.panelStrong}; + --panel-from: ${t.panelFrom}; + --panel-to: ${t.panelTo}; + --visual: ${t.visual}; + --overlay: ${t.overlay}; + --line: ${t.line}; + --text: ${t.text}; + --muted: ${t.muted}; + --blue: ${t.blue}; + --blue-soft: ${t.blueSoft}; + --mint: ${t.mint}; + --orange: ${t.orange}; + --shadow: ${t.shadow}; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +}`; +} diff --git a/src/types.ts b/src/types.ts index 92c30da..e35c6ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,14 +78,21 @@ export interface PrivacyConfig { maxCommitsPerProject: number; } +export interface DeployConfig { + adapter: string; + domain?: string; +} + export interface PortfolioConfig { owner: string; title: string; tagline: string; + theme: string; repositoryLimit?: number; dataDir: string; outputDir: string; privacy: PrivacyConfig; + deploy: DeployConfig; clock: () => string; } @@ -95,13 +102,21 @@ export const DEFAULT_PRIVACY: PrivacyConfig = { maxCommitsPerProject: 50, }; +export const DEFAULT_THEME = "dark"; + +export const DEFAULT_DEPLOY: DeployConfig = { + adapter: "none", +}; + export const DEFAULT_CONFIG = { owner: "demo-engineer", title: "EngineerProfile", tagline: "A living index of shipped systems, maintained from repository evidence", + theme: DEFAULT_THEME, repositoryLimit: 5, dataDir: "data", outputDir: "output", privacy: DEFAULT_PRIVACY, + deploy: DEFAULT_DEPLOY, clock: () => new Date().toISOString(), } satisfies PortfolioConfig; diff --git a/tests/config.test.ts b/tests/config.test.ts index 44e2720..ebf0d68 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -15,6 +15,8 @@ describe("portfolio configuration", () => { owner: "owner", title: "Evidence Index", repositoryLimit: 3, + theme: "light", + deploy: { adapter: "vercel" }, privacy: { hiddenProjects: ["owner-hidden"], redactEmails: false }, })); @@ -23,6 +25,9 @@ describe("portfolio configuration", () => { expect(config.owner).toBe("owner"); expect(config.title).toBe("Evidence Index"); expect(config.repositoryLimit).toBe(3); + expect(config.theme).toBe("light"); + expect(config.deploy.adapter).toBe("vercel"); + expect(config.deploy.domain).toBeUndefined(); expect(config.privacy.hiddenProjects).toEqual(["owner-hidden"]); expect(config.privacy.redactEmails).toBe(false); expect(config.privacy.maxCommitsPerProject).toBe(50); @@ -37,4 +42,35 @@ describe("portfolio configuration", () => { 'Configuration field "repositoryLimit" must be an integer from 1 to 100.' ); }); + + it("rejects unknown theme names", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: "rainbow" })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme" must be one of:' + ); + }); + + it("rejects unknown deploy adapters", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ deploy: { adapter: "ftp" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "deploy.adapter" must be one of:' + ); + }); + + it("accepts a deploy domain and rejects empty domains", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + deploy: { adapter: "gh-pages", domain: "docs.example.dev" }, + })); + expect(loadPortfolioConfig(TEST_FILE).deploy.domain).toBe("docs.example.dev"); + + writeFileSync(TEST_FILE, JSON.stringify({ deploy: { adapter: "gh-pages", domain: " " } })); + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "deploy.domain" must be a non-empty string.' + ); + }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts new file mode 100644 index 0000000..e8b59fa --- /dev/null +++ b/tests/deploy.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { + applyDeployAdapter, + listDeployAdapters, + resolveAdapter, +} from "../src/deploy/index.js"; +import { openDatabase } from "../src/db/client.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = join("data", "test-deploy"); +const TEST_OUTPUT = join("output", "test-deploy"); + +function deployConfig(outputDir: string) { + return { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir }; +} + +afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +describe("deployment adapters", () => { + it("registers the built-in adapters", () => { + expect(listDeployAdapters().map((adapter) => adapter.name)).toEqual([ + "gh-pages", + "vercel", + "netlify", + "surge", + "none", + ]); + }); + + it("writes nojekyll and CNAME for GitHub Pages with a domain", () => { + const result = applyDeployAdapter( + { ...deployConfig(TEST_OUTPUT), deploy: { adapter: "gh-pages", domain: "example.dev" } } + ); + expect(result.files).toEqual([".nojekyll", "CNAME"]); + expect(existsSync(join(TEST_OUTPUT, ".nojekyll"))).toBe(true); + expect(readFileSync(join(TEST_OUTPUT, "CNAME"), "utf-8").trim()).toBe("example.dev"); + }); + + it("skips CNAME for GitHub Pages without a domain", () => { + const result = applyDeployAdapter( + { ...deployConfig(TEST_OUTPUT), deploy: { adapter: "gh-pages" } } + ); + expect(result.files).toEqual([".nojekyll"]); + expect(existsSync(join(TEST_OUTPUT, "CNAME"))).toBe(false); + }); + + it("writes a caching config for Vercel", () => { + applyDeployAdapter({ ...deployConfig(TEST_OUTPUT), deploy: { adapter: "vercel" } }); + const vercel = JSON.parse(readFileSync(join(TEST_OUTPUT, "vercel.json"), "utf-8")) as { + cleanUrls: boolean; + headers: Array<{ source: string }>; + }; + expect(vercel.cleanUrls).toBe(true); + expect(vercel.headers[0].source).toBe("/assets/screenshots/(.*)"); + }); + + it("writes cache headers for Netlify", () => { + applyDeployAdapter({ ...deployConfig(TEST_OUTPUT), deploy: { adapter: "netlify" } }); + const toml = readFileSync(join(TEST_OUTPUT, "netlify.toml"), "utf-8"); + expect(toml).toContain("[build]"); + expect(toml).toContain("max-age=31536000"); + }); + + it("requires a domain for Surge", () => { + const result = applyDeployAdapter( + { ...deployConfig(TEST_OUTPUT), deploy: { adapter: "surge", domain: "site.surge.sh" } } + ); + expect(result.files).toEqual(["CNAME"]); + expect(readFileSync(join(TEST_OUTPUT, "CNAME"), "utf-8").trim()).toBe("site.surge.sh"); + }); + + it("writes nothing for the local-only adapter", () => { + const result = applyDeployAdapter({ ...deployConfig(TEST_OUTPUT), deploy: { adapter: "none" } }); + expect(result.files).toEqual([]); + expect(existsSync(join(TEST_OUTPUT, ".nojekyll"))).toBe(false); + expect(existsSync(join(TEST_OUTPUT, "CNAME"))).toBe(false); + }); + + it("rejects unknown adapters", () => { + expect(() => resolveAdapter("unknown")).toThrow(/Unknown deployment adapter "unknown"/); + expect(() => applyDeployAdapter({ ...deployConfig(TEST_OUTPUT), deploy: { adapter: "unknown" } })) + .toThrow(/Unknown deployment adapter "unknown"/); + }); + + it("records the deployment in the audit log", () => { + applyDeployAdapter( + { ...deployConfig(TEST_OUTPUT), deploy: { adapter: "vercel" } } + ); + const db = openDatabase(TEST_DATA); + const log = db.getIngestLog(5); + db.close(); + expect(log.some((entry) => entry.action === "deploy" && entry.detail === "vercel")).toBe(true); + }); +}); diff --git a/tests/site.test.ts b/tests/site.test.ts index 03061cf..5c3f1c6 100644 --- a/tests/site.test.ts +++ b/tests/site.test.ts @@ -87,6 +87,24 @@ describe("database and publish pipeline", () => { expect(html).toContain("total stars"); }); + it("renders the configured theme into the published page", async () => { + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT, theme: "light" }; + await ingestRepository( + config, + { owner: "demo-engineer", repo: "signal-router" }, + { + repo: loadFixtureRepo("signal-router"), + commits: loadFixtureCommits("signal-router"), + releases: loadFixtureReleases("signal-router"), + } + ); + + publishSite(config); + const html = readFileSync(join(TEST_OUTPUT, "index.html"), "utf-8"); + expect(html).toContain("color-scheme: light"); + expect(html).not.toContain("color-scheme: dark"); + }); + it("respects visibility when publishing", async () => { const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; await ingestRepository( diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..e649270 --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { resolveTheme, listThemes, themeRootCss } from "../src/theme/index.js"; + +describe("site themes", () => { + it("resolves the default theme by name", () => { + const theme = resolveTheme("dark"); + expect(theme.name).toBe("dark"); + expect(theme.tokens.ink).toBe("#08111f"); + expect(theme.tokens.text).toBe("#f3f7fb"); + }); + + it("exposes at least three distinct themes", () => { + const themes = listThemes(); + expect(themes.map((theme) => theme.name)).toEqual(["dark", "light", "paper"]); + const colorSchemes = new Set(themes.map((theme) => theme.colorScheme)); + expect(colorSchemes.size).toBeGreaterThan(1); + }); + + it("renders the full token set into CSS variables", () => { + const css = themeRootCss(resolveTheme("light")); + expect(css).toContain("color-scheme: light"); + expect(css).toContain("--ink:"); + expect(css).toContain("--mint:"); + expect(css).toContain("--shadow:"); + }); + + it("keeps theme output deterministic", () => { + expect(themeRootCss(resolveTheme("paper"))).toBe(themeRootCss(resolveTheme("paper"))); + }); + + it("rejects unknown theme names", () => { + expect(() => resolveTheme("mystery")).toThrow(/Unknown theme "mystery"/); + }); +});