diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae27d9..cd0a1c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,11 @@ jobs: - name: Run fixture demo run: node dist/index.js demo + - name: Exercise deployment adapter + run: | + node dist/index.js deploy --data data --output output --target "$RUNNER_TEMP/deploy-target" + test -f "$RUNNER_TEMP/deploy-target/index.html" + - 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..9eca2dd 100644 --- a/.github/workflows/refresh.yml +++ b/.github/workflows/refresh.yml @@ -4,6 +4,12 @@ on: schedule: - cron: "17 5 * * 1" workflow_dispatch: + inputs: + deploy_target: + description: Local deploy target directory (optional) + required: false + type: string + default: "" permissions: contents: read @@ -42,3 +48,16 @@ jobs: name: engineer-profile-refresh path: output/ if-no-files-found: error + + - name: Deploy to requested target + if: inputs.deploy_target != '' + run: | + node dist/index.js deploy --data data --output output --target "${{ inputs.deploy_target }}" + test -d "${{ inputs.deploy_target }}" + + - name: Store deployed target + if: inputs.deploy_target != '' + uses: actions/upload-artifact@v4 + with: + name: engineer-profile-deploy + path: ${{ inputs.deploy_target }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7802f1c..61d4f88 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,8 +15,17 @@ npm test Use fixtures for changes that need repeatable data. Do not add credentials, private repository data, or generated output. +For changes to themes, add or update tests in `tests/theme.test.ts`. +For changes to deployment, add or update tests in `tests/deploy.test.ts`. +Verify the deploy path with a temporary target: + +```bash +node dist/index.js deploy -d data -o output -t +``` + ## Pull requests Explain the user value and the data path. List the checks that you ran. -Keep public claims tied to repository evidence. \ No newline at end of file +Keep public claims tied to repository evidence. +Do not invent users, adoption numbers, or benchmarks. diff --git a/README.md b/README.md index f1a2a81..a03a05c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # EngineerProfile EngineerProfile builds a local engineering portfolio from public repository data. -It stores repository metadata, commits, releases, privacy settings, and preview -paths in SQLite. It publishes a static site from these records. +It stores repository metadata, commits, releases, privacy settings, and preview paths in SQLite. +It publishes a static site from these records. +You select a built-in theme. +You choose a deployment adapter for the finished site. ## Value @@ -10,6 +12,8 @@ paths in SQLite. It publishes a static site from these records. - Refresh project cards from public GitHub repositories. - Build release notes from releases or conventional commits. - Capture repeatable project previews with Playwright. +- Select a validated site theme from the built-in registry. +- Copy the published site to a local target with an adapter. - Hide projects and redact author emails before publication. - Run one configured refresh from a scheduled workflow. @@ -30,20 +34,24 @@ flowchart LR L --> W[Publisher] P --> W S --> W + T[Theme] --> W W --> O[Static output] + O --> E[Deploy] ``` | Area | Responsibility | | --- | --- | -| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, and privacy settings. | +| `engineer-profile.config.json` | Store owner, presentation, theme, deploy, refresh, 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/theme/` | Select a validated site theme from the built-in registry. | +| `src/deploy/` | Copy the published site to a configured target. | +| `src/refresh/` | Coordinate ingest, best-effort capture, 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/publish/` | Render HTML, changelog files, and preview assets. | +| `src/publish/` | Render themed HTML, changelog files, and preview assets. | | `fixtures/` | Provide deterministic demo data and local preview pages. | The refresh command runs each stage in a fixed order. @@ -68,7 +76,7 @@ Both directories are ignored by Git. ## Configuration `engineer-profile.config.json` is the checked-in source for scheduled refreshes. -It sets the GitHub owner, site presentation, repository limit, paths, and privacy controls. +It sets the GitHub owner, site presentation, repository limit, theme, deploy, and privacy controls. The loader accepts repository limits from 1 through 100. It rejects malformed values before network access. @@ -80,8 +88,7 @@ Run a network-backed refresh with the checked-in settings: npm run refresh ``` -The refresh command reads public repositories, captures previews, publishes HTML, -and reports skipped captures. +The refresh command reads public repositories, captures previews, publishes HTML, and reports skipped captures. GitHub ingestion uses the public API. Set `GITHUB_TOKEN` for a higher rate limit. @@ -94,6 +101,44 @@ npm run refresh Do not put a token in repository files. Use `.env.example` as a variable reference. +## Themes + +The site ships with three built-in themes. +Each theme defines one consistent set of color tokens. + +| Theme | Look | +| --- | --- | +| `aurora` | Dark blue with teal and amber accents. This is the default. | +| `terminal` | Green on black for low-light reading. | +| `paper` | Light theme with dark ink text. | + +Select a theme with the `theme` field. +The loader rejects unknown theme names before network access. + +## Deployment + +Adapters copy the published site to a destination. +The `none` adapter is the default. +It does nothing. +The `local` adapter mirrors the output directory into a target. + +Set the adapter and target in the configuration. +Run `npm run deploy` after `npm run publish`. +A refresh runs the adapter automatically when one is configured. + +```json +{ + "theme": "paper", + "deploy": { + "adapter": "local", + "targetDir": "site" + } +} +``` + +The deploy step replaces the target contents with a fresh copy. +It rejects targets that overlap the output directory. + ## Sample output The fixture set contains `signal-router` and `metrics-kit`. @@ -104,11 +149,18 @@ The second project uses commit-based notes. Ingested 2 fixture projects. Captured demo-engineer-signal-router. Captured demo-engineer-metrics-kit. -Published 2 projects to output/index.html. +Published 2 projects with the aurora theme to output/index.html. Copied 2 available preview screenshots. Open output/index.html in a browser. ``` +Point the local adapter at a target directory. +Run the deploy command after publish. + +```text +Deployed 5 files with the local adapter to site. +``` + The site shows project facts, source links, changelog previews, and screenshots. The totals come from fixture fields and stored commit records. @@ -123,7 +175,8 @@ Build before direct CLI commands. | `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 deploy` | Copy the site to the configured target. | +| `npm run refresh` | Run configured ingest, capture, publish, and deploy stages. | | `node dist/index.js status` | Show visibility and recent operations. | | `npm test` | Run deterministic unit and integration tests. | | `npm run typecheck` | Validate TypeScript types. | @@ -153,13 +206,14 @@ Each release keeps its tag, notes, date, and source URL. The site displays visible projects only. It links project cards to repositories. It links release notes to their release pages. -It records local operations in an audit table. +It records local operations, including deploys, in an audit table. ## CI and test status -The regular CI workflow runs typecheck, build, tests, the fixture demo, and artifact upload. +The regular CI workflow runs typecheck, build, tests, the fixture demo, a deploy check, and artifact upload. The scheduled refresh workflow runs each Monday and supports manual dispatch. It uploads the generated site as a workflow artifact. +Manual dispatch can request a local deploy target. The test suite covers these core behaviors: @@ -168,6 +222,8 @@ The test suite covers these core behaviors: - Release-first changelog generation. - SQLite upserts and changelog replacement. - Privacy filtering and email redaction. +- Theme registry and theme publishing. +- Deployment adapters and audit logging. - Fixture ingestion and static publishing. - Configured refresh orchestration. - Release source links. @@ -197,6 +253,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. +- The local adapter copies to one directory. It does not push to a remote host. - Scheduled runs upload artifacts. They do not commit generated output. ## Roadmap @@ -205,8 +262,8 @@ 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 a local deployment adapter. | +| v0.4 | Next | Commit-diff summaries and an RSS feed. | ## License diff --git a/engineer-profile.config.json b/engineer-profile.config.json index 81008fb..232ff4b 100644 --- a/engineer-profile.config.json +++ b/engineer-profile.config.json @@ -3,6 +3,10 @@ "title": "EngineerProfile", "tagline": "A living index of shipped systems, maintained from repository evidence", "repositoryLimit": 5, + "theme": "aurora", + "deploy": { + "adapter": "none" + }, "dataDir": "data", "outputDir": "output", "privacy": { diff --git a/package-lock.json b/package-lock.json index 87f5d51..d3e2c40 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", @@ -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..de7af52 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,13 @@ { "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", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], "bin": { "engineer-profile": "dist/index.js" }, @@ -18,6 +22,7 @@ "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": { diff --git a/src/config/loader.ts b/src/config/loader.ts index 406896b..c4ee50f 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 { DeploymentConfig, PortfolioConfig, PrivacyConfig } from "../types.js"; +import { DEFAULT_CONFIG, DEFAULT_DEPLOY, DEFAULT_PRIVACY } from "../types.js"; import { mergePrivacy } from "../privacy/controls.js"; +import { isKnownAdapter, listAdapters } from "../deploy/adapters.js"; +import { isKnownTheme, listThemeNames } from "../theme/registry.js"; export const DEFAULT_CONFIG_PATH = "engineer-profile.config.json"; @@ -29,6 +31,39 @@ function readLimit(source: ConfigValue, key: string, fallback: number): number { return value; } +function readTheme(source: ConfigValue, fallback: string): string { + if (!("theme" in source)) return fallback; + const value = source.theme; + if (typeof value !== "string" || value.trim() === "") { + throw new Error('Configuration field "theme" must be a non-empty string.'); + } + const name = value.trim(); + if (!isKnownTheme(name)) { + throw new Error(`Configuration field "theme" must be one of: ${listThemeNames().join(", ")}.`); + } + return name; +} + +function readDeploy(source: ConfigValue): DeploymentConfig { + if (!("deploy" in source)) return DEFAULT_DEPLOY; + if (!isConfigValue(source.deploy)) { + throw new Error('Configuration field "deploy" must be an object.'); + } + + const { adapter, targetDir } = source.deploy; + if (typeof adapter !== "string" || !isKnownAdapter(adapter)) { + throw new Error(`Configuration field "deploy.adapter" must be one of: ${listAdapters().join(", ")}.`); + } + if (targetDir !== undefined && (typeof targetDir !== "string" || targetDir.trim() === "")) { + throw new Error('Configuration field "deploy.targetDir" must be a non-empty string.'); + } + + return { + adapter: adapter as DeploymentConfig["adapter"], + targetDir: targetDir === undefined ? undefined : targetDir.trim(), + }; +} + function readPrivacy(source: ConfigValue): PrivacyConfig { if (!("privacy" in source)) return DEFAULT_PRIVACY; if (!isConfigValue(source.privacy)) { @@ -78,6 +113,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, DEFAULT_CONFIG.theme), + deploy: readDeploy(parsed), privacy: readPrivacy(parsed), clock, }; diff --git a/src/deploy/adapters.ts b/src/deploy/adapters.ts new file mode 100644 index 0000000..6cbc613 --- /dev/null +++ b/src/deploy/adapters.ts @@ -0,0 +1,83 @@ +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import type { DeploymentConfig } from "../types.js"; + +export interface DeployResult { + adapter: string; + target: string | null; + filesCopied: number; +} + +export interface DeploymentAdapter { + readonly name: string; + deploy(sourceDir: string, config: DeploymentConfig): DeployResult; +} + +function countFiles(dir: string): number { + let count = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + count += entry.isDirectory() ? countFiles(join(dir, entry.name)) : 1; + } + return count; +} + +function pathsOverlap(a: string, b: string): boolean { + const relativePath = relative(a, b); + return relativePath === "" || (!isAbsolute(relativePath) && !relativePath.startsWith("..")); +} + +const NONE_ADAPTER: DeploymentAdapter = { + name: "none", + deploy() { + return { adapter: "none", target: null, filesCopied: 0 }; + }, +}; + +const LOCAL_ADAPTER: DeploymentAdapter = { + name: "local", + deploy(sourceDir, config) { + const targetDir = config.targetDir?.trim(); + if (!targetDir) { + throw new Error('Local deployment requires a "deploy.targetDir" setting.'); + } + if (!existsSync(sourceDir)) { + throw new Error(`Nothing to deploy: output directory "${sourceDir}" does not exist.`); + } + + const sourceAbs = resolve(sourceDir); + const targetAbs = resolve(targetDir); + if (pathsOverlap(sourceAbs, targetAbs)) { + throw new Error( + `Deployment target "${targetDir}" must not overlap the output directory "${sourceDir}".` + ); + } + + mkdirSync(targetAbs, { recursive: true }); + rmSync(targetAbs, { recursive: true, force: true }); + mkdirSync(targetAbs, { recursive: true }); + cpSync(sourceAbs, targetAbs, { recursive: true }); + + return { adapter: "local", target: targetAbs, filesCopied: countFiles(targetAbs) }; + }, +}; + +const ADAPTERS: Record = { + none: NONE_ADAPTER, + local: LOCAL_ADAPTER, +}; + +export function isKnownAdapter(name: string): boolean { + return Object.prototype.hasOwnProperty.call(ADAPTERS, name); +} + +export function listAdapters(): string[] { + return Object.keys(ADAPTERS); +} + +export function resolveAdapter(name: string): DeploymentAdapter { + const adapter = ADAPTERS[name]; + if (!adapter) { + throw new Error(`Unknown deployment adapter "${name}". Available adapters: ${listAdapters().join(", ")}.`); + } + return adapter; +} diff --git a/src/deploy/run.ts b/src/deploy/run.ts new file mode 100644 index 0000000..4e17e5e --- /dev/null +++ b/src/deploy/run.ts @@ -0,0 +1,30 @@ +import { openDatabase } from "../db/client.js"; +import type { DeployAdapterName, PortfolioConfig } from "../types.js"; +import { resolveAdapter, type DeployResult } from "./adapters.js"; + +export interface DeployOptions { + adapter?: string; + targetDir?: string; +} + +export function deployPortfolio(config: PortfolioConfig, options: DeployOptions = {}): DeployResult { + const adapterName = options.adapter ?? config.deploy.adapter; + const deployConfig = { + adapter: adapterName as DeployAdapterName, + targetDir: options.targetDir ?? config.deploy.targetDir, + }; + const adapter = resolveAdapter(adapterName); + const result = adapter.deploy(config.outputDir, deployConfig); + + const db = openDatabase(config.dataDir, config.clock); + try { + const detail = result.target + ? `${result.adapter}: ${result.filesCopied} files -> ${result.target}` + : `${result.adapter}: no-op`; + db.logIngest("deploy", detail); + } finally { + db.close(); + } + + return result; +} diff --git a/src/index.ts b/src/index.ts index aea5c27..6c45b18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ 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 { deployPortfolio } from "./deploy/run.js"; import { loadAllFixtures } from "./fixtures/loader.js"; import { openDatabase } from "./db/client.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "./types.js"; @@ -16,7 +17,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,7 +66,7 @@ addConfigOption(program const result = publishSite(config); const copied = copyScreenshotsToOutput(config); - console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); + console.log(`Published ${result.projectCount} projects with the ${result.theme} theme to ${result.indexPath}.`); console.log(`Copied ${copied} available preview screenshots.`); console.log("Open output/index.html in a browser."); })); @@ -129,10 +130,26 @@ addConfigOption(program const config = resolveConfig(options); const result = publishSite(config); const copied = copyScreenshotsToOutput(config); - console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); + console.log(`Published ${result.projectCount} projects with the ${result.theme} theme to ${result.indexPath}.`); console.log(`Copied ${copied} available preview screenshots.`); })); +addConfigOption(program + .command("deploy") + .description("Copy the published site to a configured deployment target") + .option("-d, --data ", "Data directory") + .option("-o, --output ", "Output directory") + .option("-t, --target ", "Deployment target directory") + .action((options) => { + const config = resolveConfig(options); + const targetDir = options.target ?? config.deploy.targetDir; + const adapter = targetDir && config.deploy.adapter === "none" ? "local" : config.deploy.adapter; + const result = deployPortfolio(config, { adapter, targetDir }); + console.log( + `Deployed ${result.filesCopied} files with the ${result.adapter} adapter${result.target ? ` to ${result.target}` : ""}.` + ); + })); + addConfigOption(program .command("refresh") .description("Ingest, capture, and publish from the checked-in configuration") @@ -144,8 +161,13 @@ addConfigOption(program const result = await refreshPortfolio(config); console.log(`Ingested ${result.ingested} repositories for ${config.owner}.`); console.log(`Captured ${result.captured} project previews.`); - console.log(`Published ${result.published.projectCount} projects to ${result.published.indexPath}.`); + console.log(`Published ${result.published.projectCount} projects with the ${result.published.theme} theme to ${result.published.indexPath}.`); console.log(`Copied ${result.copiedScreenshots} available preview screenshots.`); + if (result.deployed) { + console.log( + `Deployed ${result.deployed.filesCopied} files with the ${result.deployed.adapter} adapter${result.deployed.target ? ` to ${result.deployed.target}` : ""}.` + ); + } for (const error of result.captureErrors) { console.warn(`Skipped ${error.slug}: ${error.message}`); } diff --git a/src/publish/site.ts b/src/publish/site.ts index d382163..fb025a5 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 { cssVariables, resolveTheme, type Theme } from "../theme/registry.js"; import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; function escapeHtml(text: string): string { @@ -124,96 +125,85 @@ function projectCard( `; } -function siteCss(): string { +function siteCss(theme: Theme): 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; + color-scheme: var(--scheme); +${cssVariables(theme)} + font-family: var(--sans); } * { box-sizing: border-box; } html { scroll-behavior: smooth; } -body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); line-height: 1.5; } +body { margin: 0; min-width: 320px; background: var(--bg); 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(--bg); } .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(--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(--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(--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-a), var(--panel-b)); box-shadow: var(--shadow); } +.aside-index { display: flex; justify-content: space-between; color: var(--accent-3); font: 0.68rem/1 var(--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(--mono); letter-spacing: 0.08em; text-transform: uppercase; } +.audit-panel { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; margin: 26px 0 80px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel-tint); } .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(--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-a), var(--panel-b)); 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, var(--stripe), var(--stripe) 1px, transparent 1px, transparent 14px); } +.placeholder small { color: var(--muted); font: 0.68rem/1 var(--mono); text-transform: uppercase; } +.visual-label { position: absolute; right: 16px; bottom: 16px; padding: 7px 9px; border: 1px solid rgba(255,255,255,0.18); border-radius: 5px; background: rgba(8, 17, 31, 0.72); 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(--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 var(--tag-line); border-radius: 999px; color: var(--accent-2); font: 0.68rem/1 var(--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(--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: var(--code-bg); font: 0.76rem var(--mono); } +.text-link { display: inline-block; margin-top: 14px; color: var(--accent); font: 0.73rem/1 var(--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(--mono); letter-spacing: 0.04em; text-decoration: none; text-transform: uppercase; } +.button.primary { background: var(--accent); color: var(--bg); } +.button.primary:hover { filter: brightness(1.1); } .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 var(--line-soft); color: var(--muted); font: 0.72rem/1.4 var(--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(--mono); } .site-footer p { margin: 0; } @media (max-width: 850px) { .hero { grid-template-columns: 1fr; gap: 34px; padding-top: 58px; } @@ -243,11 +233,13 @@ export interface PublishResult { indexPath: string; projectCount: number; generatedAt: string; + theme: string; } export function publishSite(config: PortfolioConfig): PublishResult { const db = openDatabase(config.dataDir, config.clock); try { + const theme = resolveTheme(config.theme); const projects = db.listProjects(true); const generatedAt = config.clock(); const views = projects.map((project) => ({ @@ -272,14 +264,14 @@ export function publishSite(config: PortfolioConfig): PublishResult { : ""; const html = ` - + ${escapeHtml(config.title)} / Portfolio - +
@@ -345,7 +337,7 @@ export function publishSite(config: PortfolioConfig): PublishResult { } db.logIngest("publish", `${projects.length} projects -> ${indexPath}`); - return { indexPath, projectCount: projects.length, generatedAt }; + return { indexPath, projectCount: projects.length, generatedAt, theme: theme.name }; } finally { db.close(); } diff --git a/src/refresh/run.ts b/src/refresh/run.ts index acc9e53..5d8aac5 100644 --- a/src/refresh/run.ts +++ b/src/refresh/run.ts @@ -1,6 +1,8 @@ import { ingestOwnerRepos } from "../ingest/orchestrator.js"; import { captureAllProjects } from "../preview/capture.js"; import { copyScreenshotsToOutput, publishSite, type PublishResult } from "../publish/site.js"; +import { deployPortfolio } from "../deploy/run.js"; +import type { DeployResult } from "../deploy/adapters.js"; import type { FixtureData } from "../ingest/orchestrator.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "../types.js"; @@ -15,6 +17,7 @@ export interface RefreshResult { copiedScreenshots: number; captureErrors: Array<{ slug: string; message: string }>; published: PublishResult; + deployed: DeployResult | null; } export async function refreshPortfolio( @@ -36,12 +39,17 @@ export async function refreshPortfolio( (slug, error) => captureErrors.push({ slug, message: error.message }) ); const published = publishSite(config); + const copiedScreenshots = copyScreenshotsToOutput(config); + const deployed = config.deploy.adapter === "none" + ? null + : deployPortfolio(config); return { ingested: ingested.length, captured: captured.length, - copiedScreenshots: copyScreenshotsToOutput(config), + copiedScreenshots, captureErrors, published, + deployed, }; } diff --git a/src/theme/registry.ts b/src/theme/registry.ts new file mode 100644 index 0000000..2ccdd82 --- /dev/null +++ b/src/theme/registry.ts @@ -0,0 +1,123 @@ +export interface Theme { + name: string; + label: string; + description: string; + scheme: "dark" | "light"; + variables: Record; +} + +const SANS = + "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif"; +const MONO = "\"SFMono-Regular\", Consolas, monospace"; + +export const THEMES: Record = { + aurora: { + name: "aurora", + label: "Aurora", + description: "Dark blue palette with teal and amber accents. This is the default theme.", + scheme: "dark", + variables: { + scheme: "dark", + bg: "#08111f", + "panel-tint": "rgba(17, 33, 57, 0.66)", + "panel-a": "rgba(23, 42, 70, 0.98)", + "panel-b": "rgba(13, 26, 45, 0.96)", + line: "rgba(169, 195, 222, 0.18)", + "line-soft": "rgba(169, 195, 222, 0.1)", + text: "#f3f7fb", + muted: "#9db0c7", + accent: "#67b7ff", + "accent-soft": "#b8dcff", + "accent-2": "#a7f3d0", + "accent-3": "#ffb86b", + glow: "rgba(70, 148, 232, 0.2)", + shadow: "0 24px 60px rgba(0, 0, 0, 0.24)", + "visual-bg": "#0a1525", + stripe: "rgba(103, 183, 255, 0.05)", + "tag-line": "rgba(167, 243, 208, 0.26)", + "code-bg": "rgba(167, 243, 208, 0.08)", + sans: SANS, + mono: MONO, + }, + }, + terminal: { + name: "terminal", + label: "Terminal", + description: "Monochrome green on black. Built for low-light reading.", + scheme: "dark", + variables: { + scheme: "dark", + bg: "#050b06", + "panel-tint": "rgba(16, 34, 21, 0.66)", + "panel-a": "rgba(14, 28, 18, 0.98)", + "panel-b": "rgba(6, 14, 9, 0.96)", + line: "rgba(130, 255, 165, 0.18)", + "line-soft": "rgba(130, 255, 165, 0.1)", + text: "#eafff0", + muted: "#8bb39a", + accent: "#66ff8c", + "accent-soft": "#c5ffd4", + "accent-2": "#66ff8c", + "accent-3": "#ffe9a8", + glow: "rgba(102, 255, 140, 0.14)", + shadow: "0 24px 60px rgba(0, 0, 0, 0.6)", + "visual-bg": "#021004", + stripe: "rgba(102, 255, 140, 0.05)", + "tag-line": "rgba(130, 255, 165, 0.26)", + "code-bg": "rgba(130, 255, 165, 0.08)", + sans: SANS, + mono: MONO, + }, + }, + paper: { + name: "paper", + label: "Paper", + description: "Light theme with a paper background and dark ink text.", + scheme: "light", + variables: { + scheme: "light", + bg: "#f6f8fb", + "panel-tint": "rgba(255, 255, 255, 0.72)", + "panel-a": "#ffffff", + "panel-b": "#e9eef5", + line: "rgba(20, 40, 70, 0.12)", + "line-soft": "rgba(20, 40, 70, 0.08)", + text: "#14243a", + muted: "#5a6c82", + accent: "#1d4ed8", + "accent-soft": "#1e3a8a", + "accent-2": "#047857", + "accent-3": "#b45309", + glow: "rgba(29, 78, 216, 0.1)", + shadow: "0 24px 60px rgba(20, 32, 46, 0.14)", + "visual-bg": "#0f1b2e", + stripe: "rgba(29, 78, 216, 0.06)", + "tag-line": "rgba(4, 120, 87, 0.24)", + "code-bg": "rgba(4, 120, 87, 0.08)", + sans: SANS, + mono: MONO, + }, + }, +}; + +export function isKnownTheme(name: string): boolean { + return Object.prototype.hasOwnProperty.call(THEMES, name); +} + +export function listThemeNames(): string[] { + return Object.keys(THEMES); +} + +export function resolveTheme(name: string): Theme { + const theme = THEMES[name]; + if (!theme) { + throw new Error(`Unknown theme "${name}". Available themes: ${listThemeNames().join(", ")}.`); + } + return theme; +} + +export function cssVariables(theme: Theme): string { + return Object.entries(theme.variables) + .map(([key, value]) => ` --${key}: ${value};`) + .join("\n"); +} diff --git a/src/types.ts b/src/types.ts index 92c30da..1f44c86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,13 @@ export interface PrivacyConfig { maxCommitsPerProject: number; } +export type DeployAdapterName = "none" | "local"; + +export interface DeploymentConfig { + adapter: DeployAdapterName; + targetDir?: string; +} + export interface PortfolioConfig { owner: string; title: string; @@ -85,6 +92,8 @@ export interface PortfolioConfig { repositoryLimit?: number; dataDir: string; outputDir: string; + theme: string; + deploy: DeploymentConfig; privacy: PrivacyConfig; clock: () => string; } @@ -95,6 +104,12 @@ export const DEFAULT_PRIVACY: PrivacyConfig = { maxCommitsPerProject: 50, }; +export const DEFAULT_THEME = "aurora"; + +export const DEFAULT_DEPLOY: DeploymentConfig = { + adapter: "none", +}; + export const DEFAULT_CONFIG = { owner: "demo-engineer", title: "EngineerProfile", @@ -102,6 +117,8 @@ export const DEFAULT_CONFIG = { repositoryLimit: 5, dataDir: "data", outputDir: "output", + theme: DEFAULT_THEME, + deploy: DEFAULT_DEPLOY, privacy: DEFAULT_PRIVACY, clock: () => new Date().toISOString(), } satisfies PortfolioConfig; diff --git a/tests/config.test.ts b/tests/config.test.ts index 44e2720..9268e71 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -37,4 +37,63 @@ describe("portfolio configuration", () => { 'Configuration field "repositoryLimit" must be an integer from 1 to 100.' ); }); + + it("applies the default theme and deployment adapter", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ owner: "owner" })); + + const config = loadPortfolioConfig(TEST_FILE, () => "2026-07-31T00:00:00.000Z"); + expect(config.theme).toBe("aurora"); + expect(config.deploy.adapter).toBe("none"); + expect(config.deploy.targetDir).toBeUndefined(); + }); + + it("loads a configured theme and local deployment adapter", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + theme: "terminal", + deploy: { adapter: "local", targetDir: "docs" }, + })); + + const config = loadPortfolioConfig(TEST_FILE, () => "2026-07-31T00:00:00.000Z"); + expect(config.theme).toBe("terminal"); + expect(config.deploy.adapter).toBe("local"); + expect(config.deploy.targetDir).toBe("docs"); + }); + + it("rejects an unknown theme", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: "neon" })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme" must be one of' + ); + }); + + it("rejects an empty theme name", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: " " })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme" must be a non-empty string.' + ); + }); + + it("rejects an unknown deployment adapter", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ deploy: { adapter: "surge" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "deploy.adapter" must be one of' + ); + }); + + it("rejects a malformed deployment target directory", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ deploy: { adapter: "local", targetDir: "" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "deploy.targetDir" must be a non-empty string.' + ); + }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts new file mode 100644 index 0000000..01987cf --- /dev/null +++ b/tests/deploy.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { deployPortfolio } from "../src/deploy/run.js"; +import { isKnownAdapter, listAdapters, resolveAdapter } from "../src/deploy/adapters.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"); +const TEST_TARGET = join("output", "test-deploy-target"); + +function configFor(overrides: Partial = {}) { + return { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + clock: () => "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +afterEach(() => { + for (const path of [TEST_DATA, TEST_OUTPUT, TEST_TARGET]) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("deployment adapters", () => { + it("lists the built-in adapters", () => { + expect(listAdapters()).toEqual(["none", "local"]); + }); + + it("recognizes every built-in adapter name", () => { + for (const name of listAdapters()) { + expect(isKnownAdapter(name)).toBe(true); + } + }); + + it("resolves known adapter names", () => { + expect(resolveAdapter("local").name).toBe("local"); + expect(resolveAdapter("none").name).toBe("none"); + }); + + it("rejects unknown adapter names", () => { + expect(() => resolveAdapter("surge")).toThrow(/Unknown deployment adapter/); + }); + + it("does nothing with the none adapter", () => { + const result = deployPortfolio(configFor()); + expect(result.adapter).toBe("none"); + expect(result.target).toBeNull(); + expect(result.filesCopied).toBe(0); + }); +}); + +describe("local deployment", () => { + it("mirrors the output directory into the target", () => { + mkdirSync(join(TEST_OUTPUT, "assets", "screenshots"), { recursive: true }); + writeFileSync(join(TEST_OUTPUT, "index.html"), "site"); + writeFileSync(join(TEST_OUTPUT, "assets", "screenshots", "a.png"), "png"); + + const result = deployPortfolio(configFor({ deploy: { adapter: "local", targetDir: TEST_TARGET } })); + + expect(result.adapter).toBe("local"); + expect(result.filesCopied).toBe(2); + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + expect(readFileSync(join(TEST_TARGET, "index.html"), "utf-8")).toBe("site"); + expect(existsSync(join(TEST_TARGET, "assets", "screenshots", "a.png"))).toBe(true); + }); + + it("replaces the previous target contents", () => { + mkdirSync(TEST_TARGET, { recursive: true }); + writeFileSync(join(TEST_TARGET, "stale.txt"), "old"); + mkdirSync(TEST_OUTPUT, { recursive: true }); + writeFileSync(join(TEST_OUTPUT, "index.html"), "fresh"); + + deployPortfolio(configFor({ deploy: { adapter: "local", targetDir: TEST_TARGET } })); + + expect(existsSync(join(TEST_TARGET, "stale.txt"))).toBe(false); + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + }); + + it("requires a target directory for the local adapter", () => { + mkdirSync(TEST_OUTPUT, { recursive: true }); + expect(() => + deployPortfolio(configFor({ deploy: { adapter: "local" } })) + ).toThrow(/targetDir/); + }); + + it("rejects a target that overlaps the output directory", () => { + mkdirSync(TEST_OUTPUT, { recursive: true }); + expect(() => + deployPortfolio(configFor({ deploy: { adapter: "local", targetDir: TEST_OUTPUT } })) + ).toThrow(/must not overlap/); + }); + + it("rejects a target nested inside the output directory", () => { + mkdirSync(TEST_OUTPUT, { recursive: true }); + expect(() => + deployPortfolio(configFor({ deploy: { adapter: "local", targetDir: join(TEST_OUTPUT, "nested") } })) + ).toThrow(/must not overlap/); + }); + + it("rejects a missing output directory", () => { + expect(() => + deployPortfolio(configFor({ deploy: { adapter: "local", targetDir: TEST_TARGET } })) + ).toThrow(/Nothing to deploy/); + }); + + it("supports a one-off target override with the none adapter", () => { + mkdirSync(TEST_OUTPUT, { recursive: true }); + writeFileSync(join(TEST_OUTPUT, "index.html"), "site"); + const result = deployPortfolio(configFor(), { adapter: "local", targetDir: TEST_TARGET }); + expect(result.adapter).toBe("local"); + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + }); + + it("records the deployment in the audit trail", () => { + mkdirSync(TEST_OUTPUT, { recursive: true }); + writeFileSync(join(TEST_OUTPUT, "index.html"), "site"); + deployPortfolio(configFor({ deploy: { adapter: "local", targetDir: TEST_TARGET } })); + + const db = openDatabase(TEST_DATA, () => "2026-01-01T00:00:00.000Z"); + const log = db.getIngestLog(5); + db.close(); + expect(log.some((entry) => entry.action === "deploy")).toBe(true); + }); +}); diff --git a/tests/deterministic.test.ts b/tests/deterministic.test.ts index 78fc6ea..f9e6468 100644 --- a/tests/deterministic.test.ts +++ b/tests/deterministic.test.ts @@ -35,6 +35,8 @@ describe("deterministic publishing", () => { expect(html[0]).toBe(html[1]); expect(html[0]).toContain("2026-07-31 00:00:00 UTC"); + expect(html[0]).toContain('data-theme="aurora"'); + expect(html[0]).toContain("--accent: #67b7ff;"); }); it("keeps release source links in the published evidence trail", async () => { diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts index d119d9b..22b601d 100644 --- a/tests/refresh.test.ts +++ b/tests/refresh.test.ts @@ -7,10 +7,12 @@ import { DEFAULT_CONFIG } from "../src/types.js"; const TEST_DATA = join("data", "test-refresh"); const TEST_OUTPUT = join("output", "test-refresh"); +const TEST_TARGET = join("output", "test-refresh-target"); afterEach(() => { rmSync(TEST_DATA, { recursive: true, force: true }); rmSync(TEST_OUTPUT, { recursive: true, force: true }); + rmSync(TEST_TARGET, { recursive: true, force: true }); }); describe("configured refresh", () => { @@ -32,7 +34,31 @@ describe("configured refresh", () => { expect(result.captured).toBe(0); expect(result.captureErrors).toEqual([]); expect(result.published.projectCount).toBe(2); + expect(result.published.theme).toBe("aurora"); + expect(result.deployed).toBeNull(); expect(result.copiedScreenshots).toBe(0); expect(existsSync(join(TEST_OUTPUT, "index.html"))).toBe(true); }); + + it("deploys after publishing when an adapter is configured", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + repositoryLimit: 2, + theme: "paper", + deploy: { adapter: "local", targetDir: TEST_TARGET }, + clock: () => "2026-07-31T00:00:00.000Z", + }; + + const result = await refreshPortfolio(config, { + fixtureRepos: loadAllFixtures(), + capture: false, + }); + + expect(result.published.theme).toBe("paper"); + expect(result.deployed).not.toBeNull(); + expect(result.deployed!.adapter).toBe("local"); + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + }); }); diff --git a/tests/site.test.ts b/tests/site.test.ts index 03061cf..80ff2d5 100644 --- a/tests/site.test.ts +++ b/tests/site.test.ts @@ -87,6 +87,25 @@ describe("database and publish pipeline", () => { expect(html).toContain("total stars"); }); + it("publishes with the default theme markers", async () => { + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; + await ingestRepository( + config, + { owner: "demo-engineer", repo: "signal-router" }, + { + repo: loadFixtureRepo("signal-router"), + commits: loadFixtureCommits("signal-router"), + releases: loadFixtureReleases("signal-router"), + } + ); + + const result = publishSite(config); + expect(result.theme).toBe("aurora"); + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain('data-theme="aurora"'); + expect(html).toContain("--accent: #67b7ff;"); + }); + 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..eeb78f2 --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, afterEach } 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 { isKnownTheme, listThemeNames, resolveTheme } 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"); + +afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); +}); + +describe("theme registry", () => { + it("resolves the default theme by name", () => { + expect(resolveTheme("aurora").name).toBe("aurora"); + }); + + it("recognizes every built-in theme name", () => { + for (const name of listThemeNames()) { + expect(isKnownTheme(name)).toBe(true); + } + }); + + it("rejects unknown theme names", () => { + expect(() => resolveTheme("neon")).toThrow(/Unknown theme/); + }); + + it("defines the same color tokens for every theme", () => { + const required = [ + "scheme", + "bg", + "panel-tint", + "panel-a", + "panel-b", + "line", + "line-soft", + "text", + "muted", + "accent", + "accent-soft", + "accent-2", + "accent-3", + "glow", + "shadow", + "visual-bg", + "stripe", + "tag-line", + "code-bg", + "sans", + "mono", + ]; + for (const name of listThemeNames()) { + const theme = resolveTheme(name); + for (const token of required) { + expect(theme.variables[token], `${name} defines ${token}`).toBeDefined(); + } + } + }); +}); + +describe("theme configuration", () => { + it("loads a valid theme from the configuration file", () => { + const dir = join("data", "test-theme-config"); + mkdirSync(dir, { recursive: true }); + const file = join(dir, "portfolio.json"); + writeFileSync(file, JSON.stringify({ theme: "terminal" })); + + const config = loadPortfolioConfig(file, () => "2026-01-01T00:00:00.000Z"); + expect(config.theme).toBe("terminal"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("rejects an unknown theme in the configuration file", () => { + const dir = join("data", "test-theme-config-bad"); + mkdirSync(dir, { recursive: true }); + const file = join(dir, "portfolio.json"); + writeFileSync(file, JSON.stringify({ theme: "neon" })); + + expect(() => loadPortfolioConfig(file)).toThrow(/must be one of/); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("theme publishing", () => { + async function publishWith(theme?: string) { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + theme: theme ?? DEFAULT_CONFIG.theme, + clock: () => "2026-01-01T00:00:00.000Z", + }; + await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures()); + return publishSite(config); + } + + it("publishes the default theme when none is configured", async () => { + const result = await publishWith(); + expect(result.theme).toBe("aurora"); + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain('data-theme="aurora"'); + }); + + it("publishes the configured theme", async () => { + const result = await publishWith("terminal"); + expect(result.theme).toBe("terminal"); + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain('data-theme="terminal"'); + expect(html).toContain("--accent: #66ff8c;"); + }); +});