From d2ffdfac4cea87be489bb746135e701a0ac8c3f1 Mon Sep 17 00:00:00 2001
From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com>
Date: Mon, 3 Aug 2026 17:21:02 -0700
Subject: [PATCH] feat: extend engineer profile
---
.github/workflows/ci.yml | 8 +-
.github/workflows/refresh.yml | 2 +-
.gitignore | 1 +
.nvmrc | 2 +-
CONTRIBUTING.md | 11 +-
README.md | 148 ++++++++++++++-------
engineer-profile.config.json | 3 +
package-lock.json | 4 +-
package.json | 2 +-
src/config/loader.ts | 79 +++++++++++-
src/deploy/local.ts | 52 ++++++++
src/index.ts | 44 ++++++-
src/ingest/github.ts | 2 +-
src/publish/site.ts | 142 +++++++++++++++-----
src/refresh/run.ts | 8 +-
src/theme/palette.ts | 236 ++++++++++++++++++++++++++++++++++
src/types.ts | 29 +++++
tests/config.test.ts | 56 ++++++++
tests/demo.test.ts | 1 +
tests/deploy.test.ts | 82 ++++++++++++
tests/deterministic.test.ts | 16 +++
tests/site.test.ts | 58 ++++++++-
tests/theme.test.ts | 92 +++++++++++++
vitest.config.mjs | 6 +
vitest.config.ts | 6 +
25 files changed, 985 insertions(+), 105 deletions(-)
create mode 100644 src/deploy/local.ts
create mode 100644 src/theme/palette.ts
create mode 100644 tests/deploy.test.ts
create mode 100644 tests/theme.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eae27d9..76eb698 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,7 +23,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: "20"
+ node-version: "22"
cache: npm
- name: Install dependencies
@@ -44,6 +44,12 @@ jobs:
- name: Run fixture demo
run: node dist/index.js demo
+ - name: Verify theme catalog
+ run: node dist/index.js themes
+
+ - name: Verify deployment manifest
+ run: node -e "const fs=require('fs');const manifest=JSON.parse(fs.readFileSync('output/site-manifest.json','utf8'));if(manifest.formatVersion!==1||typeof manifest.projectCount!=='number')process.exit(1);console.log('Manifest ok: '+manifest.projectCount+' projects')"
+
- name: Store demo output
if: success()
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/refresh.yml b/.github/workflows/refresh.yml
index 360cd31..cb2d121 100644
--- a/.github/workflows/refresh.yml
+++ b/.github/workflows/refresh.yml
@@ -24,7 +24,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: "20"
+ node-version: "22"
cache: npm
- name: Install dependencies
diff --git a/.gitignore b/.gitignore
index 6d63646..10c745d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ dist/
coverage/
data/
output/
+public/
*.db
*.db-journal
*.db-shm
diff --git a/.nvmrc b/.nvmrc
index 209e3ef..2bd5a0a 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-20
+22
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7802f1c..cbb12ac 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,7 +2,7 @@
## Local checks
-Use Node.js 20 or newer.
+Use Node.js 22 LTS or newer.
```bash
npm ci
@@ -15,8 +15,15 @@ npm test
Use fixtures for changes that need repeatable data.
Do not add credentials, private repository data, or generated output.
+## Feature areas
+
+- Themes: add palettes in `src/theme/palette.ts`.
+- Deploy targets: add adapters in `src/deploy/`.
+- The publish step writes `site-manifest.json`.
+- Keep every behavior deterministic and covered by a test.
+
## Pull requests
Explain the user value and the data path.
List the checks that you ran.
-Keep public claims tied to repository evidence.
\ No newline at end of file
+Keep public claims tied to repository evidence.
diff --git a/README.md b/README.md
index f1a2a81..cf97725 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,16 @@
# EngineerProfile
-EngineerProfile builds a local engineering portfolio from public repository data.
-It stores repository metadata, commits, releases, privacy settings, and preview
-paths in SQLite. It publishes a static site from these records.
+EngineerProfile builds a local engineering portfolio from public repository data. It stores repository metadata, commits, releases, privacy settings, previews, and publish records in SQLite. It publishes a static site from these records.
## Value
-- Keep portfolio facts close to their source data.
- Refresh project cards from public GitHub repositories.
- Build release notes from releases or conventional commits.
- Capture repeatable project previews with Playwright.
- Hide projects and redact author emails before publication.
+- Choose a presentation theme and override its accent.
+- Publish a deployment manifest with each site build.
+- Copy the site to a local deploy target.
- Run one configured refresh from a scheduled workflow.
The fixture demo runs without secrets and without network access.
@@ -30,12 +30,15 @@ flowchart LR
L --> W[Publisher]
P --> W
S --> W
+ T[Theme] --> W
W --> O[Static output]
+ W --> M[Manifest]
+ O --> X[Deploy target]
```
| Area | Responsibility |
| --- | --- |
-| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, and privacy settings. |
+| `engineer-profile.config.json` | Store owner, presentation, theme, deploy, paths, and privacy settings. |
| `src/config/` | Validate checked-in JSON and merge safe defaults. |
| `src/refresh/` | Coordinate ingest, best-effort capture, and static publishing. |
| `src/ingest/` | Fetch public GitHub data and map it to records. |
@@ -43,15 +46,16 @@ flowchart LR
| `src/changelog/` | Prefer release notes and fall back to commit groups. |
| `src/privacy/` | Hide projects and block sensitive commit messages. |
| `src/preview/` | Capture fixed viewport screenshots with Playwright. |
-| `src/publish/` | Render HTML, changelog files, and preview assets. |
+| `src/theme/` | Resolve built-in palettes and theme overrides. |
+| `src/publish/` | Render HTML, changelog files, preview assets, and the manifest. |
+| `src/deploy/` | Copy the published snapshot to local targets. |
| `fixtures/` | Provide deterministic demo data and local preview pages. |
-The refresh command runs each stage in a fixed order.
-If a preview fails, the command reports the skip and keeps the rest of the snapshot.
+The refresh command runs each stage in a fixed order. If a preview fails, the command reports the skip and keeps the rest of the snapshot.
## Setup
-Use Node.js 20 or newer.
+Use Node.js 22 LTS or newer.
```bash
npm ci
@@ -61,18 +65,13 @@ npm run demo
Open `output/index.html` in a browser.
-The demo creates a local SQLite database under `data/`.
-It writes the static site under `output/`.
-Both directories are ignored by Git.
+The demo creates a local SQLite database under `data/`. It writes the static site under `output/`. Both directories are ignored by Git.
## Configuration
-`engineer-profile.config.json` is the checked-in source for scheduled refreshes.
-It sets the GitHub owner, site presentation, repository limit, paths, and privacy controls.
+`engineer-profile.config.json` is the checked-in source for scheduled refreshes. It sets the owner, presentation, theme, deploy targets, repository limit, paths, and privacy controls.
-The loader accepts repository limits from 1 through 100.
-It rejects malformed values before network access.
-CLI `--config`, `--data`, and `--output` options override file values.
+The loader rejects malformed values before network access. CLI `--config`, `--data`, and `--output` options override file values.
Run a network-backed refresh with the checked-in settings:
@@ -80,25 +79,74 @@ Run a network-backed refresh with the checked-in settings:
npm run refresh
```
-The refresh command reads public repositories, captures previews, publishes HTML,
-and reports skipped captures.
+The refresh reads public repositories, captures previews, publishes HTML, and reports skipped captures.
-GitHub ingestion uses the public API.
-Set `GITHUB_TOKEN` for a higher rate limit.
+GitHub ingestion uses the public API. Set `GITHUB_TOKEN` for a higher rate limit.
```powershell
$env:GITHUB_TOKEN="your-token"
npm run refresh
```
-Do not put a token in repository files.
-Use `.env.example` as a variable reference.
+Do not put a token in repository files. Use `.env.example` as a variable reference.
+
+## Themes
+
+The published site uses one built-in theme. Set `theme.name` in the configuration.
+
+| Name | Appearance |
+| --- | --- |
+| `deep-space` | Dark palette with blue accents. Default. |
+| `paper` | Light palette with dark text and strong contrast. |
+
+List built-in themes:
+
+```bash
+node dist/index.js themes
+```
+
+Override presentation tokens inside the theme:
+
+```json
+{
+ "theme": {
+ "name": "deep-space",
+ "accent": "#67b7ff",
+ "radius": "16px",
+ "font": "Inter, system-ui, sans-serif"
+ }
+}
+```
+
+`accent` must be a hex color. `radius` is a CSS length. `font` is a CSS font stack.
+
+## Deployment
+
+Each publish writes `site-manifest.json`. The manifest lists projects, files, and screenshots. Deployment tooling can read this file.
+
+Add a local deploy target:
+
+```json
+{
+ "deploy": {
+ "targets": [
+ { "name": "preview", "type": "local", "target": "public" }
+ ]
+ }
+}
+```
+
+Publish and deploy the snapshot:
+
+```bash
+node dist/index.js deploy
+```
+
+The refresh command also deploys when the configuration defines targets.
## Sample output
-The fixture set contains `signal-router` and `metrics-kit`.
-The first project has release notes.
-The second project uses commit-based notes.
+The fixture set contains `signal-router` and `metrics-kit`. The first project has release notes. The second project uses commit-based notes.
```text
Ingested 2 fixture projects.
@@ -109,8 +157,14 @@ Copied 2 available preview screenshots.
Open output/index.html in a browser.
```
-The site shows project facts, source links, changelog previews, and screenshots.
-The totals come from fixture fields and stored commit records.
+A deploy with one configured target prints:
+
+```text
+Published 2 projects to output/index.html.
+Deployed preview: 6 files to public.
+```
+
+The site shows project facts, source links, changelog previews, and screenshots. The totals come from fixture fields and stored commit records. The manifest records the same totals in machine-readable form.
## Commands
@@ -123,7 +177,9 @@ Build before direct CLI commands.
| `npm run ingest -- --fixture` | Load fixture records only. |
| `npm run capture -- --fixture` | Capture local fixture pages. |
| `npm run publish` | Rebuild the site from SQLite. |
-| `npm run refresh` | Run configured ingest, capture, and publish stages. |
+| `npm run deploy` | Publish and copy output to deploy targets. |
+| `node dist/index.js themes` | List built-in themes. |
+| `npm run refresh` | Run configured ingest, capture, publish, and deploy stages. |
| `node dist/index.js status` | Show visibility and recent operations. |
| `npm test` | Run deterministic unit and integration tests. |
| `npm run typecheck` | Validate TypeScript types. |
@@ -138,40 +194,32 @@ node dist/index.js privacy --hide demo-engineer-metrics-kit
npm run publish
```
-Show the project again with `privacy --show`.
-Hidden projects remain in SQLite.
-Hidden projects stay out of public HTML and copied assets.
-Author emails are redacted by default.
-Sensitive commit messages are skipped before storage.
+Show the project again with `privacy --show`. Hidden projects remain in SQLite. Hidden projects stay out of public HTML and copied assets. Author emails are redacted by default. Sensitive commit messages are skipped before storage.
## Audit model
-Each project stores a repository URL and its last pushed timestamp.
-Each stored commit keeps its SHA, message first line, date, and source URL.
-Each release keeps its tag, notes, date, and source URL.
+Each project stores a repository URL and its last pushed timestamp. Each stored commit keeps its SHA, message first line, date, and source URL. Each release keeps its tag, notes, date, and source URL.
-The site displays visible projects only.
-It links project cards to repositories.
-It links release notes to their release pages.
-It records local operations in an audit table.
+The site displays visible projects only. It links project cards to repositories. It links release notes to their release pages. It records local operations in an audit table.
## CI and test status
-The regular CI workflow runs typecheck, build, tests, the fixture demo, and artifact upload.
-The scheduled refresh workflow runs each Monday and supports manual dispatch.
-It uploads the generated site as a workflow artifact.
+The regular CI workflow runs typecheck, build, tests, the fixture demo, theme listing, and manifest validation. The scheduled refresh workflow runs each Monday and supports manual dispatch. It uploads the generated site as a workflow artifact.
The test suite covers these core behaviors:
- Configuration validation and default merging.
+- Theme resolution and color validation.
- Conventional commit parsing.
- Release-first changelog generation.
- SQLite upserts and changelog replacement.
- Privacy filtering and email redaction.
- Fixture ingestion and static publishing.
+- Deployment manifest generation.
+- Local deploy target copying.
- Configured refresh orchestration.
- Release source links.
-- Deterministic HTML output.
+- Deterministic HTML and manifest output.
- Playwright screenshot capture.
Run the local checks:
@@ -184,9 +232,7 @@ npm test
### Validation status
-Typecheck and build pass locally.
-CI runs the complete test suite on Ubuntu with Chromium installed.
-The fixture pipeline provides deterministic data for repeatable checks.
+Typecheck and build pass locally. CI runs the complete test suite on Ubuntu with Chromium installed. The fixture pipeline provides deterministic data for repeatable checks.
## Limitations
@@ -196,6 +242,8 @@ The fixture pipeline provides deterministic data for repeatable checks.
- Changelog quality depends on releases or conventional commits.
- External pages can fail during capture.
- Capture failures are reported and do not stop publishing.
+- Two built-in themes ship with the tool.
+- Only the local deploy target is available now.
- Publishing creates local files. It does not deploy them.
- Scheduled runs upload artifacts. They do not commit generated output.
@@ -205,8 +253,8 @@ The fixture pipeline provides deterministic data for repeatable checks.
| --- | --- | --- |
| v0.1 | Complete | Fixture demo, GitHub ingest, changelog, capture, publish, and privacy controls. |
| v0.2 | Complete | Checked-in configuration, coordinated refresh command, and scheduled artifact workflow. |
-| v0.3 | Next | Custom themes and deployment adapters. |
-| v0.4 | Later | Commit-diff summaries and an RSS feed. |
+| v0.3 | Complete | Custom themes, deployment manifest, and local deploy target. |
+| v0.4 | Next | Remote deploy targets, commit-diff summaries, and an RSS feed. |
## License
diff --git a/engineer-profile.config.json b/engineer-profile.config.json
index 81008fb..39832c2 100644
--- a/engineer-profile.config.json
+++ b/engineer-profile.config.json
@@ -5,6 +5,9 @@
"repositoryLimit": 5,
"dataDir": "data",
"outputDir": "output",
+ "theme": {
+ "name": "deep-space"
+ },
"privacy": {
"hiddenProjects": [],
"redactEmails": true,
diff --git a/package-lock.json b/package-lock.json
index 87f5d51..23ffbf1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "engineer-profile",
- "version": "0.2.0",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "engineer-profile",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.8.1",
diff --git a/package.json b/package.json
index d36edbe..c613aec 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "engineer-profile",
- "version": "0.2.0",
+ "version": "0.3.0",
"description": "Self-maintaining engineering portfolio from repository activity",
"type": "module",
"main": "dist/index.js",
diff --git a/src/config/loader.ts b/src/config/loader.ts
index 406896b..5985230 100644
--- a/src/config/loader.ts
+++ b/src/config/loader.ts
@@ -1,7 +1,8 @@
import { readFileSync } from "node:fs";
-import type { PortfolioConfig, PrivacyConfig } from "../types.js";
-import { DEFAULT_CONFIG, DEFAULT_PRIVACY } from "../types.js";
+import type { DeployConfig, DeployTarget, PortfolioConfig, PrivacyConfig, ThemeConfig } from "../types.js";
+import { DEFAULT_CONFIG, DEFAULT_DEPLOY, DEFAULT_PRIVACY, DEFAULT_THEME } from "../types.js";
import { mergePrivacy } from "../privacy/controls.js";
+import { isBuiltinTheme, isValidHexColor, listBuiltinThemes } from "../theme/palette.js";
export const DEFAULT_CONFIG_PATH = "engineer-profile.config.json";
@@ -56,6 +57,78 @@ function readPrivacy(source: ConfigValue): PrivacyConfig {
});
}
+function readTheme(source: ConfigValue): ThemeConfig {
+ if (!("theme" in source)) return DEFAULT_THEME;
+ if (!isConfigValue(source.theme)) {
+ throw new Error('Configuration field "theme" must be an object.');
+ }
+
+ const name = source.theme.name === undefined ? DEFAULT_THEME.name : readString(source.theme, "name", DEFAULT_THEME.name);
+ if (!isBuiltinTheme(name)) {
+ throw new Error(
+ `Configuration field "theme.name" must be one of: ${listBuiltinThemes().map((theme) => theme.name).join(", ")}.`
+ );
+ }
+ const theme: ThemeConfig = { name };
+
+ if ("accent" in source.theme) {
+ const accent = source.theme.accent;
+ if (typeof accent !== "string" || !isValidHexColor(accent)) {
+ throw new Error('Configuration field "theme.accent" must be a hex color like "#67b7ff".');
+ }
+ theme.accent = accent.trim();
+ }
+ if ("radius" in source.theme) {
+ const radius = source.theme.radius;
+ if (typeof radius !== "string" || radius.trim() === "") {
+ throw new Error('Configuration field "theme.radius" must be a non-empty CSS length.');
+ }
+ theme.radius = radius.trim();
+ }
+ if ("font" in source.theme) {
+ const font = source.theme.font;
+ if (typeof font !== "string" || font.trim() === "") {
+ throw new Error('Configuration field "theme.font" must be a non-empty font stack.');
+ }
+ theme.font = font.trim();
+ }
+ return theme;
+}
+
+function readDeploy(source: ConfigValue): DeployConfig {
+ if (!("deploy" in source)) return DEFAULT_DEPLOY;
+ if (!isConfigValue(source.deploy)) {
+ throw new Error('Configuration field "deploy" must be an object.');
+ }
+ if (!("targets" in source.deploy)) return DEFAULT_DEPLOY;
+
+ const targets = source.deploy.targets;
+ if (!Array.isArray(targets)) {
+ throw new Error('Configuration field "deploy.targets" must be a list.');
+ }
+
+ const parsedTargets: DeployTarget[] = targets.map((target, index) => {
+ if (!isConfigValue(target)) {
+ throw new Error(`Configuration field "deploy.targets[${index}]" must be an object.`);
+ }
+ const name = target.name;
+ const type = target.type;
+ const targetPath = target.target;
+ if (typeof name !== "string" || name.trim() === "") {
+ throw new Error(`Configuration field "deploy.targets[${index}].name" must be a non-empty string.`);
+ }
+ if (type !== "local") {
+ throw new Error(`Configuration field "deploy.targets[${index}].type" must be "local".`);
+ }
+ if (typeof targetPath !== "string" || targetPath.trim() === "") {
+ throw new Error(`Configuration field "deploy.targets[${index}].target" must be a non-empty path.`);
+ }
+ return { name: name.trim(), type: "local", target: targetPath.trim() };
+ });
+
+ return { targets: parsedTargets };
+}
+
export function loadPortfolioConfig(
path: string = DEFAULT_CONFIG_PATH,
clock: () => string = DEFAULT_CONFIG.clock
@@ -78,6 +151,8 @@ export function loadPortfolioConfig(
repositoryLimit: readLimit(parsed, "repositoryLimit", DEFAULT_CONFIG.repositoryLimit),
dataDir: readString(parsed, "dataDir", DEFAULT_CONFIG.dataDir),
outputDir: readString(parsed, "outputDir", DEFAULT_CONFIG.outputDir),
+ theme: readTheme(parsed),
+ deploy: readDeploy(parsed),
privacy: readPrivacy(parsed),
clock,
};
diff --git a/src/deploy/local.ts b/src/deploy/local.ts
new file mode 100644
index 0000000..684a8a4
--- /dev/null
+++ b/src/deploy/local.ts
@@ -0,0 +1,52 @@
+import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+import { openDatabase } from "../db/client.js";
+import type { PortfolioConfig } from "../types.js";
+
+export interface DeployResult {
+ targetName: string;
+ targetPath: string;
+ files: number;
+}
+
+function countFiles(targetPath: string): number {
+ let total = 0;
+ const walk = (current: string): void => {
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
+ if (entry.isDirectory()) walk(join(current, entry.name));
+ else total++;
+ }
+ };
+ walk(targetPath);
+ return total;
+}
+
+export function deployLocal(
+ config: PortfolioConfig,
+ targetName: string,
+ targetPath: string
+): DeployResult {
+ const indexPath = join(config.outputDir, "index.html");
+ if (!existsSync(indexPath)) {
+ throw new Error(`No published site found at "${config.outputDir}". Run publish first.`);
+ }
+
+ mkdirSync(targetPath, { recursive: true });
+ cpSync(config.outputDir, targetPath, { recursive: true });
+ const files = countFiles(targetPath);
+
+ const db = openDatabase(config.dataDir, config.clock);
+ try {
+ db.logIngest("deploy", `${targetName} -> ${targetPath}`);
+ } finally {
+ db.close();
+ }
+
+ return { targetName, targetPath, files };
+}
+
+export function deployAll(config: PortfolioConfig): DeployResult[] {
+ return config.deploy.targets
+ .filter((target) => target.type === "local")
+ .map((target) => deployLocal(config, target.name, target.target));
+}
diff --git a/src/index.ts b/src/index.ts
index aea5c27..0f3b848 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,19 +4,21 @@ import { join } from "node:path";
import { Command } from "commander";
import { ingestOwnerRepos, ingestRepository } from "./ingest/orchestrator.js";
import { captureAllProjects, captureLocalHtml, closeBrowser } from "./preview/capture.js";
-import { copyScreenshotsToOutput, publishSite } from "./publish/site.js";
+import { publishSite } from "./publish/site.js";
import { loadAllFixtures } from "./fixtures/loader.js";
import { openDatabase } from "./db/client.js";
import { DEFAULT_CONFIG, type PortfolioConfig } from "./types.js";
import { DEFAULT_CONFIG_PATH, loadPortfolioConfig } from "./config/loader.js";
import { refreshPortfolio } from "./refresh/run.js";
+import { listBuiltinThemes } from "./theme/palette.js";
+import { deployAll } from "./deploy/local.js";
const program = new Command();
program
.name("engineer-profile")
.description("Build a local engineering portfolio from public repository evidence")
- .version("0.2.0");
+ .version("0.3.0");
function resolveConfig(options: { config?: string; data?: string; output?: string }): PortfolioConfig {
const base = options.config
@@ -64,9 +66,8 @@ addConfigOption(program
}
const result = publishSite(config);
- const copied = copyScreenshotsToOutput(config);
console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`);
- console.log(`Copied ${copied} available preview screenshots.`);
+ console.log(`Copied ${result.screenshotsCopied} available preview screenshots.`);
console.log("Open output/index.html in a browser.");
}));
@@ -128,11 +129,39 @@ addConfigOption(program
.action((options) => {
const config = resolveConfig(options);
const result = publishSite(config);
- const copied = copyScreenshotsToOutput(config);
console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`);
- console.log(`Copied ${copied} available preview screenshots.`);
+ console.log(`Copied ${result.screenshotsCopied} available preview screenshots.`);
}));
+addConfigOption(program
+ .command("deploy")
+ .description("Publish the snapshot and copy it to configured deploy targets")
+ .option("-d, --data
@@ -307,7 +348,7 @@ export function publishSite(config: PortfolioConfig): PublishResult {
Auditable snapshot. Sourced from public repository metadata, commits, and releases. Source: auditable SQLite data. ${languages.length ? `Languages: ${languages.map(escapeHtml).join(", ")}.` : "No language data was provided."}