From d2ffdfac4cea87be489bb746135e701a0ac8c3f1 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:21:02 -0700 Subject: [PATCH] feat: extend engineer profile --- .github/workflows/ci.yml | 8 +- .github/workflows/refresh.yml | 2 +- .gitignore | 1 + .nvmrc | 2 +- CONTRIBUTING.md | 11 +- README.md | 148 ++++++++++++++------- engineer-profile.config.json | 3 + package-lock.json | 4 +- package.json | 2 +- src/config/loader.ts | 79 +++++++++++- src/deploy/local.ts | 52 ++++++++ src/index.ts | 44 ++++++- src/ingest/github.ts | 2 +- src/publish/site.ts | 142 +++++++++++++++----- src/refresh/run.ts | 8 +- src/theme/palette.ts | 236 ++++++++++++++++++++++++++++++++++ src/types.ts | 29 +++++ tests/config.test.ts | 56 ++++++++ tests/demo.test.ts | 1 + tests/deploy.test.ts | 82 ++++++++++++ tests/deterministic.test.ts | 16 +++ tests/site.test.ts | 58 ++++++++- tests/theme.test.ts | 92 +++++++++++++ vitest.config.mjs | 6 + vitest.config.ts | 6 + 25 files changed, 985 insertions(+), 105 deletions(-) create mode 100644 src/deploy/local.ts create mode 100644 src/theme/palette.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..76eb698 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: npm - name: Install dependencies @@ -44,6 +44,12 @@ jobs: - name: Run fixture demo run: node dist/index.js demo + - name: Verify theme catalog + run: node dist/index.js themes + + - name: Verify deployment manifest + run: node -e "const fs=require('fs');const manifest=JSON.parse(fs.readFileSync('output/site-manifest.json','utf8'));if(manifest.formatVersion!==1||typeof manifest.projectCount!=='number')process.exit(1);console.log('Manifest ok: '+manifest.projectCount+' projects')" + - name: Store demo output if: success() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/refresh.yml b/.github/workflows/refresh.yml index 360cd31..cb2d121 100644 --- a/.github/workflows/refresh.yml +++ b/.github/workflows/refresh.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: npm - name: Install dependencies diff --git a/.gitignore b/.gitignore index 6d63646..10c745d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ coverage/ data/ output/ +public/ *.db *.db-journal *.db-shm 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..cbb12ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Local checks -Use Node.js 20 or newer. +Use Node.js 22 LTS or newer. ```bash npm ci @@ -15,8 +15,15 @@ npm test Use fixtures for changes that need repeatable data. Do not add credentials, private repository data, or generated output. +## Feature areas + +- Themes: add palettes in `src/theme/palette.ts`. +- Deploy targets: add adapters in `src/deploy/`. +- The publish step writes `site-manifest.json`. +- Keep every behavior deterministic and covered by a test. + ## 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. diff --git a/README.md b/README.md index f1a2a81..cf97725 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # 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. +EngineerProfile builds a local engineering portfolio from public repository data. It stores repository metadata, commits, releases, privacy settings, previews, and publish records in SQLite. It publishes a static site from these records. ## Value -- Keep portfolio facts close to their source data. - Refresh project cards from public GitHub repositories. - Build release notes from releases or conventional commits. - Capture repeatable project previews with Playwright. - Hide projects and redact author emails before publication. +- Choose a presentation theme and override its accent. +- Publish a deployment manifest with each site build. +- Copy the site to a local deploy target. - Run one configured refresh from a scheduled workflow. The fixture demo runs without secrets and without network access. @@ -30,12 +30,15 @@ flowchart LR L --> W[Publisher] P --> W S --> W + T[Theme] --> W W --> O[Static output] + W --> M[Manifest] + O --> X[Deploy target] ``` | Area | Responsibility | | --- | --- | -| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, and privacy settings. | +| `engineer-profile.config.json` | Store owner, presentation, 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/ingest/` | Fetch public GitHub data and map it to records. | @@ -43,15 +46,16 @@ flowchart LR | `src/changelog/` | Prefer release notes and fall back to commit groups. | | `src/privacy/` | Hide projects and block sensitive commit messages. | | `src/preview/` | Capture fixed viewport screenshots with Playwright. | -| `src/publish/` | Render HTML, changelog files, and preview assets. | +| `src/theme/` | Resolve built-in palettes and theme overrides. | +| `src/publish/` | Render HTML, changelog files, preview assets, and the manifest. | +| `src/deploy/` | Copy the published snapshot to local targets. | | `fixtures/` | Provide deterministic demo data and local preview pages. | -The refresh command runs each stage in a fixed order. -If a preview fails, the command reports the skip and keeps the rest of the snapshot. +The refresh command runs each stage in a fixed order. If a preview fails, the command reports the skip and keeps the rest of the snapshot. ## Setup -Use Node.js 20 or newer. +Use Node.js 22 LTS or newer. ```bash npm ci @@ -61,18 +65,13 @@ npm run demo Open `output/index.html` in a browser. -The demo creates a local SQLite database under `data/`. -It writes the static site under `output/`. -Both directories are ignored by Git. +The demo creates a local SQLite database under `data/`. It writes the static site under `output/`. 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. +`engineer-profile.config.json` is the checked-in source for scheduled refreshes. It sets the owner, presentation, theme, deploy targets, repository limit, paths, and privacy controls. -The loader accepts repository limits from 1 through 100. -It rejects malformed values before network access. -CLI `--config`, `--data`, and `--output` options override file values. +The loader rejects malformed values before network access. CLI `--config`, `--data`, and `--output` options override file values. Run a network-backed refresh with the checked-in settings: @@ -80,25 +79,74 @@ 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 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. +GitHub ingestion uses the public API. Set `GITHUB_TOKEN` for a higher rate limit. ```powershell $env:GITHUB_TOKEN="your-token" npm run refresh ``` -Do not put a token in repository files. -Use `.env.example` as a variable reference. +Do not put a token in repository files. Use `.env.example` as a variable reference. + +## Themes + +The published site uses one built-in theme. Set `theme.name` in the configuration. + +| Name | Appearance | +| --- | --- | +| `deep-space` | Dark palette with blue accents. Default. | +| `paper` | Light palette with dark text and strong contrast. | + +List built-in themes: + +```bash +node dist/index.js themes +``` + +Override presentation tokens inside the theme: + +```json +{ + "theme": { + "name": "deep-space", + "accent": "#67b7ff", + "radius": "16px", + "font": "Inter, system-ui, sans-serif" + } +} +``` + +`accent` must be a hex color. `radius` is a CSS length. `font` is a CSS font stack. + +## Deployment + +Each publish writes `site-manifest.json`. The manifest lists projects, files, and screenshots. Deployment tooling can read this file. + +Add a local deploy target: + +```json +{ + "deploy": { + "targets": [ + { "name": "preview", "type": "local", "target": "public" } + ] + } +} +``` + +Publish and deploy the snapshot: + +```bash +node dist/index.js deploy +``` + +The refresh command also deploys when the configuration defines targets. ## Sample output -The fixture set contains `signal-router` and `metrics-kit`. -The first project has release notes. -The second project uses commit-based notes. +The fixture set contains `signal-router` and `metrics-kit`. The first project has release notes. The second project uses commit-based notes. ```text Ingested 2 fixture projects. @@ -109,8 +157,14 @@ Copied 2 available preview screenshots. Open output/index.html in a browser. ``` -The site shows project facts, source links, changelog previews, and screenshots. -The totals come from fixture fields and stored commit records. +A deploy with one configured target prints: + +```text +Published 2 projects to output/index.html. +Deployed preview: 6 files to public. +``` + +The site shows project facts, source links, changelog previews, and screenshots. The totals come from fixture fields and stored commit records. The manifest records the same totals in machine-readable form. ## Commands @@ -123,7 +177,9 @@ 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` | Publish and copy output to deploy targets. | +| `node dist/index.js themes` | List built-in themes. | +| `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. | @@ -138,40 +194,32 @@ node dist/index.js privacy --hide demo-engineer-metrics-kit npm run publish ``` -Show the project again with `privacy --show`. -Hidden projects remain in SQLite. -Hidden projects stay out of public HTML and copied assets. -Author emails are redacted by default. -Sensitive commit messages are skipped before storage. +Show the project again with `privacy --show`. Hidden projects remain in SQLite. Hidden projects stay out of public HTML and copied assets. Author emails are redacted by default. Sensitive commit messages are skipped before storage. ## Audit model -Each project stores a repository URL and its last pushed timestamp. -Each stored commit keeps its SHA, message first line, date, and source URL. -Each release keeps its tag, notes, date, and source URL. +Each project stores a repository URL and its last pushed timestamp. Each stored commit keeps its SHA, message first line, date, and source URL. Each 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. +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. ## CI and test status -The regular CI workflow runs typecheck, build, tests, the fixture demo, and artifact upload. -The scheduled refresh workflow runs each Monday and supports manual dispatch. -It uploads the generated site as a workflow artifact. +The regular CI workflow runs typecheck, build, tests, the fixture demo, theme listing, and manifest validation. The scheduled refresh workflow runs each Monday and supports manual dispatch. It uploads the generated site as a workflow artifact. The test suite covers these core behaviors: - Configuration validation and default merging. +- Theme resolution and color validation. - Conventional commit parsing. - Release-first changelog generation. - SQLite upserts and changelog replacement. - Privacy filtering and email redaction. - Fixture ingestion and static publishing. +- Deployment manifest generation. +- Local deploy target copying. - Configured refresh orchestration. - Release source links. -- Deterministic HTML output. +- Deterministic HTML and manifest output. - Playwright screenshot capture. Run the local checks: @@ -184,9 +232,7 @@ npm test ### Validation status -Typecheck and build pass locally. -CI runs the complete test suite on Ubuntu with Chromium installed. -The fixture pipeline provides deterministic data for repeatable checks. +Typecheck and build pass locally. CI runs the complete test suite on Ubuntu with Chromium installed. The fixture pipeline provides deterministic data for repeatable checks. ## Limitations @@ -196,6 +242,8 @@ The fixture pipeline provides deterministic data for repeatable checks. - Changelog quality depends on releases or conventional commits. - External pages can fail during capture. - Capture failures are reported and do not stop publishing. +- Two built-in themes ship with the tool. +- Only the local deploy target is available now. - Publishing creates local files. It does not deploy them. - Scheduled runs upload artifacts. They do not commit generated output. @@ -205,8 +253,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 | Custom themes, deployment manifest, and local deploy target. | +| v0.4 | Next | Remote deploy targets, commit-diff summaries, and an RSS feed. | ## License diff --git a/engineer-profile.config.json b/engineer-profile.config.json index 81008fb..39832c2 100644 --- a/engineer-profile.config.json +++ b/engineer-profile.config.json @@ -5,6 +5,9 @@ "repositoryLimit": 5, "dataDir": "data", "outputDir": "output", + "theme": { + "name": "deep-space" + }, "privacy": { "hiddenProjects": [], "redactEmails": true, diff --git a/package-lock.json b/package-lock.json index 87f5d51..23ffbf1 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", diff --git a/package.json b/package.json index d36edbe..c613aec 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", diff --git a/src/config/loader.ts b/src/config/loader.ts index 406896b..5985230 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; -import type { PortfolioConfig, PrivacyConfig } from "../types.js"; -import { DEFAULT_CONFIG, DEFAULT_PRIVACY } from "../types.js"; +import type { DeployConfig, DeployTarget, PortfolioConfig, PrivacyConfig, ThemeConfig } from "../types.js"; +import { DEFAULT_CONFIG, DEFAULT_DEPLOY, DEFAULT_PRIVACY, DEFAULT_THEME } from "../types.js"; import { mergePrivacy } from "../privacy/controls.js"; +import { isBuiltinTheme, isValidHexColor, listBuiltinThemes } from "../theme/palette.js"; export const DEFAULT_CONFIG_PATH = "engineer-profile.config.json"; @@ -56,6 +57,78 @@ function readPrivacy(source: ConfigValue): PrivacyConfig { }); } +function readTheme(source: ConfigValue): ThemeConfig { + if (!("theme" in source)) return DEFAULT_THEME; + if (!isConfigValue(source.theme)) { + throw new Error('Configuration field "theme" must be an object.'); + } + + const name = source.theme.name === undefined ? DEFAULT_THEME.name : readString(source.theme, "name", DEFAULT_THEME.name); + if (!isBuiltinTheme(name)) { + throw new Error( + `Configuration field "theme.name" must be one of: ${listBuiltinThemes().map((theme) => theme.name).join(", ")}.` + ); + } + const theme: ThemeConfig = { name }; + + if ("accent" in source.theme) { + const accent = source.theme.accent; + if (typeof accent !== "string" || !isValidHexColor(accent)) { + throw new Error('Configuration field "theme.accent" must be a hex color like "#67b7ff".'); + } + theme.accent = accent.trim(); + } + if ("radius" in source.theme) { + const radius = source.theme.radius; + if (typeof radius !== "string" || radius.trim() === "") { + throw new Error('Configuration field "theme.radius" must be a non-empty CSS length.'); + } + theme.radius = radius.trim(); + } + if ("font" in source.theme) { + const font = source.theme.font; + if (typeof font !== "string" || font.trim() === "") { + throw new Error('Configuration field "theme.font" must be a non-empty font stack.'); + } + theme.font = font.trim(); + } + return theme; +} + +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.'); + } + if (!("targets" in source.deploy)) return DEFAULT_DEPLOY; + + const targets = source.deploy.targets; + if (!Array.isArray(targets)) { + throw new Error('Configuration field "deploy.targets" must be a list.'); + } + + const parsedTargets: DeployTarget[] = targets.map((target, index) => { + if (!isConfigValue(target)) { + throw new Error(`Configuration field "deploy.targets[${index}]" must be an object.`); + } + const name = target.name; + const type = target.type; + const targetPath = target.target; + if (typeof name !== "string" || name.trim() === "") { + throw new Error(`Configuration field "deploy.targets[${index}].name" must be a non-empty string.`); + } + if (type !== "local") { + throw new Error(`Configuration field "deploy.targets[${index}].type" must be "local".`); + } + if (typeof targetPath !== "string" || targetPath.trim() === "") { + throw new Error(`Configuration field "deploy.targets[${index}].target" must be a non-empty path.`); + } + return { name: name.trim(), type: "local", target: targetPath.trim() }; + }); + + return { targets: parsedTargets }; +} + export function loadPortfolioConfig( path: string = DEFAULT_CONFIG_PATH, clock: () => string = DEFAULT_CONFIG.clock @@ -78,6 +151,8 @@ export function loadPortfolioConfig( repositoryLimit: readLimit(parsed, "repositoryLimit", DEFAULT_CONFIG.repositoryLimit), dataDir: readString(parsed, "dataDir", DEFAULT_CONFIG.dataDir), outputDir: readString(parsed, "outputDir", DEFAULT_CONFIG.outputDir), + theme: readTheme(parsed), + deploy: readDeploy(parsed), privacy: readPrivacy(parsed), clock, }; diff --git a/src/deploy/local.ts b/src/deploy/local.ts new file mode 100644 index 0000000..684a8a4 --- /dev/null +++ b/src/deploy/local.ts @@ -0,0 +1,52 @@ +import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { openDatabase } from "../db/client.js"; +import type { PortfolioConfig } from "../types.js"; + +export interface DeployResult { + targetName: string; + targetPath: string; + files: number; +} + +function countFiles(targetPath: string): number { + let total = 0; + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + if (entry.isDirectory()) walk(join(current, entry.name)); + else total++; + } + }; + walk(targetPath); + return total; +} + +export function deployLocal( + config: PortfolioConfig, + targetName: string, + targetPath: string +): DeployResult { + const indexPath = join(config.outputDir, "index.html"); + if (!existsSync(indexPath)) { + throw new Error(`No published site found at "${config.outputDir}". Run publish first.`); + } + + mkdirSync(targetPath, { recursive: true }); + cpSync(config.outputDir, targetPath, { recursive: true }); + const files = countFiles(targetPath); + + const db = openDatabase(config.dataDir, config.clock); + try { + db.logIngest("deploy", `${targetName} -> ${targetPath}`); + } finally { + db.close(); + } + + return { targetName, targetPath, files }; +} + +export function deployAll(config: PortfolioConfig): DeployResult[] { + return config.deploy.targets + .filter((target) => target.type === "local") + .map((target) => deployLocal(config, target.name, target.target)); +} diff --git a/src/index.ts b/src/index.ts index aea5c27..0f3b848 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,19 +4,21 @@ import { join } from "node:path"; 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 { publishSite } from "./publish/site.js"; import { loadAllFixtures } from "./fixtures/loader.js"; import { openDatabase } from "./db/client.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "./types.js"; import { DEFAULT_CONFIG_PATH, loadPortfolioConfig } from "./config/loader.js"; import { refreshPortfolio } from "./refresh/run.js"; +import { listBuiltinThemes } from "./theme/palette.js"; +import { deployAll } from "./deploy/local.js"; const program = new Command(); program .name("engineer-profile") .description("Build a local engineering portfolio from public repository evidence") - .version("0.2.0"); + .version("0.3.0"); function resolveConfig(options: { config?: string; data?: string; output?: string }): PortfolioConfig { const base = options.config @@ -64,9 +66,8 @@ addConfigOption(program } const result = publishSite(config); - const copied = copyScreenshotsToOutput(config); console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); - console.log(`Copied ${copied} available preview screenshots.`); + console.log(`Copied ${result.screenshotsCopied} available preview screenshots.`); console.log("Open output/index.html in a browser."); })); @@ -128,11 +129,39 @@ addConfigOption(program .action((options) => { const config = resolveConfig(options); const result = publishSite(config); - const copied = copyScreenshotsToOutput(config); console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); - console.log(`Copied ${copied} available preview screenshots.`); + console.log(`Copied ${result.screenshotsCopied} available preview screenshots.`); })); +addConfigOption(program + .command("deploy") + .description("Publish the snapshot and copy it to configured deploy targets") + .option("-d, --data ", "Data directory") + .option("-o, --output ", "Output directory") + .action((options) => { + const config = resolveConfig(options); + const result = publishSite(config); + console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); + const results = deployAll(config); + if (results.length === 0) { + console.log('No deploy targets configured. Add a "deploy.targets" entry to the configuration.'); + } + for (const deployed of results) { + console.log(`Deployed ${deployed.targetName}: ${deployed.files} files to ${deployed.targetPath}.`); + } + })); + +program + .command("themes") + .description("List built-in presentation themes") + .action(() => { + const themes = listBuiltinThemes(); + console.log(`Built-in themes: ${themes.length}`); + for (const theme of themes) { + console.log(` ${theme.name}: ${theme.description}`); + } + }); + addConfigOption(program .command("refresh") .description("Ingest, capture, and publish from the checked-in configuration") @@ -149,6 +178,9 @@ addConfigOption(program for (const error of result.captureErrors) { console.warn(`Skipped ${error.slug}: ${error.message}`); } + for (const target of result.deployed) { + console.log(`Deployed to ${target}.`); + } })); addConfigOption(program 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..268780f 100644 --- a/src/publish/site.ts +++ b/src/publish/site.ts @@ -1,7 +1,8 @@ -import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { formatChangelogMarkdown } from "../changelog/generator.js"; import { openDatabase } from "../db/client.js"; +import { resolveTheme, themeVariables, type ThemeTokens } from "../theme/palette.js"; import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; function escapeHtml(text: string): string { @@ -62,6 +63,30 @@ function publicAuditDetail(detail: string | null): string { return separator >= 0 ? `${detail.slice(0, separator)} -> local artifact` : detail; } +function brandMark(title: string): string { + const words = title.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return "EP"; + if (words.length === 1) return title.slice(0, 2).toUpperCase(); + return words + .slice(0, 2) + .map((word) => word[0]) + .join("") + .toUpperCase(); +} + +function listFiles(dir: string): string[] { + const files: string[] = []; + const walk = (current: string, prefix: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(join(current, entry.name), relative); + else files.push(relative); + } + }; + walk(dir, ""); + return files.sort(); +} + function displayDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value.slice(0, 10); @@ -124,29 +149,14 @@ function projectCard( `; } -function siteCss(): string { +function siteCss(theme: ThemeTokens): 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; -} +${themeVariables(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; } 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: var(--glow), var(--ink); } .container { width: min(1180px, calc(100% - 48px)); margin: 0 auto; } .site-nav { display: flex; justify-content: space-between; align-items: center; padding: 28px 0; border-bottom: 1px solid var(--line); } .brand { display: inline-flex; align-items: center; gap: 12px; font: 700 0.9rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; } @@ -158,7 +168,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: var(--aside-gradient); 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 +176,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(--audit-bg); } .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: var(--radius); background: var(--panel-gradient); 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, var(--stripe), var(--stripe) 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 var(--label-border); border-radius: 5px; background: var(--label-bg); color: var(--blue-soft); } .project-body { padding: 30px 34px 32px; } .card-topline { display: flex; justify-content: space-between; gap: 12px; color: var(--blue); } .project-body h2 { margin: 18px 0 8px; font-size: 2.1rem; font-weight: 560; letter-spacing: -0.06em; } @@ -187,7 +197,7 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we .facts { display: flex; flex-wrap: wrap; gap: 22px; margin: 24px 0 16px; color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; } .facts strong { color: var(--text); font-size: 1rem; font-weight: 600; } .tags { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 26px; } -.tag { padding: 5px 9px; border: 1px solid rgba(167, 243, 208, 0.26); border-radius: 999px; color: var(--mint); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; } +.tag { padding: 5px 9px; border: 1px solid var(--tag-border); border-radius: 999px; background: var(--tag-bg); color: var(--mint); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; } .change-log { padding: 18px 0 20px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } .section-heading { display: flex; justify-content: space-between; color: var(--orange); } .source-badge { color: var(--muted); } @@ -197,12 +207,12 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we .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; } +.change-preview code { padding: 2px 4px; border-radius: 3px; color: var(--mint); background: var(--code-bg); font: 0.76rem "SFMono-Regular", Consolas, monospace; } .text-link { display: inline-block; margin-top: 14px; color: var(--blue); font: 0.73rem/1 "SFMono-Regular", Consolas, monospace; text-decoration: none; text-transform: uppercase; } .card-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; } .button { display: inline-block; padding: 10px 14px; border-radius: 7px; font: 0.72rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.04em; text-decoration: none; text-transform: uppercase; } -.button.primary { background: var(--blue); color: var(--ink); } -.button.primary:hover { background: var(--blue-soft); } +.button.primary { background: var(--blue); color: var(--button-text); } +.button.primary:hover { background: var(--blue-soft); color: var(--button-text); } .button.secondary { border: 1px solid var(--line); color: var(--text); } .button.secondary:hover { border-color: var(--blue); color: var(--blue); } .muted { color: var(--muted); } @@ -210,7 +220,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-soft); 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; } @@ -239,10 +249,38 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we `; } +export interface ProjectManifestEntry { + slug: string; + name: string; + url: string; + language: string | null; + stars: number; + commits: number; + releases: number; + visible: boolean; + hasScreenshot: boolean; + changelogFile: string | null; +} + +export interface SiteManifest { + formatVersion: 1; + generatedAt: string; + title: string; + owner: string; + theme: string; + projectCount: number; + projects: ProjectManifestEntry[]; + files: string[]; + screenshots: string[]; +} + export interface PublishResult { indexPath: string; + manifestPath: string; projectCount: number; generatedAt: string; + theme: string; + screenshotsCopied: number; } export function publishSite(config: PortfolioConfig): PublishResult { @@ -270,6 +308,8 @@ export function publishSite(config: PortfolioConfig): PublishResult { const visibleProjects = projects.length === 0 ? '
Portfolio / 00Empty

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

' : ""; + const theme = resolveTheme(config.theme); + const themeLabel = `Theme: ${escapeHtml(theme.name)}`; const html = ` @@ -277,15 +317,16 @@ export function publishSite(config: PortfolioConfig): PublishResult { + ${escapeHtml(config.title)} / Portfolio - +
@@ -307,7 +348,7 @@ export function publishSite(config: PortfolioConfig): PublishResult {
Auditable snapshot. Sourced from public repository metadata, commits, and releases. Source: auditable SQLite data. ${languages.length ? `Languages: ${languages.map(escapeHtml).join(", ")}.` : "No language data was provided."}
-
Generated ${escapeHtml(generatedAt.slice(0, 19).replace("T", " "))} UTC
Emails redacted / hidden projects excluded
+
Generated ${escapeHtml(generatedAt.slice(0, 19).replace("T", " "))} UTC
Emails redacted / hidden projects excluded
${themeLabel}

Portfolio / 02

Project index

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

@@ -344,14 +385,41 @@ export function publishSite(config: PortfolioConfig): PublishResult { writeFileSync(join(config.outputDir, `${view.project.slug}-changelog.md`), markdown, "utf-8"); } + const screenshotsCopied = copyScreenshots(config); + const manifestPath = join(config.outputDir, "site-manifest.json"); + const publishedFiles = listFiles(config.outputDir); + const manifest: SiteManifest = { + formatVersion: 1, + generatedAt, + title: config.title, + owner: config.owner, + theme: theme.name, + projectCount: projects.length, + projects: views.map((view) => ({ + slug: view.project.slug, + name: view.project.name, + url: view.project.url, + language: view.project.language, + stars: view.project.stars, + commits: view.evidence.commits, + releases: view.evidence.releases, + visible: true, + hasScreenshot: view.project.screenshot_path !== null, + changelogFile: view.changelog.length > 0 ? `${view.project.slug}-changelog.md` : null, + })), + files: [...publishedFiles, "site-manifest.json"].sort(), + screenshots: publishedFiles.filter((file) => file.startsWith("assets/screenshots/")), + }; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); + db.logIngest("publish", `${projects.length} projects -> ${indexPath}`); - return { indexPath, projectCount: projects.length, generatedAt }; + return { indexPath, manifestPath, projectCount: projects.length, generatedAt, theme: theme.name, screenshotsCopied }; } finally { db.close(); } } -export function copyScreenshotsToOutput(config: PortfolioConfig): number { +function copyScreenshots(config: PortfolioConfig): number { const assetsDir = join(config.outputDir, "assets", "screenshots"); mkdirSync(assetsDir, { recursive: true }); @@ -367,4 +435,8 @@ export function copyScreenshotsToOutput(config: PortfolioConfig): number { db.close(); } return copied; +} + +export function copyScreenshotsToOutput(config: PortfolioConfig): number { + return copyScreenshots(config); } \ No newline at end of file diff --git a/src/refresh/run.ts b/src/refresh/run.ts index acc9e53..e21a7c5 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 { publishSite, type PublishResult } from "../publish/site.js"; +import { deployAll } from "../deploy/local.js"; import type { FixtureData } from "../ingest/orchestrator.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "../types.js"; @@ -14,6 +15,7 @@ export interface RefreshResult { captured: number; copiedScreenshots: number; captureErrors: Array<{ slug: string; message: string }>; + deployed: string[]; published: PublishResult; } @@ -36,12 +38,14 @@ export async function refreshPortfolio( (slug, error) => captureErrors.push({ slug, message: error.message }) ); const published = publishSite(config); + const deployed = deployAll(config).map((result) => result.targetName); return { ingested: ingested.length, captured: captured.length, - copiedScreenshots: copyScreenshotsToOutput(config), + copiedScreenshots: published.screenshotsCopied, captureErrors, + deployed, published, }; } diff --git a/src/theme/palette.ts b/src/theme/palette.ts new file mode 100644 index 0000000..4b5719f --- /dev/null +++ b/src/theme/palette.ts @@ -0,0 +1,236 @@ +import { DEFAULT_THEME, type ThemeConfig } from "../types.js"; + +export type ThemeMode = "dark" | "light"; + +export interface ThemeTokens { + name: string; + mode: ThemeMode; + ink: string; + inkSoft: string; + panel: string; + panelStrong: string; + line: string; + lineSoft: string; + text: string; + muted: string; + blue: string; + blueSoft: string; + buttonText: string; + mint: string; + orange: string; + shadow: string; + visual: string; + glow: string; + panelGradient: string; + asideGradient: string; + labelBg: string; + labelBorder: string; + auditBg: string; + codeBg: string; + tagBg: string; + tagBorder: string; + stripe: string; + radius: string; + font: string; +} + +interface BuiltinPalette extends Omit { + radius: string; + font: string; +} + +const SHARED_FONT = + 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + +const BUILTIN_PALETTES: Record = { + "deep-space": { + mode: "dark", + ink: "#08111f", + inkSoft: "#0d1a2d", + panel: "#112139", + panelStrong: "#172a46", + line: "rgba(169, 195, 222, 0.18)", + lineSoft: "rgba(169, 195, 222, 0.1)", + text: "#f3f7fb", + muted: "#9db0c7", + blue: "#67b7ff", + blueSoft: "#b8dcff", + buttonText: "#08111f", + mint: "#a7f3d0", + orange: "#ffb86b", + shadow: "0 24px 60px rgba(0, 0, 0, 0.24)", + visual: "#0a1525", + glow: "radial-gradient(circle at 82% -10%, rgba(70, 148, 232, 0.2), transparent 34rem)", + panelGradient: "linear-gradient(135deg, rgba(23, 42, 70, 0.98), rgba(13, 26, 45, 0.96))", + asideGradient: "linear-gradient(145deg, rgba(23, 42, 70, 0.92), rgba(13, 26, 45, 0.72))", + labelBg: "rgba(8, 17, 31, 0.72)", + labelBorder: "rgba(255, 255, 255, 0.18)", + auditBg: "rgba(17, 33, 57, 0.66)", + codeBg: "rgba(167, 243, 208, 0.08)", + tagBg: "rgba(167, 243, 208, 0.06)", + tagBorder: "rgba(167, 243, 208, 0.26)", + stripe: "rgba(103, 183, 255, 0.05)", + radius: "16px", + font: SHARED_FONT, + }, + paper: { + mode: "light", + ink: "#f6f8fb", + inkSoft: "#eef1f6", + panel: "#ffffff", + panelStrong: "#e9eef5", + line: "rgba(15, 23, 42, 0.14)", + lineSoft: "rgba(15, 23, 42, 0.08)", + text: "#0f172a", + muted: "#5b6b82", + blue: "#0f6bbd", + blueSoft: "#1d4ed8", + buttonText: "#ffffff", + mint: "#0f766e", + orange: "#b45309", + shadow: "0 24px 60px rgba(15, 23, 42, 0.14)", + visual: "#e3e9f2", + glow: "radial-gradient(circle at 82% -10%, rgba(15, 107, 189, 0.14), transparent 34rem)", + panelGradient: "linear-gradient(135deg, #ffffff, #eef2f8)", + asideGradient: "linear-gradient(145deg, #ffffff, #edf1f6)", + labelBg: "rgba(255, 255, 255, 0.8)", + labelBorder: "rgba(15, 23, 42, 0.12)", + auditBg: "rgba(255, 255, 255, 0.72)", + codeBg: "rgba(15, 118, 110, 0.08)", + tagBg: "rgba(15, 118, 110, 0.06)", + tagBorder: "rgba(15, 118, 110, 0.24)", + stripe: "rgba(15, 107, 189, 0.08)", + radius: "16px", + font: SHARED_FONT, + }, +}; + +const BUILTIN_DESCRIPTIONS: Record = { + "deep-space": "Dark palette with blue accents. Default.", + paper: "Light palette with dark text and strong contrast.", +}; + +export interface ThemeCatalogEntry { + name: string; + description: string; +} + +export function listBuiltinThemes(): ThemeCatalogEntry[] { + return Object.keys(BUILTIN_PALETTES).map((name) => ({ + name, + description: BUILTIN_DESCRIPTIONS[name] ?? "Built-in theme.", + })); +} + +export function isBuiltinTheme(name: string): boolean { + return name in BUILTIN_PALETTES; +} + +const HEX_COLOR_RE = /^#?[0-9a-f]{3}([0-9a-f]{3})?$/i; + +export function isValidHexColor(value: string): boolean { + return HEX_COLOR_RE.test(value.trim()); +} + +function normalizeHexColor(value: string): string { + let hex = value.trim().replace(/^#/, "").toLowerCase(); + if (hex.length === 3) { + hex = hex + .split("") + .map((channel) => channel + channel) + .join(""); + } + return `#${hex}`; +} + +function hexToRgb(hex: string): [number, number, number] | null { + const normalized = hex.replace(/^#/, ""); + const match = /^[0-9a-f]{6}$/i.exec(normalized); + if (!match) return null; + const value = Number.parseInt(normalized, 16); + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; +} + +function rgbToHex(rgb: [number, number, number]): string { + return `#${rgb.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +function mixHex(source: string, target: string, amount: number): string { + const from = hexToRgb(source) ?? [0, 0, 0]; + const to = hexToRgb(target) ?? [255, 255, 255]; + const mixed = from.map((channel, index) => + Math.round(channel + (to[index] - channel) * amount) + ) as [number, number, number]; + return rgbToHex(mixed); +} + +function rgbaFromHex(hex: string, alpha: number): string { + const [red, green, blue] = hexToRgb(hex) ?? [0, 0, 0]; + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +function glowFromAccent(accent: string, mode: ThemeMode): string { + const alpha = mode === "dark" ? 0.2 : 0.14; + return `radial-gradient(circle at 82% -10%, ${rgbaFromHex(accent, alpha)}, transparent 34rem)`; +} + +function stripeFromAccent(accent: string, mode: ThemeMode): string { + const alpha = mode === "dark" ? 0.05 : 0.08; + return rgbaFromHex(accent, alpha); +} + +export function resolveTheme(config?: ThemeConfig): ThemeTokens { + const requestedName = config?.name ?? DEFAULT_THEME.name; + const baseName = isBuiltinTheme(requestedName) ? requestedName : DEFAULT_THEME.name; + const base = BUILTIN_PALETTES[baseName]; + const tokens: ThemeTokens = { + ...base, + name: baseName, + radius: base.radius, + font: base.font, + }; + + if (config?.accent) { + tokens.blue = normalizeHexColor(config.accent); + tokens.blueSoft = mixHex(tokens.blue, base.mode === "dark" ? "#ffffff" : "#000000", 0.5); + tokens.glow = glowFromAccent(tokens.blue, base.mode); + tokens.stripe = stripeFromAccent(tokens.blue, base.mode); + } + if (config?.radius) tokens.radius = config.radius; + if (config?.font) tokens.font = config.font; + + return tokens; +} + +export function themeVariables(tokens: ThemeTokens): string { + return `:root { + color-scheme: ${tokens.mode}; + --ink: ${tokens.ink}; + --ink-soft: ${tokens.inkSoft}; + --panel: ${tokens.panel}; + --panel-strong: ${tokens.panelStrong}; + --line: ${tokens.line}; + --line-soft: ${tokens.lineSoft}; + --text: ${tokens.text}; + --muted: ${tokens.muted}; + --blue: ${tokens.blue}; + --blue-soft: ${tokens.blueSoft}; + --button-text: ${tokens.buttonText}; + --mint: ${tokens.mint}; + --orange: ${tokens.orange}; + --shadow: ${tokens.shadow}; + --visual: ${tokens.visual}; + --glow: ${tokens.glow}; + --panel-gradient: ${tokens.panelGradient}; + --aside-gradient: ${tokens.asideGradient}; + --label-bg: ${tokens.labelBg}; + --label-border: ${tokens.labelBorder}; + --audit-bg: ${tokens.auditBg}; + --code-bg: ${tokens.codeBg}; + --tag-bg: ${tokens.tagBg}; + --tag-border: ${tokens.tagBorder}; + --stripe: ${tokens.stripe}; + --radius: ${tokens.radius}; + --font: ${tokens.font}; +}`; +} diff --git a/src/types.ts b/src/types.ts index 92c30da..0a7d2cb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,23 @@ export interface PrivacyConfig { maxCommitsPerProject: number; } +export interface ThemeConfig { + name: string; + accent?: string; + radius?: string; + font?: string; +} + +export interface DeployTarget { + name: string; + type: "local"; + target: string; +} + +export interface DeployConfig { + targets: DeployTarget[]; +} + export interface PortfolioConfig { owner: string; title: string; @@ -85,6 +102,8 @@ export interface PortfolioConfig { repositoryLimit?: number; dataDir: string; outputDir: string; + theme: ThemeConfig; + deploy: DeployConfig; privacy: PrivacyConfig; clock: () => string; } @@ -95,6 +114,14 @@ export const DEFAULT_PRIVACY: PrivacyConfig = { maxCommitsPerProject: 50, }; +export const DEFAULT_THEME: ThemeConfig = { + name: "deep-space", +}; + +export const DEFAULT_DEPLOY: DeployConfig = { + targets: [], +}; + export const DEFAULT_CONFIG = { owner: "demo-engineer", title: "EngineerProfile", @@ -102,6 +129,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..a3b7c0a 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -37,4 +37,60 @@ describe("portfolio configuration", () => { 'Configuration field "repositoryLimit" must be an integer from 1 to 100.' ); }); + + it("loads a named theme with overrides", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + theme: { name: "paper", accent: "#123456", radius: "18px", font: "Georgia, serif" }, + })); + + const config = loadPortfolioConfig(TEST_FILE); + expect(config.theme).toEqual({ + name: "paper", + accent: "#123456", + radius: "18px", + font: "Georgia, serif", + }); + }); + + it("rejects an unknown theme name", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: { name: "mystery" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme.name" must be one of' + ); + }); + + it("rejects an invalid accent color", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: { name: "deep-space", accent: "red" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme.accent" must be a hex color' + ); + }); + + it("loads local deploy targets", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + deploy: { targets: [{ name: "preview", type: "local", target: "public" }] }, + })); + + const config = loadPortfolioConfig(TEST_FILE); + expect(config.deploy.targets).toEqual([ + { name: "preview", type: "local", target: "public" }, + ]); + }); + + it("rejects an unsupported deploy target type", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + deploy: { targets: [{ name: "pages", type: "github-pages", target: "public" }] }, + })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "deploy.targets[0].type" must be "local".' + ); + }); }); diff --git a/tests/demo.test.ts b/tests/demo.test.ts index 4329839..df2d423 100644 --- a/tests/demo.test.ts +++ b/tests/demo.test.ts @@ -45,6 +45,7 @@ describe("demo pipeline", () => { const html = readFileSync(published.indexPath, "utf-8"); expect(html).toContain("signal-router"); expect(html).toContain("metrics-kit"); + expect(existsSync(published.manifestPath)).toBe(true); expect(existsSync(join(TEST_OUTPUT, "demo-engineer-signal-router-changelog.md"))).toBe(true); expect(existsSync(join(TEST_OUTPUT, "assets", "screenshots", "demo-engineer-signal-router.png"))).toBe(true); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts new file mode 100644 index 0000000..c87d317 --- /dev/null +++ b/tests/deploy.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { ingestOwnerRepos } from "../src/ingest/orchestrator.js"; +import { publishSite } from "../src/publish/site.js"; +import { deployAll, deployLocal } from "../src/deploy/local.js"; +import { openDatabase } from "../src/db/client.js"; +import { loadAllFixtures } from "../src/fixtures/loader.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = "data/test-deploy"; +const TEST_OUTPUT = "output/test-deploy"; +const TEST_TARGET = "output/test-deploy-target"; + +afterEach(() => { + for (const path of [TEST_DATA, TEST_OUTPUT, TEST_TARGET]) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("local deploy adapter", () => { + it("copies the published site to a target directory", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deploy: { targets: [{ name: "preview", type: "local" as const, target: TEST_TARGET }] }, + }; + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + publishSite(config); + + const results = deployAll(config); + + expect(results).toHaveLength(1); + expect(results[0].targetName).toBe("preview"); + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + expect(existsSync(join(TEST_TARGET, "site-manifest.json"))).toBe(true); + expect(results[0].files).toBeGreaterThan(0); + }); + + it("records the deploy in the audit log", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deploy: { targets: [{ name: "preview", type: "local" as const, target: TEST_TARGET }] }, + }; + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + publishSite(config); + deployAll(config); + + const database = openDatabase(TEST_DATA, config.clock); + const log = database.getIngestLog(10); + database.close(); + expect(log.some((entry) => entry.action === "deploy")).toBe(true); + }); + + it("throws when no published site exists", () => { + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; + expect(() => deployLocal(config, "preview", TEST_TARGET)).toThrow(/Run publish first/); + }); + + it("keeps the deployed manifest readable", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deploy: { targets: [{ name: "preview", type: "local" as const, target: TEST_TARGET }] }, + }; + await ingestOwnerRepos(config, "demo-engineer", 2, loadAllFixtures()); + publishSite(config); + deployAll(config); + + const manifest = JSON.parse(readFileSync(join(TEST_TARGET, "site-manifest.json"), "utf-8")) as { + projectCount: number; + files: string[]; + }; + expect(manifest.projectCount).toBe(2); + expect(manifest.files).toContain("index.html"); + expect(manifest.files).toContain("site-manifest.json"); + }); +}); diff --git a/tests/deterministic.test.ts b/tests/deterministic.test.ts index 78fc6ea..411c115 100644 --- a/tests/deterministic.test.ts +++ b/tests/deterministic.test.ts @@ -46,4 +46,20 @@ describe("deterministic publishing", () => { expect(html).toContain("https://github.com/demo-engineer/signal-router/releases/tag/v0.3.0"); expect(existsSync(join(OUTPUTS[0], "demo-engineer-signal-router-changelog.md"))).toBe(true); }); + + it("produces the same manifest for the same fixture snapshot", async () => { + const fixtures = loadAllFixtures(); + const manifests: string[] = []; + for (let index = 0; index < RUNS.length; index++) { + const config = fixedConfig(RUNS[index], OUTPUTS[index]); + await ingestOwnerRepos(config, config.owner, fixtures.length, fixtures); + const result = publishSite(config); + manifests.push(readFileSync(result.manifestPath, "utf-8")); + } + + expect(manifests[0]).toBe(manifests[1]); + const parsed = JSON.parse(manifests[0]) as { formatVersion: number; projectCount: number }; + expect(parsed.formatVersion).toBe(1); + expect(parsed.projectCount).toBe(2); + }); }); \ No newline at end of file diff --git a/tests/site.test.ts b/tests/site.test.ts index 03061cf..f60a39b 100644 --- a/tests/site.test.ts +++ b/tests/site.test.ts @@ -3,7 +3,7 @@ import { rmSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { openDatabase } from "../src/db/client.js"; import { ingestRepository } from "../src/ingest/orchestrator.js"; -import { publishSite, copyScreenshotsToOutput } from "../src/publish/site.js"; +import { publishSite } from "../src/publish/site.js"; import { loadFixtureRepo, loadFixtureCommits, loadFixtureReleases } from "../src/fixtures/loader.js"; import { DEFAULT_CONFIG } from "../src/types.js"; @@ -127,4 +127,60 @@ describe("database and publish pipeline", () => { const md = readFileSync(changelogPath, "utf-8"); expect(md).toContain("v0.3.0"); }); + + it("publishes a deployment manifest with theme and project records", 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(existsSync(result.manifestPath)).toBe(true); + + const manifest = JSON.parse(readFileSync(result.manifestPath, "utf-8")) as { + formatVersion: number; + theme: string; + projectCount: number; + projects: Array<{ slug: string; stars: number; hasScreenshot: boolean }>; + files: string[]; + }; + expect(manifest.formatVersion).toBe(1); + expect(manifest.theme).toBe("deep-space"); + expect(manifest.projectCount).toBe(1); + expect(manifest.projects[0].slug).toBe("demo-engineer-signal-router"); + expect(manifest.projects[0].stars).toBe(42); + expect(manifest.files).toContain("index.html"); + expect(manifest.files).toContain("site-manifest.json"); + }); + + it("renders the configured theme name and variables", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + theme: { name: "paper", accent: "#0f6bbd" }, + }; + await ingestRepository( + config, + { owner: "demo-engineer", repo: "signal-router" }, + { + repo: loadFixtureRepo("signal-router"), + commits: loadFixtureCommits("signal-router"), + releases: loadFixtureReleases("signal-router"), + } + ); + + const result = publishSite(config); + const html = readFileSync(result.indexPath, "utf-8"); + + expect(html).toContain("color-scheme: light"); + expect(html).toContain("--blue: #0f6bbd"); + expect(html).toContain("Theme: paper"); + }); }); diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..5147ed2 --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + isValidHexColor, + listBuiltinThemes, + resolveTheme, + themeVariables, +} from "../src/theme/palette.js"; + +describe("theme resolution", () => { + it("uses deep-space by default", () => { + const theme = resolveTheme(); + expect(theme.name).toBe("deep-space"); + expect(theme.mode).toBe("dark"); + }); + + it("resolves a named built-in theme", () => { + const theme = resolveTheme({ name: "paper" }); + expect(theme.name).toBe("paper"); + expect(theme.mode).toBe("light"); + expect(theme.ink).toBe("#f6f8fb"); + }); + + it("falls back to the default for an unknown theme name", () => { + const theme = resolveTheme({ name: "not-a-theme" }); + expect(theme.name).toBe("deep-space"); + }); + + it("applies an accent override and derives soft variants", () => { + const theme = resolveTheme({ name: "deep-space", accent: "#ff0000" }); + expect(theme.blue).toBe("#ff0000"); + expect(theme.blueSoft).toMatch(/^#[0-9a-f]{6}$/); + expect(theme.glow).toContain("rgba(255, 0, 0,"); + expect(theme.stripe).toContain("rgba(255, 0, 0,"); + }); + + it("keeps the base palette when no accent is provided", () => { + const theme = resolveTheme({ name: "deep-space" }); + expect(theme.blue).toBe("#67b7ff"); + expect(theme.blueSoft).toBe("#b8dcff"); + }); + + it("applies radius and font overrides", () => { + const theme = resolveTheme({ + name: "deep-space", + radius: "20px", + font: "Georgia, serif", + }); + expect(theme.radius).toBe("20px"); + expect(theme.font).toBe("Georgia, serif"); + }); +}); + +describe("theme catalog", () => { + it("lists every built-in theme with a name", () => { + const names = listBuiltinThemes().map((theme) => theme.name); + expect(names).toContain("deep-space"); + expect(names).toContain("paper"); + for (const theme of listBuiltinThemes()) { + expect(theme.description.length).toBeGreaterThan(0); + } + }); +}); + +describe("color validation", () => { + it("accepts 3-digit and 6-digit hex colors", () => { + expect(isValidHexColor("#abc")).toBe(true); + expect(isValidHexColor("#aabbcc")).toBe(true); + expect(isValidHexColor("aabbcc")).toBe(true); + expect(isValidHexColor("#67b7ff")).toBe(true); + }); + + it("rejects non-hex values", () => { + expect(isValidHexColor("red")).toBe(false); + expect(isValidHexColor("#12")).toBe(false); + expect(isValidHexColor("#gggfff")).toBe(false); + }); +}); + +describe("theme variables", () => { + it("emits a CSS custom property block for light themes", () => { + const css = themeVariables(resolveTheme({ name: "paper" })); + expect(css).toContain("color-scheme: light"); + expect(css).toContain("--ink: #f6f8fb"); + expect(css).toContain("--font:"); + }); + + it("keeps dark mode and accent tokens", () => { + const css = themeVariables(resolveTheme({ name: "deep-space", accent: "#0f6bbd" })); + expect(css).toContain("color-scheme: dark"); + expect(css).toContain("--blue: #0f6bbd"); + }); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs index b699bb5..f6b8510 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -4,5 +4,11 @@ export default { environment: "node", include: ["tests/**/*.test.ts"], testTimeout: 30000, + pool: "forks", + poolOptions: { + forks: { + singleFork: true, + }, + }, }, }; \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 66e7fb6..6ef0fc1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,11 @@ export default defineConfig({ environment: "node", include: ["tests/**/*.test.ts"], testTimeout: 30000, + pool: "forks", + poolOptions: { + forks: { + singleFork: true, + }, + }, }, });