Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/
coverage/
data/
output/
site/
*.db
*.db-journal
*.db-shm
Expand Down
65 changes: 57 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,23 +29,29 @@ 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. |
| `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 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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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. |
Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand All @@ -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

Expand Down
12 changes: 12 additions & 0 deletions engineer-profile.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
Expand Down
79 changes: 77 additions & 2 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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];
Expand Down Expand Up @@ -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,
};
Expand Down
22 changes: 22 additions & 0 deletions src/deploy/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, DeployAdapter> = {
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));
}
Loading
Loading