diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae27d9..534904c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,20 @@ jobs: - name: Run fixture demo run: node dist/index.js demo + - name: Verify theme catalog + run: node dist/index.js themes + + - name: Verify site manifest + run: | + node -e " + const m = require('./output/site-manifest.json'); + if (m.formatVersion !== 1) process.exit(1); + if (m.projectCount !== 2) process.exit(1); + if (!m.files.includes('index.html')) process.exit(1); + if (!m.files.includes('site-manifest.json')) process.exit(1); + console.log('Manifest ok: ' + m.projectCount + ' projects, theme ' + m.theme); + " + - name: Store demo output if: success() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 6d63646..c229850 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ coverage/ data/ output/ +/deploy/ *.db *.db-journal *.db-shm diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7802f1c..feef8b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,13 @@ npm test Use fixtures for changes that need repeatable data. Do not add credentials, private repository data, or generated output. +## Feature areas + +The theme catalog lives in `src/theme/`. +Add a palette, then register its description. +The deploy adapters live in `src/deploy/`. +Keep the publish, capture, and deploy stages separated. + ## Pull requests Explain the user value and the data path. diff --git a/README.md b/README.md index f1a2a81..4e53623 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ 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. +Themes and deploy targets control the presentation. ## Value @@ -10,6 +11,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. +- Choose a built-in theme or tune the accent color. +- Copy the published site to local deploy targets. - Hide projects and redact author emails before publication. - Run one configured refresh from a scheduled workflow. @@ -30,20 +33,28 @@ flowchart LR L --> W[Publisher] P --> W S --> W + T[Theme] --> W W --> O[Static output] + W --> M[Site manifest] + O --> A[Deploy] + C --> T + C --> A + A --> Y[Local targets] ``` | Area | Responsibility | | --- | --- | -| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, and privacy settings. | +| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, theme, deploy, and privacy settings. | | `src/config/` | Validate checked-in JSON and merge safe defaults. | -| `src/refresh/` | Coordinate ingest, best-effort capture, and static publishing. | +| `src/refresh/` | Coordinate ingest, best-effort capture, static publishing, and deploy. | | `src/ingest/` | Fetch public GitHub data and map it to records. | | `src/db/` | Store projects, commits, changelogs, and audit events. | | `src/changelog/` | Prefer release notes and fall back to commit groups. | | `src/privacy/` | Hide projects and block sensitive commit messages. | | `src/preview/` | Capture fixed viewport screenshots with Playwright. | -| `src/publish/` | Render HTML, changelog files, and preview assets. | +| `src/theme/` | Resolve built-in themes and emit CSS variables. | +| `src/publish/` | Render HTML, changelog files, preview assets, and the site manifest. | +| `src/deploy/` | Copy the published snapshot to configured local targets. | | `fixtures/` | Provide deterministic demo data and local preview pages. | The refresh command runs each stage in a fixed order. @@ -68,7 +79,8 @@ 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, presentation, repository limit, paths, theme, deploy +targets, and privacy controls. The loader accepts repository limits from 1 through 100. It rejects malformed values before network access. @@ -81,7 +93,7 @@ npm run refresh ``` The refresh command reads public repositories, captures previews, publishes HTML, -and reports skipped captures. +copies the site to deploy targets, and reports skipped captures. GitHub ingestion uses the public API. Set `GITHUB_TOKEN` for a higher rate limit. @@ -94,6 +106,50 @@ npm run refresh Do not put a token in repository files. Use `.env.example` as a variable reference. +## Themes + +Choose a theme with the `theme.name` field. +Built-in themes are `deep-space`, `paper`, and `terminal`. +The `deep-space` theme is the default. +Override the accent color, corner radius, or font for any theme. + +```json +{ + "theme": { + "name": "terminal", + "accent": "#39d353" + } +} +``` + +Run `npm run themes` to list the catalog. + +## Deploy targets + +Deploy targets copy the published snapshot to local folders. +Use the `deploy.targets` list in the configuration. +Each target needs a name, a type, and a target path. +The `local` type copies the output directory. +A target must stay outside the output directory. +The publisher rejects unsafe target paths. + +```json +{ + "deploy": { + "targets": [ + { + "name": "public", + "type": "local", + "target": "deploy/site" + } + ] + } +} +``` + +Run `npm run deploy` to publish and copy the site. +Deploy runs automatically at the end of a refresh. + ## Sample output The fixture set contains `signal-router` and `metrics-kit`. @@ -106,11 +162,13 @@ Captured demo-engineer-signal-router. Captured demo-engineer-metrics-kit. Published 2 projects to output/index.html. Copied 2 available preview screenshots. +Deployed public: 6 files to deploy/site. 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. +The manifest lists the published files and the active theme. ## Commands @@ -119,11 +177,13 @@ Build before direct CLI commands. | Command | Result | | --- | --- | | `npm run demo` | Run the complete fixture pipeline. | +| `npm run themes` | List built-in presentation themes. | | `npm run ingest -- octocat --limit 3` | Load public repository evidence. | | `npm run ingest -- --fixture` | Load fixture records only. | | `npm run capture -- --fixture` | Capture local fixture pages. | | `npm run publish` | Rebuild the site from SQLite. | -| `npm run refresh` | Run configured ingest, capture, and publish stages. | +| `npm run deploy` | Publish the site and copy it to targets. | +| `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. | @@ -144,6 +204,13 @@ Hidden projects stay out of public HTML and copied assets. Author emails are redacted by default. Sensitive commit messages are skipped before storage. +## Site manifest + +`publish` writes `site-manifest.json` to the output directory. +The manifest records the theme, the project count, and every published file. +It lists each project with its changelog file. +Scripts can use the manifest to verify a build. + ## Audit model Each project stores a repository URL and its last pushed timestamp. @@ -153,11 +220,12 @@ 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 ingest, capture, publish, and deploy operations in an audit table. ## CI and test status The regular CI workflow runs typecheck, build, tests, the fixture demo, and artifact upload. +It verifies the theme catalog and the site manifest. The scheduled refresh workflow runs each Monday and supports manual dispatch. It uploads the generated site as a workflow artifact. @@ -168,7 +236,10 @@ The test suite covers these core behaviors: - Release-first changelog generation. - SQLite upserts and changelog replacement. - Privacy filtering and email redaction. +- Theme resolution and CSS variable output. +- Local deploy adapter safety checks. - Fixture ingestion and static publishing. +- Site manifest accuracy. - Configured refresh orchestration. - Release source links. - Deterministic HTML output. @@ -196,6 +267,7 @@ 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. +- Deploy adapters copy local folders. They do not upload to remote hosts. - Publishing creates local files. It does not deploy them. - Scheduled runs upload artifacts. They do not commit generated output. @@ -205,8 +277,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, local deploy adapters, and the site manifest. | +| 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..b08ee84 100644 --- a/engineer-profile.config.json +++ b/engineer-profile.config.json @@ -5,6 +5,18 @@ "repositoryLimit": 5, "dataDir": "data", "outputDir": "output", + "theme": { + "name": "deep-space" + }, + "deploy": { + "targets": [ + { + "name": "public", + "type": "local", + "target": "deploy/site" + } + ] + }, "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..637c970 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "description": "Self-maintaining engineering portfolio from repository activity", "type": "module", "main": "dist/index.js", @@ -18,7 +18,9 @@ "ingest": "npm run build && node dist/index.js ingest", "publish": "npm run build && node dist/index.js publish", "capture": "npm run build && node dist/index.js capture", - "refresh": "npm run build && node dist/index.js refresh" + "refresh": "npm run build && node dist/index.js refresh", + "deploy": "npm run build && node dist/index.js deploy", + "themes": "npm run build && node dist/index.js themes" }, "engines": { "node": ">=20" diff --git a/src/config/loader.ts b/src/config/loader.ts index 406896b..d0ad852 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,7 +1,14 @@ 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 +63,83 @@ 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)) { + const names = listBuiltinThemes().map((theme) => theme.name).join(", "); + throw new Error(`Configuration field "theme.name" must be one of: ${names}.`); + } + 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, type, target: targetPath } = 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 +162,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/index.ts b/src/deploy/index.ts new file mode 100644 index 0000000..ed53b6f --- /dev/null +++ b/src/deploy/index.ts @@ -0,0 +1,22 @@ +import type { DeployTarget, PortfolioConfig } from "../types.js"; +import { deployLocal, type DeployResult } from "./local.js"; + +export type { DeployResult } from "./local.js"; + +export function deployToTarget( + config: PortfolioConfig, + target: DeployTarget +): DeployResult { + switch (target.type) { + case "local": + return deployLocal(config, target); + default: { + const exhaustive: never = target.type; + throw new Error(`Unknown deploy adapter "${exhaustive}".`); + } + } +} + +export function deployAll(config: PortfolioConfig): DeployResult[] { + return config.deploy.targets.map((target) => deployToTarget(config, target)); +} diff --git a/src/deploy/local.ts b/src/deploy/local.ts new file mode 100644 index 0000000..2c38dea --- /dev/null +++ b/src/deploy/local.ts @@ -0,0 +1,61 @@ +import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { openDatabase } from "../db/client.js"; +import type { DeployTarget, PortfolioConfig } from "../types.js"; + +export interface DeployResult { + targetName: string; + targetPath: string; + files: number; +} + +export function isPathInside(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +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, + target: DeployTarget +): DeployResult { + const indexPath = join(config.outputDir, "index.html"); + if (!existsSync(indexPath)) { + throw new Error(`No published site found in "${config.outputDir}". Run publish first.`); + } + + const outputRoot = resolve(config.outputDir); + const targetRoot = resolve(target.target); + if (isPathInside(outputRoot, targetRoot)) { + throw new Error( + `Deploy target "${target.name}" must be outside the output directory "${config.outputDir}".` + ); + } + + mkdirSync(target.target, { recursive: true }); + cpSync(config.outputDir, target.target, { recursive: true }); + const files = countFiles(target.target); + + const db = openDatabase(config.dataDir, config.clock); + try { + db.logIngest("deploy", `${target.name} -> ${target.target}`); + } finally { + db.close(); + } + + return { targetName: target.name, targetPath: target.target, files }; +} diff --git a/src/index.ts b/src/index.ts index aea5c27..71fe942 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,15 @@ 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/index.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 @@ -67,6 +69,9 @@ addConfigOption(program const copied = copyScreenshotsToOutput(config); console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); console.log(`Copied ${copied} available preview screenshots.`); + for (const deployed of deployAll(config)) { + console.log(`Deployed ${deployed.targetName}: ${deployed.files} files to ${deployed.targetPath}.`); + } console.log("Open output/index.html in a browser."); })); @@ -135,7 +140,7 @@ addConfigOption(program addConfigOption(program .command("refresh") - .description("Ingest, capture, and publish from the checked-in configuration") + .description("Ingest, capture, publish, and deploy from the checked-in configuration") .option("-d, --data ", "Data directory") .option("-o, --output ", "Output directory") .action(async (options) => { @@ -149,6 +154,41 @@ addConfigOption(program for (const error of result.captureErrors) { console.warn(`Skipped ${error.slug}: ${error.message}`); } + for (const deployed of result.deployed) { + 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("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); + mkdirSync(config.dataDir, { recursive: true }); + const result = publishSite(config); + const copied = copyScreenshotsToOutput(config); + console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); + console.log(`Copied ${copied} available preview screenshots.`); + const deployed = deployAll(config); + if (deployed.length === 0) { + console.log('No deploy targets configured. Add a "deploy.targets" entry to the configuration.'); + } + for (const item of deployed) { + console.log(`Deployed ${item.targetName}: ${item.files} files to ${item.targetPath}.`); + } })); 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..d7db10e 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 { openDatabase, type PortfolioDatabase } 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 { @@ -124,33 +125,18 @@ 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; } +body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); line-height: 1.5; font-family: var(--font); } 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-soft); } .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; } +.brand-mark { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--blue); color: var(--blue); border-radius: var(--radius); font-size: 0.72rem; } .nav-link { color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; } .nav-link:hover, .text-link:hover, h2 a:hover { color: var(--blue); } .hero { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr); gap: 80px; align-items: end; padding: 86px 0 70px; } @@ -158,7 +144,7 @@ a { color: inherit; } .kicker { color: var(--mint); margin: 0 0 22px; } h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-weight: 650; letter-spacing: -0.08em; line-height: 0.94; } .hero-copy { max-width: 530px; margin: 28px 0 0; color: var(--blue-soft); font-size: 1.12rem; } -.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(23, 42, 70, 0.92), rgba(13, 26, 45, 0.72)); box-shadow: var(--shadow); } +.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: var(--radius); 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 +152,19 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we .stat:last-child { border-right: 0; } .stat strong { display: block; color: var(--text); font-size: 1.8rem; font-weight: 600; letter-spacing: -0.04em; } .stat span { color: var(--muted); font: 0.7rem/1.3 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; } -.audit-panel { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; margin: 26px 0 80px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 10px; background: rgba(17, 33, 57, 0.66); } +.audit-panel { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; margin: 26px 0 80px; padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius); 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: calc(var(--radius) / 2); 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,8 +173,8 @@ 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; } -.change-log { padding: 18px 0 20px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.tag { padding: 5px 9px; border: 1px solid var(--tag-border); border-radius: 999px; color: var(--mint); background: var(--tag-bg); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; } +.change-log { padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel-strong); } .section-heading { display: flex; justify-content: space-between; color: var(--orange); } .source-badge { color: var(--muted); } .change-log h3 { margin: 12px 0 2px; font-size: 1.08rem; font-weight: 550; } @@ -197,20 +183,20 @@ 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 { display: inline-block; padding: 10px 14px; border-radius: var(--radius); 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(--button-text); } .button.primary:hover { background: var(--blue-soft); } .button.secondary { border: 1px solid var(--line); color: var(--text); } .button.secondary:hover { border-color: var(--blue); color: var(--blue); } .muted { color: var(--muted); } -.source-trail { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 36px; margin: 80px 0; padding: 26px 0; border-top: 1px solid var(--line); } +.source-trail { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 36px; margin: 80px 0; padding: 26px 28px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); } .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 +225,60 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we `; } +export interface SiteManifestProject { + slug: string; + name: string; + url: string; + changelogFile: string | null; +} + +export interface SiteManifest { + formatVersion: 1; + generatedAt: string; + title: string; + owner: string; + theme: string; + projectCount: number; + projects: SiteManifestProject[]; + files: string[]; + screenshots: string[]; +} + export interface PublishResult { indexPath: string; + manifestPath: string; projectCount: number; generatedAt: string; + theme: string; +} + +function relativePosixPaths(outputDir: string): string[] { + const paths: 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 { + paths.push(relative); + } + } + }; + walk(outputDir, ""); + return paths.sort(); +} + +function copyScreenshotAssets(config: PortfolioConfig, db: PortfolioDatabase): number { + const assetsDir = join(config.outputDir, "assets", "screenshots"); + mkdirSync(assetsDir, { recursive: true }); + + let available = 0; + for (const project of db.listProjects(true)) { + if (!project.screenshot_path || !existsSync(project.screenshot_path)) continue; + cpSync(project.screenshot_path, join(assetsDir, `${project.slug}.png`)); + available++; + } + return available; } export function publishSite(config: PortfolioConfig): PublishResult { @@ -250,6 +286,7 @@ export function publishSite(config: PortfolioConfig): PublishResult { try { const projects = db.listProjects(true); const generatedAt = config.clock(); + const theme = resolveTheme(config.theme); const views = projects.map((project) => ({ project, changelog: db.getChangelog(project.id), @@ -279,7 +316,7 @@ export function publishSite(config: PortfolioConfig): PublishResult { ${escapeHtml(config.title)} / Portfolio - +
@@ -295,7 +332,7 @@ export function publishSite(config: PortfolioConfig): PublishResult {

${escapeHtml(config.tagline)}. Each project stays connected to its repository, commits, releases, and preview.

@@ -315,7 +352,7 @@ export function publishSite(config: PortfolioConfig): PublishResult { ${cards}
-

Audit / 03

Refresh trail

EngineerProfile records each ingest, capture, and publish operation locally.

+

Audit / 03

Refresh trail

EngineerProfile records each ingest, capture, publish, and deploy operation locally.

    ${auditItems}

${escapeHtml(config.title)} / local publisher

Public output contains visible projects only. No invented metrics.

@@ -344,27 +381,46 @@ export function publishSite(config: PortfolioConfig): PublishResult { writeFileSync(join(config.outputDir, `${view.project.slug}-changelog.md`), markdown, "utf-8"); } + copyScreenshotAssets(config, db); + + const publishedFiles = relativePosixPaths(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, + changelogFile: view.changelog.length > 0 ? `${view.project.slug}-changelog.md` : null, + })), + files: [...new Set([...publishedFiles, "site-manifest.json"])].sort(), + screenshots: publishedFiles.filter((file) => file.startsWith("assets/screenshots/")), + }; + const manifestPath = join(config.outputDir, "site-manifest.json"); + 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, + }; } finally { db.close(); } } export function copyScreenshotsToOutput(config: PortfolioConfig): number { - const assetsDir = join(config.outputDir, "assets", "screenshots"); - mkdirSync(assetsDir, { recursive: true }); - const db = openDatabase(config.dataDir, config.clock); - let copied = 0; try { - for (const project of db.listProjects(true)) { - if (!project.screenshot_path || !existsSync(project.screenshot_path)) continue; - cpSync(project.screenshot_path, join(assetsDir, `${project.slug}.png`)); - copied++; - } + return copyScreenshotAssets(config, db); } finally { db.close(); } - return copied; -} \ No newline at end of file +} diff --git a/src/refresh/run.ts b/src/refresh/run.ts index acc9e53..86274ee 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 { deployAll, type DeployResult } from "../deploy/index.js"; import type { FixtureData } from "../ingest/orchestrator.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "../types.js"; @@ -15,6 +16,7 @@ export interface RefreshResult { copiedScreenshots: number; captureErrors: Array<{ slug: string; message: string }>; published: PublishResult; + deployed: DeployResult[]; } export async function refreshPortfolio( @@ -36,6 +38,7 @@ export async function refreshPortfolio( (slug, error) => captureErrors.push({ slug, message: error.message }) ); const published = publishSite(config); + const deployed = deployAll(config); return { ingested: ingested.length, @@ -43,5 +46,6 @@ export async function refreshPortfolio( copiedScreenshots: copyScreenshotsToOutput(config), captureErrors, published, + deployed, }; } diff --git a/src/theme/index.ts b/src/theme/index.ts new file mode 100644 index 0000000..b2fbf33 --- /dev/null +++ b/src/theme/index.ts @@ -0,0 +1,8 @@ +export { + isBuiltinTheme, + isValidHexColor, + listBuiltinThemes, + resolveTheme, + themeVariables, +} from "./palette.js"; +export type { ThemeCatalogEntry, ThemeMode, ThemeTokens } from "./palette.js"; diff --git a/src/theme/palette.ts b/src/theme/palette.ts new file mode 100644 index 0000000..e18349e --- /dev/null +++ b/src/theme/palette.ts @@ -0,0 +1,266 @@ +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 { + mode: ThemeMode; +} + +export interface ThemeCatalogEntry { + name: string; + description: string; +} + +const SANS_FONT = + 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; +const MONO_FONT = 'ui-monospace, "Cascadia Mono", Consolas, "SFMono-Regular", Menlo, monospace'; + +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: SANS_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: SANS_FONT, + }, + terminal: { + mode: "dark", + ink: "#070b09", + inkSoft: "#0b120d", + panel: "#0f1610", + panelStrong: "#141e16", + line: "rgba(154, 230, 170, 0.16)", + lineSoft: "rgba(154, 230, 170, 0.08)", + text: "#d8ffe0", + muted: "#8aa395", + blue: "#3fd95e", + blueSoft: "#9cf0ae", + buttonText: "#04100a", + mint: "#a7f3b0", + orange: "#ffb86b", + shadow: "0 24px 60px rgba(0, 0, 0, 0.34)", + visual: "#050a07", + glow: "radial-gradient(circle at 82% -10%, rgba(63, 217, 94, 0.16), transparent 34rem)", + panelGradient: "linear-gradient(135deg, rgba(20, 30, 22, 0.98), rgba(11, 18, 13, 0.96))", + asideGradient: "linear-gradient(145deg, rgba(20, 30, 22, 0.92), rgba(11, 18, 13, 0.76))", + labelBg: "rgba(7, 11, 9, 0.78)", + labelBorder: "rgba(216, 255, 224, 0.2)", + auditBg: "rgba(15, 22, 16, 0.7)", + codeBg: "rgba(63, 217, 94, 0.1)", + tagBg: "rgba(63, 217, 94, 0.08)", + tagBorder: "rgba(154, 230, 170, 0.3)", + stripe: "rgba(63, 217, 94, 0.06)", + radius: "4px", + font: MONO_FONT, + }, +}; + +const BUILTIN_DESCRIPTIONS: Record = { + "deep-space": "Dark palette with blue accents. Default.", + paper: "Light palette with dark text and high contrast.", + terminal: "Dark palette with green phosphor text.", +}; + +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 match = /^[0-9a-f]{6}$/i.exec(hex.replace(/^#/, "")); + if (!match) return null; + const value = Number.parseInt(match[0], 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, + }; + + if (config?.accent) { + const accent = normalizeHexColor(config.accent); + tokens.blue = accent; + tokens.blueSoft = mixHex(accent, base.mode === "dark" ? "#ffffff" : "#000000", 0.5); + tokens.glow = glowFromAccent(accent, base.mode); + tokens.stripe = stripeFromAccent(accent, 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..d329913 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; @@ -86,6 +103,8 @@ export interface PortfolioConfig { dataDir: string; outputDir: string; privacy: PrivacyConfig; + theme: ThemeConfig; + deploy: DeployConfig; 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", @@ -103,5 +130,7 @@ export const DEFAULT_CONFIG = { dataDir: "data", outputDir: "output", privacy: DEFAULT_PRIVACY, + theme: DEFAULT_THEME, + deploy: DEFAULT_DEPLOY, clock: () => new Date().toISOString(), } satisfies PortfolioConfig; diff --git a/tests/config.test.ts b/tests/config.test.ts index 44e2720..7d034ad 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("loads theme and deploy settings", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + theme: { name: "paper", accent: "#0f6bbd" }, + deploy: { + targets: [ + { name: "public", type: "local", target: "deploy/site" }, + ], + }, + })); + + const config = loadPortfolioConfig(TEST_FILE); + + expect(config.theme.name).toBe("paper"); + expect(config.theme.accent).toBe("#0f6bbd"); + expect(config.deploy.targets).toHaveLength(1); + expect(config.deploy.targets[0].name).toBe("public"); + expect(config.deploy.targets[0].target).toBe("deploy/site"); + }); + + it("applies the default theme and deploy settings when omitted", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ owner: "owner" })); + + const config = loadPortfolioConfig(TEST_FILE); + + expect(config.theme.name).toBe("deep-space"); + expect(config.deploy.targets).toEqual([]); + }); + + it("rejects unknown theme names", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: { name: "vaporwave" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + /Configuration field "theme.name" must be one of:/ + ); + }); + + it("rejects invalid accent colors", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: { name: "paper", accent: "not-a-color" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + /Configuration field "theme.accent" must be a hex color/ + ); + }); + + it("rejects deploy targets with an unknown adapter", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + deploy: { targets: [{ name: "netlify", type: "ftp", target: "x" }] }, + })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + /deploy.targets\[0\]\.type" must be "local"/ + ); + }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts new file mode 100644 index 0000000..151c408 --- /dev/null +++ b/tests/deploy.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { deployAll, deployToTarget } from "../src/deploy/index.js"; +import { isPathInside } from "../src/deploy/local.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("deploy", "test-deploy-target"); + +function publishedConfig(target = TEST_TARGET) { + return { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deploy: { + targets: [{ name: "public", type: "local" as const, target }], + }, + clock: () => "2026-07-31T00:00:00.000Z", + }; +} + +function writePublishedSite() { + mkdirSync(join(TEST_OUTPUT, "assets", "screenshots"), { recursive: true }); + writeFileSync(join(TEST_OUTPUT, "index.html"), "portfolio", "utf-8"); + writeFileSync(join(TEST_OUTPUT, "site-manifest.json"), "{}", "utf-8"); +} + +describe("deploy adapters", () => { + beforeEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + rmSync(TEST_TARGET, { recursive: true, force: true }); + }); + + afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + rmSync(TEST_TARGET, { recursive: true, force: true }); + }); + + it("copies the published site into a local target", () => { + writePublishedSite(); + const result = deployAll(publishedConfig()); + + expect(result).toHaveLength(1); + expect(result[0].targetName).toBe("public"); + expect(result[0].targetPath).toBe(TEST_TARGET); + expect(result[0].files).toBeGreaterThanOrEqual(2); + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + expect(existsSync(join(TEST_TARGET, "site-manifest.json"))).toBe(true); + }); + + it("reports a publish-first error when output is missing", () => { + const config = publishedConfig(); + expect(() => deployToTarget(config, config.deploy.targets[0])).toThrow( + /No published site found/ + ); + }); + + it("rejects a target inside the output directory", () => { + writePublishedSite(); + const config = publishedConfig(join(TEST_OUTPUT, "nested")); + expect(() => deployAll(config)).toThrow(/must be outside the output directory/); + }); + + it("returns an empty result list without targets", () => { + writePublishedSite(); + const config = { ...publishedConfig(), deploy: { targets: [] } }; + expect(deployAll(config)).toEqual([]); + }); + + it("records a deploy audit entry", () => { + writePublishedSite(); + deployAll(publishedConfig()); + + const db = openDatabase(TEST_DATA, publishedConfig().clock); + const log = db.getIngestLog(5); + db.close(); + expect(log[0].action).toBe("deploy"); + expect(log[0].detail).toContain("public"); + }); + + it("copies subdirectories inside the output directory", () => { + writePublishedSite(); + writeFileSync( + join(TEST_OUTPUT, "assets", "screenshots", "demo-engineer-signal-router.png"), + "png", + "utf-8" + ); + deployAll(publishedConfig()); + + const targetFiles = readdirSync(join(TEST_TARGET, "assets", "screenshots")); + expect(targetFiles).toContain("demo-engineer-signal-router.png"); + }); + + it("reports paths that fall inside a parent directory", () => { + expect(isPathInside("output", "output/nested")).toBe(true); + expect(isPathInside("output", "output")).toBe(true); + expect(isPathInside("output", "other")).toBe(false); + expect(isPathInside("output", "output-extra")).toBe(false); + }); +}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts new file mode 100644 index 0000000..7c5745c --- /dev/null +++ b/tests/manifest.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { readFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { ingestOwnerRepos } from "../src/ingest/orchestrator.js"; +import { captureLocalHtml, closeBrowser } from "../src/preview/capture.js"; +import { publishSite, type SiteManifest } from "../src/publish/site.js"; +import { loadAllFixtures } from "../src/fixtures/loader.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = join("data", "test-manifest"); +const TEST_OUTPUT = join("output", "test-manifest"); + +function readManifest(): SiteManifest { + return JSON.parse(readFileSync(join(TEST_OUTPUT, "site-manifest.json"), "utf-8")) as SiteManifest; +} + +describe("site manifest", () => { + beforeEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + }); + + afterEach(async () => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + await closeBrowser(); + }); + + it("records format, theme, and project counts", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + clock: () => "2026-07-31T00:00:00.000Z", + }; + await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures()); + const result = publishSite(config); + + expect(result.theme).toBe("deep-space"); + expect(result.manifestPath).toBe(join(TEST_OUTPUT, "site-manifest.json")); + expect(existsSync(result.manifestPath)).toBe(true); + + const manifest = readManifest(); + expect(manifest.formatVersion).toBe(1); + expect(manifest.theme).toBe("deep-space"); + expect(manifest.projectCount).toBe(2); + expect(manifest.owner).toBe("demo-engineer"); + expect(manifest.generatedAt).toBe("2026-07-31T00:00:00.000Z"); + expect(manifest.files).toContain("index.html"); + expect(manifest.files).toContain("site-manifest.json"); + expect(manifest.files).toContain("demo-engineer-signal-router-changelog.md"); + expect(manifest.projects).toHaveLength(2); + }); + + it("lists screenshots after capture", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + clock: () => "2026-07-31T00:00:00.000Z", + }; + await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures()); + for (const fixture of loadAllFixtures()) { + const slug = fixture.repo.full_name.replace(/\//g, "-").toLowerCase(); + await captureLocalHtml(config, slug, join("fixtures", "preview-pages", `${fixture.repo.name}.html`)); + } + publishSite(config); + + const manifest = readManifest(); + expect(manifest.screenshots).toContain("assets/screenshots/demo-engineer-signal-router.png"); + expect(manifest.screenshots).toContain("assets/screenshots/demo-engineer-metrics-kit.png"); + }); + + it("publishes with a custom theme", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + theme: { name: "paper" }, + clock: () => "2026-07-31T00:00:00.000Z", + }; + await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures()); + const result = publishSite(config); + + expect(result.theme).toBe("paper"); + expect(readManifest().theme).toBe("paper"); + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain("Theme / paper"); + expect(html).toContain("color-scheme: light"); + }); +}); diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts index d119d9b..5eb9405 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("deploy", "test-refresh"); 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", () => { @@ -33,6 +35,29 @@ describe("configured refresh", () => { expect(result.captureErrors).toEqual([]); expect(result.published.projectCount).toBe(2); expect(result.copiedScreenshots).toBe(0); + expect(result.deployed).toEqual([]); expect(existsSync(join(TEST_OUTPUT, "index.html"))).toBe(true); }); + + it("deploys to configured local targets", async () => { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + repositoryLimit: 2, + deploy: { + targets: [{ name: "public", type: "local" as const, target: "deploy/test-refresh" }], + }, + clock: () => "2026-07-31T00:00:00.000Z", + }; + + const result = await refreshPortfolio(config, { + fixtureRepos: loadAllFixtures(), + capture: false, + }); + + expect(result.deployed).toHaveLength(1); + expect(result.deployed[0].targetName).toBe("public"); + expect(existsSync(join("deploy", "test-refresh", "index.html"))).toBe(true); + }); }); diff --git a/tests/site.test.ts b/tests/site.test.ts index 03061cf..27465e1 100644 --- a/tests/site.test.ts +++ b/tests/site.test.ts @@ -78,13 +78,25 @@ describe("database and publish pipeline", () => { const result = publishSite(config); expect(result.projectCount).toBe(1); + expect(result.theme).toBe("deep-space"); expect(existsSync(result.indexPath)).toBe(true); + expect(existsSync(result.manifestPath)).toBe(true); const html = readFileSync(result.indexPath, "utf-8"); expect(html).toContain("signal-router"); expect(html).toContain("auditable SQLite data"); expect(html).toContain("42 stars"); expect(html).toContain("total stars"); + expect(html).toContain("Theme / deep-space"); + + const manifest = JSON.parse(readFileSync(result.manifestPath, "utf-8")) as { + formatVersion: number; + theme: string; + files: string[]; + }; + expect(manifest.formatVersion).toBe(1); + expect(manifest.theme).toBe("deep-space"); + expect(manifest.files).toContain("index.html"); }); it("respects visibility when publishing", async () => { diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..78c1cdc --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { + listBuiltinThemes, + isBuiltinTheme, + isValidHexColor, + resolveTheme, + themeVariables, +} from "../src/theme/palette.js"; +import { DEFAULT_THEME } from "../src/types.js"; + +describe("theme catalog", () => { + it("exposes deterministic built-in themes", () => { + const names = listBuiltinThemes().map((theme) => theme.name); + expect(names).toEqual(["deep-space", "paper", "terminal"]); + for (const theme of listBuiltinThemes()) { + expect(theme.description.length).toBeGreaterThan(0); + } + }); + + it("recognizes built-in theme names", () => { + expect(isBuiltinTheme("deep-space")).toBe(true); + expect(isBuiltinTheme("paper")).toBe(true); + expect(isBuiltinTheme("terminal")).toBe(true); + expect(isBuiltinTheme("vaporwave")).toBe(false); + }); +}); + +describe("hex color validation", () => { + it("accepts short and long hex forms", () => { + expect(isValidHexColor("#fff")).toBe(true); + expect(isValidHexColor("#67b7ff")).toBe(true); + expect(isValidHexColor("67b7ff")).toBe(true); + }); + + it("rejects non-hex values", () => { + expect(isValidHexColor("red")).toBe(false); + expect(isValidHexColor("#12")).toBe(false); + expect(isValidHexColor("#ggg")).toBe(false); + }); +}); + +describe("resolveTheme", () => { + it("defaults to the configured default theme", () => { + const tokens = resolveTheme(); + expect(tokens.name).toBe(DEFAULT_THEME.name); + expect(tokens.mode).toBe("dark"); + }); + + it("resolves a requested built-in theme", () => { + const tokens = resolveTheme({ name: "paper" }); + expect(tokens.name).toBe("paper"); + expect(tokens.mode).toBe("light"); + }); + + it("falls back to the default theme for unknown names", () => { + const tokens = resolveTheme({ name: "not-a-theme" }); + expect(tokens.name).toBe(DEFAULT_THEME.name); + }); + + it("applies an accent override to derived tokens", () => { + const base = resolveTheme({ name: "deep-space" }); + const tokens = resolveTheme({ name: "deep-space", accent: "#ff0000" }); + expect(tokens.blue).toBe("#ff0000"); + expect(tokens.blue).not.toBe(base.blue); + expect(tokens.glow).toContain("rgba(255, 0, 0,"); + expect(tokens.stripe).toContain("rgba(255, 0, 0,"); + }); + + it("applies radius and font overrides", () => { + const tokens = resolveTheme({ name: "paper", radius: "20px", font: "Georgia, serif" }); + expect(tokens.radius).toBe("20px"); + expect(tokens.font).toBe("Georgia, serif"); + }); +}); + +describe("themeVariables", () => { + it("emits a :root block with color-scheme and core tokens", () => { + const css = themeVariables(resolveTheme({ name: "deep-space" })); + expect(css).toContain(":root {"); + expect(css).toContain("color-scheme: dark"); + expect(css).toContain("--blue: #67b7ff"); + expect(css).toContain("--font:"); + }); + + it("emits a light color-scheme for the paper theme", () => { + const css = themeVariables(resolveTheme({ name: "paper" })); + expect(css).toContain("color-scheme: light"); + }); + + it("emits deterministic output for the same theme", () => { + const first = themeVariables(resolveTheme({ name: "paper" })); + const second = themeVariables(resolveTheme({ name: "paper" })); + expect(first).toBe(second); + }); +});