From 4fb53874a3bcd7875b21d2ead574c60cab89732b Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:51:16 -0700 Subject: [PATCH] feat: extend engineer profile --- .github/workflows/ci.yml | 9 ++ .gitignore | 1 + README.md | 65 ++++++++-- engineer-profile.config.json | 12 ++ package-lock.json | 6 +- package.json | 6 +- src/config/loader.ts | 79 +++++++++++- src/deploy/index.ts | 22 ++++ src/deploy/local.ts | 42 +++++++ src/index.ts | 39 +++++- src/publish/site.ts | 110 ++++++++++++----- src/theme/palette.ts | 228 +++++++++++++++++++++++++++++++++++ src/types.ts | 26 ++++ tests/config.test.ts | 46 +++++++ tests/demo.test.ts | 4 + tests/deploy.test.ts | 80 ++++++++++++ tests/deterministic.test.ts | 3 + tests/site.test.ts | 4 + tests/theme.test.ts | 64 ++++++++++ 19 files changed, 795 insertions(+), 51 deletions(-) create mode 100644 src/deploy/index.ts 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..d36d594 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,15 @@ 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 fs=require('fs');const m=JSON.parse(fs.readFileSync('output/site-manifest.json','utf8'));if(m.formatVersion!==1||m.projectCount<1)process.exit(1);console.log('Manifest ok - '+m.projectCount+' projects, theme '+m.theme)\"" + + - name: Verify local deploy + run: node dist/index.js deploy && test -f site/index.html + - name: Store demo output if: success() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 6d63646..674fd6c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ coverage/ data/ output/ +site/ *.db *.db-journal *.db-shm diff --git a/README.md b/README.md index f1a2a81..d585f9c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ 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. +- Apply a built-in theme or customize accent, radius, and font. - Hide projects and redact author emails before publication. +- Deploy a versioned snapshot to a local target. - Run one configured refresh from a scheduled workflow. The fixture demo runs without secrets and without network access. @@ -27,15 +29,19 @@ flowchart LR D --> L[Changelog] D --> P[Privacy] D --> S[Playwright] + D --> T[Theme] L --> W[Publisher] P --> W S --> W + T --> W W --> O[Static output] + O --> A[Deploy] + A --> N[Local target] ``` | 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/ingest/` | Fetch public GitHub data and map it to records. | @@ -43,7 +49,9 @@ 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 generate CSS variables. | +| `src/publish/` | Render HTML, changelog files, preview assets, and a 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,12 +76,41 @@ Both directories are ignored by Git. ## Configuration `engineer-profile.config.json` is the checked-in source for scheduled refreshes. -It sets the GitHub owner, site presentation, repository limit, paths, and privacy controls. +It sets the GitHub owner, site presentation, repository limit, paths, theme, deploy, 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 `theme` section selects a built-in palette. +You can override the accent color, corner radius, and font. + +```json +{ + "theme": { + "name": "deep-space", + "accent": "#67b7ff", + "radius": "16px" + } +} +``` + +The `deploy` section lists local targets. +Each target has a name, adapter type, and destination path. + +```json +{ + "deploy": { + "targets": [ + { "name": "docs", "type": "local", "target": "site" } + ] + } +} +``` + +Run `node dist/index.js themes` to list built-in themes. +Run `node dist/index.js deploy` to copy the site to every target. + Run a network-backed refresh with the checked-in settings: ```bash @@ -105,12 +142,15 @@ Ingested 2 fixture projects. Captured demo-engineer-signal-router. Captured demo-engineer-metrics-kit. Published 2 projects to output/index.html. +Theme deep-space applied. Wrote deploy manifest to output/site-manifest.json. 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. +The manifest lists the theme and every published file. +It gives deployment tooling a versioned snapshot of the build. ## Commands @@ -122,8 +162,10 @@ Build before direct CLI commands. | `npm run ingest -- octocat --limit 3` | Load public repository evidence. | | `npm run ingest -- --fixture` | Load fixture records only. | | `npm run capture -- --fixture` | Capture local fixture pages. | -| `npm run publish` | Rebuild the site from SQLite. | +| `npm run publish` | Rebuild the site and manifest from SQLite. | | `npm run refresh` | Run configured ingest, capture, and publish stages. | +| `npm run themes` | List available built-in themes. | +| `npm run deploy` | Copy the site to every configured target. | | `node dist/index.js status` | Show visibility and recent operations. | | `npm test` | Run deterministic unit and integration tests. | | `npm run typecheck` | Validate TypeScript types. | @@ -154,24 +196,28 @@ 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. +Deploy runs add a `deploy` entry with the target name and path. ## CI and test status -The regular CI workflow runs typecheck, build, tests, the fixture demo, and artifact upload. +The regular CI workflow runs typecheck, build, tests, the fixture demo, theme, manifest, and deploy checks. +It uploads the demo site as an artifact. 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, overrides, and CSS variable output. - Conventional commit parsing. - Release-first changelog generation. - SQLite upserts and changelog replacement. - Privacy filtering and email redaction. - Fixture ingestion and static publishing. - Configured refresh orchestration. +- Site manifest generation and local deploy. - Release source links. -- Deterministic HTML output. +- Deterministic HTML and manifest output. - Playwright screenshot capture. Run the local checks: @@ -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. +- The manifest lists publish-time files only. +- The local deploy adapter copies files. It does not upload them. - Publishing creates local files. It does not deploy them. - Scheduled runs upload artifacts. They do not commit generated output. @@ -205,8 +253,9 @@ The fixture pipeline provides deterministic data for repeatable checks. | --- | --- | --- | | v0.1 | Complete | Fixture demo, GitHub ingest, changelog, capture, publish, and privacy controls. | | v0.2 | Complete | Checked-in configuration, coordinated refresh command, and scheduled artifact workflow. | -| v0.3 | Next | Custom themes and deployment adapters. | -| v0.4 | Later | Commit-diff summaries and an RSS feed. | +| v0.3 | Complete | Custom themes and local deployment adapters with a site manifest. | +| v0.4 | Next | Commit-diff summaries and an RSS feed. | +| v0.5 | Later | Additional deploy adapters and publishing drafts. | ## License diff --git a/engineer-profile.config.json b/engineer-profile.config.json index 81008fb..703e2c9 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": "docs", + "type": "local", + "target": "site" + } + ] + }, "privacy": { "hiddenProjects": [], "redactEmails": true, diff --git a/package-lock.json b/package-lock.json index 87f5d51..d3e2c40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "better-sqlite3": "^11.8.1", @@ -1845,6 +1845,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2352,6 +2353,7 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index d36edbe..d80d52e 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", + "themes": "npm run build && node dist/index.js themes", + "deploy": "npm run build && node dist/index.js deploy" }, "engines": { "node": ">=20" diff --git a/src/config/loader.ts b/src/config/loader.ts index 406896b..7b99b8a 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, 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 } from "../theme/palette.js"; export const DEFAULT_CONFIG_PATH = "engineer-profile.config.json"; @@ -20,6 +21,78 @@ function readString(source: ConfigValue, key: string, fallback: string): string return value.trim(); } +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, accent, radius, font } = source.theme; + const theme: ThemeConfig = {}; + + if (name !== undefined) { + if (typeof name !== "string" || !isBuiltinTheme(name)) { + throw new Error('Configuration field "theme.name" must be a built-in theme name.'); + } + theme.name = name; + } + if (accent !== undefined) { + if (typeof accent !== "string" || !isValidHexColor(accent)) { + throw new Error('Configuration field "theme.accent" must be a hex color.'); + } + theme.accent = accent; + } + if (radius !== undefined) { + if (typeof radius !== "string" || radius.trim() === "") { + throw new Error('Configuration field "theme.radius" must be a non-empty string.'); + } + theme.radius = radius; + } + if (font !== undefined) { + if (typeof font !== "string" || font.trim() === "") { + throw new Error('Configuration field "theme.font" must be a non-empty string.'); + } + theme.font = font; + } + + return { ...DEFAULT_THEME, ...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.'); + } + + const { targets } = source.deploy; + if (targets === undefined) return DEFAULT_DEPLOY; + if (!Array.isArray(targets)) { + throw new Error('Configuration field "deploy.targets" must be a list.'); + } + + const parsed = targets.map((entry, index) => { + if (!isConfigValue(entry)) { + throw new Error(`Deploy target ${index} must be an object.`); + } + if (typeof entry.name !== "string" || entry.name.trim() === "") { + throw new Error(`Deploy target ${index} must have a non-empty "name".`); + } + if (entry.type !== "local") { + throw new Error(`Deploy target "${entry.name}" uses an unknown adapter type.`); + } + if (typeof entry.target !== "string" || entry.target.trim() === "") { + throw new Error(`Deploy target "${entry.name}" must have a non-empty "target" path.`); + } + return { + name: entry.name.trim(), + type: "local" as const, + target: entry.target.trim(), + }; + }); + + return { targets: parsed }; +} + function readLimit(source: ConfigValue, key: string, fallback: number): number { if (!(key in source)) return fallback; const value = source[key]; @@ -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/index.ts b/src/deploy/index.ts new file mode 100644 index 0000000..067df80 --- /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 { deployLocal, type DeployResult } from "./local.js"; + +type DeployAdapter = (config: PortfolioConfig, target: DeployTarget) => DeployResult; + +const ADAPTERS: Record = { + local: deployLocal, +}; + +export function deployTo(config: PortfolioConfig, target: DeployTarget): DeployResult { + const adapter = ADAPTERS[target.type]; + if (!adapter) { + throw new Error(`No deploy adapter is registered for type "${target.type}".`); + } + return adapter(config, target); +} + +export function runDeploys(config: PortfolioConfig): DeployResult[] { + return config.deploy.targets.map((target) => deployTo(config, target)); +} diff --git a/src/deploy/local.ts b/src/deploy/local.ts new file mode 100644 index 0000000..27c7d1f --- /dev/null +++ b/src/deploy/local.ts @@ -0,0 +1,42 @@ +import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { join } 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; +} + +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 at "${config.outputDir}". Run publish first.`); + } + + 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..367a5f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import { Command } from "commander"; import { ingestOwnerRepos, ingestRepository } from "./ingest/orchestrator.js"; import { captureAllProjects, captureLocalHtml, closeBrowser } from "./preview/capture.js"; import { copyScreenshotsToOutput, publishSite } from "./publish/site.js"; +import { runDeploys } from "./deploy/index.js"; +import { listBuiltinThemes } from "./theme/palette.js"; import { loadAllFixtures } from "./fixtures/loader.js"; import { openDatabase } from "./db/client.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "./types.js"; @@ -16,7 +18,7 @@ const program = new Command(); program .name("engineer-profile") .description("Build a local engineering portfolio from public repository evidence") - .version("0.2.0"); + .version("0.3.0"); function resolveConfig(options: { config?: string; data?: string; output?: string }): PortfolioConfig { const base = options.config @@ -31,6 +33,10 @@ function resolveConfig(options: { config?: string; data?: string; output?: strin }; } +function displayPath(path: string): string { + return path.split("\\").join("/"); +} + function addConfigOption(command: Command): Command { return command.option("-c, --config ", "Configuration file"); } @@ -65,7 +71,8 @@ addConfigOption(program const result = publishSite(config); const copied = copyScreenshotsToOutput(config); - console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); + console.log(`Published ${result.projectCount} projects to ${displayPath(result.indexPath)}.`); + console.log(`Theme ${result.theme} applied. Wrote deploy manifest to ${displayPath(result.manifestPath)}.`); console.log(`Copied ${copied} available preview screenshots.`); console.log("Open output/index.html in a browser."); })); @@ -129,8 +136,32 @@ addConfigOption(program const config = resolveConfig(options); const result = publishSite(config); const copied = copyScreenshotsToOutput(config); - console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); + console.log(`Published ${result.projectCount} projects to ${displayPath(result.indexPath)}.`); + console.log(`Theme ${result.theme} applied.`); console.log(`Copied ${copied} available preview screenshots.`); + console.log(`Wrote deploy manifest to ${displayPath(result.manifestPath)}.`); + })); + +program + .command("themes") + .description("List available built-in themes") + .action(() => { + for (const theme of listBuiltinThemes()) { + console.log(`${theme.name}: ${theme.description}`); + } + }); + +addConfigOption(program + .command("deploy") + .description("Deploy the published site to configured targets") + .option("-d, --data ", "Data directory") + .option("-o, --output ", "Output directory") + .action((options) => { + const config = resolveConfig(options); + const results = runDeploys(config); + for (const result of results) { + console.log(`Deployed to ${result.targetName} (${displayPath(result.targetPath)}, ${result.files} files).`); + } })); addConfigOption(program @@ -144,7 +175,7 @@ addConfigOption(program const result = await refreshPortfolio(config); console.log(`Ingested ${result.ingested} repositories for ${config.owner}.`); console.log(`Captured ${result.captured} project previews.`); - console.log(`Published ${result.published.projectCount} projects to ${result.published.indexPath}.`); + console.log(`Published ${result.published.projectCount} projects to ${displayPath(result.published.indexPath)}.`); console.log(`Copied ${result.copiedScreenshots} available preview screenshots.`); for (const error of result.captureErrors) { console.warn(`Skipped ${error.slug}: ${error.message}`); diff --git a/src/publish/site.ts b/src/publish/site.ts index d382163..15ac09a 100644 --- a/src/publish/site.ts +++ b/src/publish/site.ts @@ -1,7 +1,8 @@ -import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join, relative } 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 { @@ -124,29 +125,14 @@ function projectCard( `; } -function siteCss(): string { +function siteCss(tokens: 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(tokens)} * { 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); font-family: var(--font); 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 +144,7 @@ a { color: inherit; } .kicker { color: var(--mint); margin: 0 0 22px; } h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-weight: 650; letter-spacing: -0.08em; line-height: 0.94; } .hero-copy { max-width: 530px; margin: 28px 0 0; color: var(--blue-soft); font-size: 1.12rem; } -.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(23, 42, 70, 0.92), rgba(13, 26, 45, 0.72)); box-shadow: var(--shadow); } +.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: 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: 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 +173,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; 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,11 +183,11 @@ 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 { 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); } @@ -210,7 +196,7 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we .source-trail h2 { margin: 0 0 8px; font-size: 1.2rem; font-weight: 550; } .source-trail p { margin: 0; color: var(--muted); font-size: 0.86rem; } .audit-list { margin: 0; padding: 0; list-style: none; } -.audit-list li { display: grid; grid-template-columns: 150px 72px 1fr; gap: 12px; padding: 8px 0; border-bottom: 1px solid rgba(169, 195, 222, 0.1); color: var(--muted); font: 0.72rem/1.4 "SFMono-Regular", Consolas, monospace; } +.audit-list li { display: grid; grid-template-columns: 150px 72px 1fr; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line-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; } @@ -241,13 +227,57 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we export interface PublishResult { indexPath: string; + manifestPath: string; + theme: string; projectCount: number; generatedAt: string; } +export const SITE_MANIFEST_NAME = "site-manifest.json"; +const SITE_MANIFEST_VERSION = 1; + +export interface SiteManifest { + formatVersion: number; + generatedAt: string; + owner: string; + theme: string; + projectCount: number; + files: string[]; +} + +function listOutputFiles(outputDir: string): string[] { + const files: string[] = []; + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + if (entry.isDirectory()) walk(join(current, entry.name)); + else files.push(relative(outputDir, join(current, entry.name)).split("\\").join("/")); + } + }; + walk(outputDir); + return files.sort(); +} + +export function writeSiteManifest( + config: PortfolioConfig, + snapshot: { generatedAt: string; theme: string; projectCount: number } +): string { + const manifestPath = join(config.outputDir, SITE_MANIFEST_NAME); + const manifest: SiteManifest = { + formatVersion: SITE_MANIFEST_VERSION, + generatedAt: snapshot.generatedAt, + owner: config.owner, + theme: snapshot.theme, + projectCount: snapshot.projectCount, + files: listOutputFiles(config.outputDir), + }; + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8"); + return manifestPath; +} + export function publishSite(config: PortfolioConfig): PublishResult { const db = openDatabase(config.dataDir, config.clock); try { + const tokens = resolveTheme(config.theme); const projects = db.listProjects(true); const generatedAt = config.clock(); const views = projects.map((project) => ({ @@ -279,7 +309,7 @@ export function publishSite(config: PortfolioConfig): PublishResult { ${escapeHtml(config.title)} / Portfolio - +
@@ -344,8 +374,14 @@ export function publishSite(config: PortfolioConfig): PublishResult { writeFileSync(join(config.outputDir, `${view.project.slug}-changelog.md`), markdown, "utf-8"); } + const manifestPath = writeSiteManifest(config, { + generatedAt, + theme: tokens.name, + projectCount: projects.length, + }); + db.logIngest("publish", `${projects.length} projects -> ${indexPath}`); - return { indexPath, projectCount: projects.length, generatedAt }; + return { indexPath, manifestPath, theme: tokens.name, projectCount: projects.length, generatedAt }; } finally { db.close(); } @@ -363,6 +399,14 @@ export function copyScreenshotsToOutput(config: PortfolioConfig): number { cpSync(project.screenshot_path, join(assetsDir, `${project.slug}.png`)); copied++; } + + const visible = db.listProjects(true).length; + const tokens = resolveTheme(config.theme); + writeSiteManifest(config, { + generatedAt: config.clock(), + theme: tokens.name, + projectCount: visible, + }); } finally { db.close(); } diff --git a/src/theme/palette.ts b/src/theme/palette.ts new file mode 100644 index 0000000..02b925a --- /dev/null +++ b/src/theme/palette.ts @@ -0,0 +1,228 @@ +import { DEFAULT_THEME_NAME, 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 {} + +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 }; + + if (config?.accent && isValidHexColor(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..11f157b 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,11 @@ export const DEFAULT_PRIVACY: PrivacyConfig = { maxCommitsPerProject: 50, }; +export const DEFAULT_THEME_NAME = "deep-space"; +export const DEFAULT_THEME: ThemeConfig = { name: DEFAULT_THEME_NAME }; + +export const DEFAULT_DEPLOY: DeployConfig = { targets: [] }; + export const DEFAULT_CONFIG = { owner: "demo-engineer", title: "EngineerProfile", @@ -102,6 +126,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..d6e8f94 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -37,4 +37,50 @@ describe("portfolio configuration", () => { 'Configuration field "repositoryLimit" must be an integer from 1 to 100.' ); }); + + it("loads theme and deploy sections with defaults", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + theme: { name: "paper", accent: "#22c55e", radius: "8px" }, + deploy: { targets: [{ name: "docs", type: "local", target: "site" }] }, + })); + + const config = loadPortfolioConfig(TEST_FILE); + + expect(config.theme.name).toBe("paper"); + expect(config.theme.accent).toBe("#22c55e"); + expect(config.theme.radius).toBe("8px"); + expect(config.deploy.targets).toEqual([ + { name: "docs", type: "local", target: "site" }, + ]); + }); + + it("rejects an unknown theme name", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: { name: "missing" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme.name" must be a built-in theme name.' + ); + }); + + it("rejects an invalid accent color", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: { accent: "blue" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme.accent" must be a hex color.' + ); + }); + + it("rejects an unknown deploy adapter type", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + deploy: { targets: [{ name: "pages", type: "github-pages", target: "site" }] }, + })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + /uses an unknown adapter type/ + ); + }); }); diff --git a/tests/demo.test.ts b/tests/demo.test.ts index 4329839..01a6b2e 100644 --- a/tests/demo.test.ts +++ b/tests/demo.test.ts @@ -47,5 +47,9 @@ describe("demo pipeline", () => { expect(html).toContain("metrics-kit"); 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); + + const manifest = JSON.parse(readFileSync(published.manifestPath, "utf-8")); + expect(manifest.files).toContain("assets/screenshots/demo-engineer-signal-router.png"); + expect(manifest.projectCount).toBe(2); }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts new file mode 100644 index 0000000..64f0fca --- /dev/null +++ b/tests/deploy.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { deployLocal, runDeploys } from "../src/deploy/index.js"; +import { loadAllFixtures } from "../src/fixtures/loader.js"; +import { ingestOwnerRepos } from "../src/ingest/orchestrator.js"; +import { publishSite } from "../src/publish/site.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = join("data", "test-deploy"); +const TEST_OUTPUT = join("output", "test-deploy"); +const TEST_TARGET = join("output", "test-deploy-target"); + +afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + rmSync(TEST_TARGET, { recursive: true, force: true }); +}); + +async function publishedConfig() { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + clock: () => "2026-07-31T00:00:00.000Z", + }; + await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures()); + return publishSite(config); +} + +describe("site manifest", () => { + it("writes a deploy manifest during publish", async () => { + const result = await publishedConfig(); + const manifest = JSON.parse(readFileSync(result.manifestPath, "utf-8")); + + expect(manifest.formatVersion).toBe(1); + expect(manifest.projectCount).toBe(2); + expect(manifest.theme).toBe("deep-space"); + expect(manifest.generatedAt).toBe("2026-07-31T00:00:00.000Z"); + expect(manifest.files).toContain("index.html"); + }); +}); + +describe("local deploy adapter", () => { + it("copies the published site to a local target", async () => { + await publishedConfig(); + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; + + const result = deployLocal(config, { name: "docs", type: "local", target: TEST_TARGET }); + + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + expect(existsSync(join(TEST_TARGET, "site-manifest.json"))).toBe(true); + expect(result.targetName).toBe("docs"); + expect(result.files).toBeGreaterThan(0); + }); + + it("runs every configured deploy target", async () => { + await publishedConfig(); + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deploy: { targets: [{ name: "docs", type: "local", target: TEST_TARGET }] }, + }; + + const results = runDeploys(config); + + expect(results).toHaveLength(1); + expect(results[0].targetPath).toBe(TEST_TARGET); + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + }); + + it("requires a published site before deploying", () => { + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; + + expect(() => deployLocal(config, { name: "docs", type: "local", target: TEST_TARGET })).toThrow( + /Run publish first/ + ); + }); +}); diff --git a/tests/deterministic.test.ts b/tests/deterministic.test.ts index 78fc6ea..aee6524 100644 --- a/tests/deterministic.test.ts +++ b/tests/deterministic.test.ts @@ -26,14 +26,17 @@ describe("deterministic publishing", () => { it("produces the same HTML for the same fixture snapshot", async () => { const fixtures = loadAllFixtures(); const html = []; + const manifests = []; 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); html.push(readFileSync(result.indexPath, "utf-8")); + manifests.push(readFileSync(result.manifestPath, "utf-8")); } expect(html[0]).toBe(html[1]); + expect(manifests[0]).toBe(manifests[1]); expect(html[0]).toContain("2026-07-31 00:00:00 UTC"); }); diff --git a/tests/site.test.ts b/tests/site.test.ts index 03061cf..a3c8072 100644 --- a/tests/site.test.ts +++ b/tests/site.test.ts @@ -79,12 +79,16 @@ describe("database and publish pipeline", () => { const result = publishSite(config); expect(result.projectCount).toBe(1); expect(existsSync(result.indexPath)).toBe(true); + expect(existsSync(result.manifestPath)).toBe(true); + expect(result.theme).toBe("deep-space"); 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("--blue:"); + expect(html).toContain("color-scheme: dark"); }); it("respects visibility when publishing", async () => { diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..8b339e8 --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + isBuiltinTheme, + isValidHexColor, + listBuiltinThemes, + resolveTheme, + themeVariables, +} from "../src/theme/palette.js"; +import { DEFAULT_THEME_NAME } from "../src/types.js"; + +describe("theme catalog", () => { + it("lists the built-in themes with descriptions", () => { + const themes = listBuiltinThemes(); + expect(themes.map((theme) => theme.name)).toEqual(["deep-space", "paper"]); + for (const theme of themes) expect(theme.description.length).toBeGreaterThan(0); + }); + + it("resolves the default theme when no theme is set", () => { + const tokens = resolveTheme(); + expect(tokens.name).toBe(DEFAULT_THEME_NAME); + expect(tokens.mode).toBe("dark"); + }); + + it("resolves a named built-in theme", () => { + const tokens = resolveTheme({ name: "paper" }); + expect(tokens.mode).toBe("light"); + expect(tokens.text).toBe("#0f172a"); + expect(tokens.buttonText).toBe("#ffffff"); + }); + + it("applies accent, radius, and font overrides", () => { + const tokens = resolveTheme({ + name: "deep-space", + accent: "#22c55e", + radius: "8px", + font: "Georgia", + }); + expect(tokens.blue).toBe("#22c55e"); + expect(tokens.radius).toBe("8px"); + expect(tokens.font).toBe("Georgia"); + }); + + it("falls back to the default theme for an unknown name", () => { + expect(isBuiltinTheme("missing")).toBe(false); + const tokens = resolveTheme({ name: "missing" }); + expect(tokens.name).toBe(DEFAULT_THEME_NAME); + }); + + it("validates hex color values", () => { + expect(isValidHexColor("#123")).toBe(true); + expect(isValidHexColor("#123456")).toBe(true); + expect(isValidHexColor("12ab")).toBe(false); + expect(isValidHexColor("zzz")).toBe(false); + }); + + it("renders CSS variables for every token", () => { + const css = themeVariables(resolveTheme()); + expect(css).toContain("color-scheme: dark"); + expect(css).toContain("--ink:"); + expect(css).toContain("--blue:"); + expect(css).toContain("--button-text:"); + expect(css).toContain("--font:"); + }); +});