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