diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae27d9..541e0bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,10 +44,35 @@ 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, commit trail, and RSS feed + run: node scripts/verify-manifest.mjs + + - name: Preview local deploy + run: node dist/index.js deploy --dry-run + + - name: Verify JSON deployment report + run: | + node dist/index.js deploy --dry-run --json > deploy-preview.json + node scripts/verify-deploy-report.mjs + + - name: Sync local deploy and verify report + run: | + node dist/index.js deploy --json > deploy-sync.json + DEPLOY_REPORT=deploy-sync.json node scripts/verify-deploy-report.mjs + + - name: Check deployed snapshot + run: node scripts/verify-deploy.mjs + - name: Store demo output if: success() uses: actions/upload-artifact@v4 with: name: engineer-profile-demo - path: output/ - if-no-files-found: error \ No newline at end of file + path: | + output/ + deploy-preview.json + deploy-sync.json + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 6d63646..e50c317 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ coverage/ data/ output/ +/deploy/ *.db *.db-journal *.db-shm @@ -12,7 +13,9 @@ output/ !.env.example playwright-report/ test-results/ +deploy-preview.json +deploy-sync.json .cache/ .DS_Store Thumbs.db -*.log \ No newline at end of file +*.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7802f1c..04622c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,9 +14,20 @@ npm test Use fixtures for changes that need repeatable data. Do not add credentials, private repository data, or generated output. +Run `node dist/index.js themes` after theme changes. +Run `node dist/index.js themes --preview` after gallery changes. +Check that `output/index.html` applies the theme font and radius tokens. +Verify `output/site-manifest.json` after publish changes. +Check the deploy sync with `node dist/index.js deploy`. +Run `node dist/index.js deploy --dry-run` before changing a deploy target. +Run `node dist/index.js deploy --dry-run --json` to inspect the report contract. +Run `node scripts/verify-manifest.mjs` and `node scripts/verify-deploy.mjs` after CI-only changes. +Run `node scripts/verify-deploy-report.mjs` after deployment report changes. +Verify `output/feed.xml` after changelog or feed changes. +Check `output/*-changes.md` after commit-diff changes. ## 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..c290fbc 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,16 @@ paths in SQLite. It publishes a static site from these records. - Build release notes from releases or conventional commits. - Capture repeatable project previews with Playwright. - Hide projects and redact author emails before publication. +- Choose a built-in theme and override accent, radius, and font. + The published page applies every override to its CSS tokens. +- Compare every theme in a generated gallery page. +- Summarize commit activity between release windows. +- Publish an RSS feed from visible projects. +- Deploy the snapshot to configured local targets. +- Sync targets and remove stale files before verification. +- Preview local deployment changes before synchronization. +- Generate JSON deployment reports for CI and release tooling. +- Record a machine-readable manifest with every publish. - Run one configured refresh from a scheduled workflow. The fixture demo runs without secrets and without network access. @@ -31,19 +41,28 @@ flowchart LR P --> W S --> W W --> O[Static output] + T[Theme] --> W + D --> F[Diff summaries] + F --> W + W --> M[Manifest] + W --> X[RSS feed] + M --> K[Deploy] + X --> K ``` | Area | Responsibility | | --- | --- | -| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, and privacy settings. | +| `engineer-profile.config.json` | Store owner, presentation, refresh, theme, feed, 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/theme/` | Resolve built-in themes, emit CSS variables, and render a theme gallery. | +| `src/changelog/` | Prefer release notes, fall back to commit groups, and summarize commit diffs. | +| `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/publish/` | Render HTML, changelog files, commit-trail files, the theme gallery, the RSS feed, the site manifest, and preview assets. | +| `src/deploy/` | Compare snapshots, compute digests, create reports, and sync local targets. | | `fixtures/` | Provide deterministic demo data and local preview pages. | The refresh command runs each stage in a fixed order. @@ -60,6 +79,9 @@ npm run demo ``` Open `output/index.html` in a browser. +Open `output/theme-gallery.html` to compare the built-in themes. +Open `output/feed.xml` to inspect the RSS feed. +Run `node dist/index.js deploy --dry-run` to preview target changes. The demo creates a local SQLite database under `data/`. It writes the static site under `output/`. @@ -68,12 +90,109 @@ 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, feed, 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. +### Theme + +Set the theme with a name from the built-in catalog. + +```json +{ + "theme": { + "name": "deep-space", + "accent": "#67b7ff", + "radius": "16px", + "font": "Inter, system-ui, sans-serif" + } +} +``` + +`name` selects a built-in theme. +`accent` sets the primary color. +`radius` sets the corner radius. +`font` sets the base font stack. +All overrides are optional. + +The site applies the theme tokens to its generated CSS. +`--font` sets the base font of the page body. +`--radius` sets the corner radius of cards and panels. +The publish step writes these tokens into every generated page. + +Run `node dist/index.js themes` to list the catalog. +The catalog contains `deep-space`, `paper`, and `terminal`. +The loader rejects unknown theme names and invalid accent colors. + +Run `node dist/index.js themes --preview` to write a theme gallery page. +The page renders every theme with its real tokens. +It includes a `configured` card for your overrides. +Every published snapshot also contains `theme-gallery.html`. + +### Feed + +The publish step writes `output/feed.xml` as an RSS 2.0 document. +Each visible project becomes one feed item. +Items use the project description, the latest changelog signal, and the repository link. + +Set the feed base URL for a hosted site. + +```json +{ + "feed": { + "baseUrl": "https://portfolio.example.com" + } +} +``` + +Without a base URL, the feed links to `https://github.com/`. +The loader rejects feed base URLs that are not absolute http(s) URLs. + +### Deploy + +Deploy syncs the published output to one or more local targets. +A target must live outside the output directory. +Deploy removes stale files first, then copies the new snapshot. +It copies the snapshot, then verifies the key output files. +Each deploy records an audit entry with the file counts. +Each report records a SHA-256 digest for the source and target snapshots. + +Deployment previews compare file paths and SHA-256 content hashes. +Use the dry run before a local sync. +It reports added, changed, removed, and unchanged files. +It does not create or modify the target. + +Use `--json` when another tool must read the deployment result. +Preview reports include file changes and snapshot digests. +Sync reports include file counts, verification, and matching digests. + +```json +{ + "deploy": { + "targets": [ + { + "name": "public", + "type": "local", + "target": "deploy/public" + } + ] + } +} +``` + +The refresh command deploys configured targets after publishing. +Run `node dist/index.js deploy` to publish and deploy in one step. +The command reports the file count, the removed stale count, and the verification state. +Add `--json` to print a stable report instead of human-readable lines. + +The report verifier prints this result: + +```text +Deploy report ok: 1 target(s), mode preview. +``` + Run a network-backed refresh with the checked-in settings: ```bash @@ -81,7 +200,7 @@ npm run refresh ``` The refresh command reads public repositories, captures previews, publishes HTML, -and reports skipped captures. +deploys configured targets, and reports skipped captures. GitHub ingestion uses the public API. Set `GITHUB_TOKEN` for a higher rate limit. @@ -106,12 +225,31 @@ Captured demo-engineer-signal-router. Captured demo-engineer-metrics-kit. Published 2 projects to output/index.html. Copied 2 available preview screenshots. +Wrote theme gallery to output/theme-gallery.html. +Wrote RSS feed to output/feed.xml. +Preview public: changed (0 added, 3 changed, 0 removed, 7 unchanged). + ~ feed.xml + ~ index.html + ~ site-manifest.json Open output/index.html in a browser. ``` +![Demo output](docs/demo-index.png) + +![Theme gallery](docs/theme-gallery.png) + The site shows project facts, source links, changelog previews, and screenshots. The totals come from fixture fields and stored commit records. +The index renders a commit trail below the project cards. +The trail groups stored commits into release windows. +Each window lists its commit count and change types. +The full detail lives in `output/-changes.md`. + +The demo also writes `output/site-manifest.json`. +The manifest records the theme details, project list, generated files, and the RSS feed. +The deployment report records the target path and snapshot identity. + ## Commands Build before direct CLI commands. @@ -123,12 +261,20 @@ 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 gallery` | Build and write the theme gallery page. | +| `npm run refresh` | Run configured ingest, capture, publish, and deploy stages. | +| `node dist/index.js deploy` | Publish the snapshot and sync it to configured targets. | +| `node dist/index.js deploy --dry-run` | Publish the snapshot and preview target changes without syncing. | +| `node dist/index.js deploy --dry-run --json` | Print a machine-readable preview report. | +| `node dist/index.js themes` | List built-in presentation themes. | +| `node dist/index.js themes --preview` | Write a theme gallery HTML page. | | `node dist/index.js status` | Show visibility and recent operations. | | `npm test` | Run deterministic unit and integration tests. | | `npm run typecheck` | Validate TypeScript types. | | `npm run build` | Compile the CLI to `dist/`. | +Publish, refresh, and deploy write the RSS feed and commit-trail files with the snapshot. + ## Privacy controls Hide a project before publishing. @@ -154,10 +300,13 @@ 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 operations keep their target name and verification state in the audit trail. ## 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 +verification, manifest verification, RSS feed verification, local deploy verification, +deployment report verification, and artifact upload. The scheduled refresh workflow runs each Monday and supports manual dispatch. It uploads the generated site as a workflow artifact. @@ -166,12 +315,20 @@ The test suite covers these core behaviors: - Configuration validation and default merging. - Conventional commit parsing. - Release-first changelog generation. +- Commit-diff summaries and release-window bucketing. +- RSS feed rendering, escaping, and determinism. - SQLite upserts and changelog replacement. - Privacy filtering and email redaction. - Fixture ingestion and static publishing. - Configured refresh orchestration. - Release source links. -- Deterministic HTML output. +- Deterministic HTML, manifest, gallery, and feed output. +- Theme resolution, CSS variable emission, and gallery rendering. +- Generated CSS token application for font and radius. +- Local deploy sync, stale cleanup, and verification. +- Snapshot previews with deterministic added, changed, removed, and unchanged file lists. +- Snapshot digests and preview or sync deployment reports. +- Site manifest versioning, determinism, and theme details. - Playwright screenshot capture. Run the local checks: @@ -185,8 +342,12 @@ npm test ### Validation status Typecheck and build pass locally. -CI runs the complete test suite on Ubuntu with Chromium installed. +The restricted Windows sandbox blocks Vitest's esbuild child process. +CI runs the full test suite on Ubuntu. +CI installs Chromium before Playwright checks. The fixture pipeline provides deterministic data for repeatable checks. +The demo output feeds two verification scripts in CI. +They check the site manifest, the generated CSS tokens, the RSS feed, and the deployed snapshot. ## Limitations @@ -194,9 +355,16 @@ The fixture pipeline provides deterministic data for repeatable checks. - Public API calls have rate limits without a token. - Capture needs a local Chromium installation. - Changelog quality depends on releases or conventional commits. +- Commit-diff windows use commit dates. Release dates set the boundaries. - External pages can fail during capture. - Capture failures are reported and do not stop publishing. - Publishing creates local files. It does not deploy them. +- Themes offer built-in palettes, selected overrides, and a generated gallery. +- Local deploy syncs files. It does not push to hosts. +- Snapshot digests identify local bytes. They do not verify a remote host. +- Remote deploy adapters are not implemented. +- A dry run publishes the local snapshot before comparison. +- The RSS feed uses the configured base URL or the GitHub profile. - Scheduled runs upload artifacts. They do not commit generated output. ## Roadmap @@ -205,8 +373,12 @@ 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, theme overrides, generated CSS tokens for font and radius, theme gallery page, versioned site manifest, and local deploy sync. | +| v0.4 | Complete | Commit-diff summaries, a commit trail on the site, and an RSS feed. | +| v0.5 | Complete | Local deployment previews with deterministic file and content diffs. | +| v0.6 | Complete | Snapshot digests, JSON deployment reports, and CI report verification. | +| v0.7 | Next | Remote deploy adapters with provider-specific credentials and verification. | +| v0.8 | Later | Release brief digests and changelog archiving. | ## License diff --git a/docs/demo-index.png b/docs/demo-index.png new file mode 100644 index 0000000..f4643ea Binary files /dev/null and b/docs/demo-index.png differ diff --git a/docs/theme-gallery.png b/docs/theme-gallery.png new file mode 100644 index 0000000..afabe74 Binary files /dev/null and b/docs/theme-gallery.png differ diff --git a/engineer-profile.config.json b/engineer-profile.config.json index 81008fb..b143262 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/public" + } + ] + }, "privacy": { "hiddenProjects": [], "redactEmails": true, diff --git a/package-lock.json b/package-lock.json index 87f5d51..b75eebd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.6.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..5e2342b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "engineer-profile", - "version": "0.2.0", + "version": "0.6.0", "description": "Self-maintaining engineering portfolio from repository activity", "type": "module", "main": "dist/index.js", @@ -18,6 +18,7 @@ "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", + "gallery": "npm run build && node dist/index.js themes --preview", "refresh": "npm run build && node dist/index.js refresh" }, "engines": { diff --git a/scripts/verify-deploy-report.mjs b/scripts/verify-deploy-report.mjs new file mode 100644 index 0000000..4d504b6 --- /dev/null +++ b/scripts/verify-deploy-report.mjs @@ -0,0 +1,83 @@ +import { readFileSync } from "node:fs"; + +const reportPath = process.env.DEPLOY_REPORT ?? "deploy-preview.json"; +const digestPattern = /^[a-f0-9]{64}$/; + +function fail(message) { + console.error("Verify failed: " + message); + process.exit(1); +} + +let report; +try { + report = JSON.parse(readFileSync(reportPath, "utf-8").replace(/^\uFEFF/, "")); +} catch { + fail("could not read " + reportPath); +} + +if (report === null || typeof report !== "object" || Array.isArray(report)) { + fail("report must be an object"); +} + +if (report.formatVersion !== 1) { + fail("expected formatVersion 1, got " + report.formatVersion); +} +if (report.mode !== "preview" && report.mode !== "sync") { + fail("unknown report mode " + report.mode); +} +if (typeof report.generatedAt !== "string" || report.generatedAt === "") { + fail("generatedAt is missing"); +} +if (typeof report.outputDir !== "string" || report.outputDir === "") { + fail("outputDir is missing"); +} +if (!Array.isArray(report.targets)) { + fail("targets is missing"); +} + +for (const target of report.targets) { + if (target === null || typeof target !== "object") { + fail("target must be an object"); + } + if (typeof target.targetName !== "string" || target.targetName === "") { + fail("targetName is missing"); + } + if (typeof target.targetPath !== "string" || target.targetPath === "") { + fail("targetPath is missing for " + target.targetName); + } + if (!digestPattern.test(target.sourceDigest ?? "")) { + fail("sourceDigest is invalid for " + target.targetName); + } + if (report.mode === "preview") { + if (target.status !== "clean" && target.status !== "changed") { + fail("status is invalid for " + target.targetName); + } + if (!Array.isArray(target.added) || !Array.isArray(target.changed) || !Array.isArray(target.removed)) { + fail("file changes are missing for " + target.targetName); + } + if (target.targetDigest !== null && !digestPattern.test(target.targetDigest ?? "")) { + fail("targetDigest is invalid for " + target.targetName); + } + if (target.status === "clean" && target.sourceDigest !== target.targetDigest) { + fail("clean target digest does not match source for " + target.targetName); + } + continue; + } + if (!Number.isInteger(target.files) || target.files < 0) { + fail("files is invalid for " + target.targetName); + } + if (!Number.isInteger(target.removed) || target.removed < 0) { + fail("removed is invalid for " + target.targetName); + } + if (target.verified !== true) { + fail("target is not verified for " + target.targetName); + } + if (!digestPattern.test(target.targetDigest ?? "")) { + fail("targetDigest is invalid for " + target.targetName); + } + if (target.sourceDigest !== target.targetDigest) { + fail("synced target digest does not match source for " + target.targetName); + } +} + +console.log("Deploy report ok: " + report.targets.length + " target(s), mode " + report.mode + "."); diff --git a/scripts/verify-deploy.mjs b/scripts/verify-deploy.mjs new file mode 100644 index 0000000..3b4a108 --- /dev/null +++ b/scripts/verify-deploy.mjs @@ -0,0 +1,26 @@ +import { existsSync, readFileSync } from "node:fs"; + +const DEPLOY_DIR = process.env.DEPLOY_DIR ?? "deploy/public"; + +function fail(message) { + console.error(`Verify failed: ${message}`); + process.exit(1); +} + +for (const file of ["index.html", "site-manifest.json", "theme-gallery.html", "feed.xml"]) { + if (!existsSync(`${DEPLOY_DIR}/${file}`)) { + fail(`deployed snapshot is missing ${file}`); + } +} + +let manifest; +try { + manifest = JSON.parse(readFileSync(`${DEPLOY_DIR}/site-manifest.json`, "utf-8")); +} catch { + fail("deployed site-manifest.json is invalid"); +} +if (manifest.projectCount !== 2) { + fail(`expected deployed projectCount 2, got ${manifest.projectCount}`); +} + +console.log(`Deploy ok: ${DEPLOY_DIR} contains the published snapshot (${manifest.projectCount} projects).`); diff --git a/scripts/verify-manifest.mjs b/scripts/verify-manifest.mjs new file mode 100644 index 0000000..16c7c3e --- /dev/null +++ b/scripts/verify-manifest.mjs @@ -0,0 +1,121 @@ +import { readFileSync } from "node:fs"; + +const OUTPUT = process.env.OUTPUT_DIR ?? "output"; + +function fail(message) { + console.error(`Verify failed: ${message}`); + process.exit(1); +} + +function readJson(relativePath) { + try { + return JSON.parse(readFileSync(`${OUTPUT}/${relativePath}`, "utf-8")); + } catch { + fail(`could not read ${relativePath}`); + } +} + +const manifest = readJson("site-manifest.json"); + +if (manifest.formatVersion !== 3) { + fail(`expected formatVersion 3, got ${manifest.formatVersion}`); +} +if (typeof manifest.projectCount !== "number") { + fail("projectCount is missing"); +} +if (manifest.projectCount !== 2) { + fail(`expected projectCount 2, got ${manifest.projectCount}`); +} +if (typeof manifest.owner !== "string" || manifest.owner === "") { + fail("owner is missing"); +} +if (manifest.feed !== "feed.xml") { + fail(`expected feed feed.xml, got ${manifest.feed}`); +} +if (!Array.isArray(manifest.files) || !manifest.files.includes("index.html")) { + fail("files does not list index.html"); +} +if (!manifest.files.includes("site-manifest.json")) { + fail("files does not list site-manifest.json"); +} +if (!manifest.files.includes("theme-gallery.html")) { + fail("files does not list theme-gallery.html"); +} +if (!manifest.files.includes("feed.xml")) { + fail("files does not list feed.xml"); +} +for (const project of manifest.projects) { + if (project.changesFile && !manifest.files.includes(project.changesFile)) { + fail(`files does not list ${project.changesFile}`); + } +} +if (!Array.isArray(manifest.screenshots)) { + fail("screenshots is missing"); +} + +const theme = manifest.theme; +if (typeof theme !== "object" || theme === null) { + fail("theme is missing"); +} +for (const field of ["name", "mode", "accent", "radius", "font"]) { + if (typeof theme[field] !== "string" || theme[field] === "") { + fail(`theme.${field} is missing`); + } +} + +let gallery; +try { + gallery = readFileSync(`${OUTPUT}/theme-gallery.html`, "utf-8"); +} catch { + fail("theme-gallery.html was not generated"); +} +for (const name of ["deep-space", "paper", "terminal"]) { + if (!gallery.includes(name)) { + fail(`theme gallery does not render ${name}`); + } +} + +let feed; +try { + feed = readFileSync(`${OUTPUT}/feed.xml`, "utf-8"); +} catch { + fail("feed.xml was not generated"); +} +if (!feed.includes("/g) || []).length !== manifest.projectCount) { + fail(`feed.xml does not contain one item per project (${manifest.projectCount})`); +} + +let indexHtml; +try { + indexHtml = readFileSync(`${OUTPUT}/index.html`, "utf-8"); +} catch { + fail("index.html was not generated"); +} +if (!indexHtml.includes("Commit trail")) { + fail("index.html does not render the commit trail"); +} +if (!indexHtml.includes("font-family: var(--font);")) { + fail("index.html does not apply the theme font token"); +} +const projectCardRule = indexHtml.slice( + indexHtml.indexOf(".project-card"), + indexHtml.indexOf(".project-card") + 400 +); +if (!projectCardRule.includes("border-radius: var(--radius);")) { + fail("index.html does not apply the theme radius token to project cards"); +} +if (!indexHtml.includes("--font:")) { + fail("index.html does not declare a theme font variable"); +} +if (!indexHtml.includes("--radius:")) { + fail("index.html does not declare a theme radius variable"); +} + +console.log( + `Manifest ok: ${manifest.projectCount} projects, theme ${theme.name} (${theme.mode}), ` + + `gallery ${manifest.files.includes("theme-gallery.html") ? "present" : "absent"}, ` + + `feed ${manifest.files.includes("feed.xml") ? "present" : "absent"}` +); diff --git a/src/changelog/diff.ts b/src/changelog/diff.ts new file mode 100644 index 0000000..b12d263 --- /dev/null +++ b/src/changelog/diff.ts @@ -0,0 +1,185 @@ +import { parseConventionalCommit } from "./generator.js"; + +export interface DiffCommit { + sha: string; + message: string; + committed_at: string; +} + +export interface DiffVersion { + version: string; + title: string; + published_at: string; +} + +export const DIFF_TYPE_ORDER = [ + "feat", + "fix", + "perf", + "refactor", + "docs", + "test", + "chore", + "build", + "ci", +] as const; + +export type DiffTypeName = (typeof DIFF_TYPE_ORDER)[number]; + +export interface DiffCounts { + feat: number; + fix: number; + perf: number; + refactor: number; + docs: number; + test: number; + chore: number; + build: number; + ci: number; + other: number; +} + +export const UNRELEASED_VERSION = "unreleased"; +export const UNRELEASED_TITLE = "Unreleased"; + +export interface CommitDiffSummary { + version: string; + title: string; + publishedAt: string; + commits: DiffCommit[]; + counts: DiffCounts; +} + +export function emptyDiffCounts(): DiffCounts { + return { + feat: 0, + fix: 0, + perf: 0, + refactor: 0, + docs: 0, + test: 0, + chore: 0, + build: 0, + ci: 0, + other: 0, + }; +} + +function countsFor(commits: DiffCommit[]): DiffCounts { + const counts = emptyDiffCounts(); + for (const commit of commits) { + const parsed = parseConventionalCommit(commit.sha, commit.message); + if (parsed && (DIFF_TYPE_ORDER as readonly string[]).includes(parsed.type)) { + counts[parsed.type as DiffTypeName]++; + } else { + counts.other++; + } + } + return counts; +} + +export function summarizeCommitDiffs( + commits: DiffCommit[], + versions: DiffVersion[] +): CommitDiffSummary[] { + const orderedCommits = [...commits].sort( + (a, b) => + a.committed_at.localeCompare(b.committed_at) || a.sha.localeCompare(b.sha) + ); + const orderedVersions = [...versions].sort( + (a, b) => + a.published_at.localeCompare(b.published_at) || + a.version.localeCompare(b.version) + ); + + const summaries: CommitDiffSummary[] = []; + + for (let index = 0; index < orderedVersions.length; index++) { + const version = orderedVersions[index]; + const start = index === 0 ? null : orderedVersions[index - 1].published_at; + const inWindow = orderedCommits.filter( + (commit) => + (start === null || commit.committed_at > start) && + commit.committed_at <= version.published_at + ); + if (inWindow.length === 0) continue; + summaries.push({ + version: version.version, + title: version.title, + publishedAt: version.published_at, + commits: [...inWindow].reverse(), + counts: countsFor(inWindow), + }); + } + + const newest = orderedVersions[orderedVersions.length - 1]; + const unreleased = newest + ? orderedCommits.filter((commit) => commit.committed_at > newest.published_at) + : orderedCommits; + if (unreleased.length > 0) { + summaries.push({ + version: UNRELEASED_VERSION, + title: UNRELEASED_TITLE, + publishedAt: unreleased[unreleased.length - 1].committed_at, + commits: [...unreleased].reverse(), + counts: countsFor(unreleased), + }); + } + + return summaries.reverse(); +} + +const TYPE_LABELS: Record = { + feat: "feature", + fix: "fix", + perf: "perf", + refactor: "refactor", + docs: "docs", + test: "test", + chore: "chore", + build: "build", + ci: "ci", +}; + +export function describeDiffCounts(counts: DiffCounts): string { + const parts: string[] = []; + if (counts.other > 0) { + parts.push(`${counts.other} other`); + } + for (const type of DIFF_TYPE_ORDER) { + const count = counts[type]; + if (count === 0) continue; + const label = TYPE_LABELS[type]; + parts.push(`${count} ${label}${count === 1 ? "" : "s"}`); + } + return parts.length > 0 ? parts.join(", ") : "no typed changes"; +} + +export function formatCommitDiffMarkdown( + projectName: string, + summaries: CommitDiffSummary[] +): string { + if (summaries.length === 0) { + return `# ${projectName}\n\nNo commit activity is recorded.\n`; + } + + const parts = [`# ${projectName} / Commit trail`, ""]; + for (const summary of summaries) { + const versionLabel = + summary.version === UNRELEASED_VERSION + ? UNRELEASED_TITLE + : `${summary.title} (${summary.version})`; + parts.push(`## ${versionLabel}`); + parts.push(`*${summary.publishedAt.slice(0, 10)}*`); + parts.push(""); + parts.push( + `Commits: ${summary.commits.length}. ${describeDiffCounts(summary.counts)}.` + ); + parts.push(""); + for (const commit of summary.commits) { + parts.push(`- ${commit.message} (\`${commit.sha.slice(0, 7)}\`)`); + } + parts.push(""); + } + return parts.join("\n").trim() + "\n"; +} diff --git a/src/config/loader.ts b/src/config/loader.ts index 406896b..3fecfcf 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, FeedConfig, PortfolioConfig, PrivacyConfig, ThemeConfig } from "../types.js"; +import { DEFAULT_CONFIG, DEFAULT_DEPLOY, DEFAULT_FEED, 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,93 @@ 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 }; +} + +function readFeed(source: ConfigValue): FeedConfig { + if (!("feed" in source)) return DEFAULT_FEED; + if (!isConfigValue(source.feed)) { + throw new Error('Configuration field "feed" must be an object.'); + } + const feed: FeedConfig = {}; + if ("baseUrl" in source.feed) { + const baseUrl = source.feed.baseUrl; + if (typeof baseUrl !== "string" || !/^https?:\/\/.+/i.test(baseUrl.trim())) { + throw new Error('Configuration field "feed.baseUrl" must be an absolute http(s) URL.'); + } + feed.baseUrl = baseUrl.trim().replace(/\/+$/, ""); + } + return feed; +} + export function loadPortfolioConfig( path: string = DEFAULT_CONFIG_PATH, clock: () => string = DEFAULT_CONFIG.clock @@ -78,6 +166,9 @@ 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), + feed: readFeed(parsed), privacy: readPrivacy(parsed), clock, }; diff --git a/src/db/client.ts b/src/db/client.ts index eb64f8b..536a9ec 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -274,6 +274,27 @@ export class PortfolioDatabase { }>; } + getCommitsAscending(projectId: number, limit?: number): Array<{ + sha: string; + message: string; + author_name: string | null; + committed_at: string; + url: string; + }> { + const query = "SELECT sha, message, author_name, committed_at, url FROM commits WHERE project_id = ? ORDER BY committed_at ASC, id ASC"; + return ( + limit === undefined + ? this.db.prepare(query).all(projectId) + : this.db.prepare(`${query} LIMIT ?`).all(projectId, limit) + ) as Array<{ + sha: string; + message: string; + author_name: string | null; + committed_at: string; + url: string; + }>; + } + countCommits(projectId: number): number { const row = this.db .prepare("SELECT COUNT(*) AS count FROM commits WHERE project_id = ?") diff --git a/src/deploy/index.ts b/src/deploy/index.ts new file mode 100644 index 0000000..5194c55 --- /dev/null +++ b/src/deploy/index.ts @@ -0,0 +1,38 @@ +import type { DeployTarget, PortfolioConfig } from "../types.js"; +import { deployLocal } from "./local.js"; +import type { DeployResult } from "./local.js"; +import { previewLocal } from "./preview.js"; +import type { DeployPreview } from "./preview.js"; + +export type { DeployResult } from "./local.js"; +export type { DeployPreview } from "./preview.js"; +export { createDeployReport } from "./report.js"; +export type { DeployReport, DeployReportMode, DeployReportTarget } from "./report.js"; + +export function deployToTarget( + config: PortfolioConfig, + target: DeployTarget +): DeployResult { + if (target.type === "local") { + return deployLocal(config, target); + } + throw new Error(`Unknown deploy adapter "${target.type}".`); +} + +export function deployAll(config: PortfolioConfig): DeployResult[] { + return config.deploy.targets.map((target) => deployToTarget(config, target)); +} + +export function previewToTarget( + config: PortfolioConfig, + target: DeployTarget +): DeployPreview { + if (target.type === "local") { + return previewLocal(config, target); + } + throw new Error(`Unknown deploy adapter "${target.type}".`); +} + +export function previewAll(config: PortfolioConfig): DeployPreview[] { + return config.deploy.targets.map((target) => previewToTarget(config, target)); +} diff --git a/src/deploy/local.ts b/src/deploy/local.ts new file mode 100644 index 0000000..74ed0b1 --- /dev/null +++ b/src/deploy/local.ts @@ -0,0 +1,76 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { openDatabase } from "../db/client.js"; +import type { DeployTarget, PortfolioConfig } from "../types.js"; +import { digestSnapshot, listSnapshotPaths } from "./snapshot.js"; + +export interface DeployResult { + targetName: string; + targetPath: string; + files: number; + removed: number; + verified: boolean; + sourceDigest: string | null; + targetDigest: string | null; +} + +function isPathInside(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +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.`); + } + + 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 }); + const outputFiles = listSnapshotPaths(outputRoot); + const existingFiles = listSnapshotPaths(targetRoot); + + let removed = 0; + for (const relative of existingFiles) { + if (outputFiles.includes(relative)) continue; + rmSync(join(targetRoot, relative), { force: true }); + removed++; + } + + cpSync(config.outputDir, target.target, { recursive: true, force: true }); + const files = listSnapshotPaths(targetRoot).length; + const hasRequiredFiles = + existsSync(join(targetRoot, "index.html")) && + existsSync(join(targetRoot, "site-manifest.json")); + const sourceDigest = digestSnapshot(outputRoot); + const targetDigest = digestSnapshot(targetRoot); + const verified = hasRequiredFiles && sourceDigest === targetDigest; + + const db = openDatabase(config.dataDir, config.clock); + try { + const detail = `${target.name} -> ${target.target} (${files} files, ${removed} removed, ${verified ? "verified" : "unverified"})`; + db.logIngest("deploy", detail); + } finally { + db.close(); + } + + return { + targetName: target.name, + targetPath: target.target, + files, + removed, + verified, + sourceDigest, + targetDigest, + }; +} diff --git a/src/deploy/preview.ts b/src/deploy/preview.ts new file mode 100644 index 0000000..1d6e3fb --- /dev/null +++ b/src/deploy/preview.ts @@ -0,0 +1,40 @@ +import { existsSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import type { DeployTarget, PortfolioConfig } from "../types.js"; +import { compareSnapshots, type SnapshotDiff } from "./snapshot.js"; + +export interface DeployPreview extends SnapshotDiff { + targetName: string; + targetPath: string; +} + +function isPathInside(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function validatePreview(config: PortfolioConfig, target: DeployTarget): { outputRoot: string; targetRoot: string } { + const outputRoot = resolve(config.outputDir); + const targetRoot = resolve(target.target); + if (!existsSync(join(config.outputDir, "index.html"))) { + throw new Error(`No published site found at "${config.outputDir}". Run publish first.`); + } + if (isPathInside(outputRoot, targetRoot)) { + throw new Error( + `Deploy target "${target.name}" must be outside the output directory "${config.outputDir}".` + ); + } + if (existsSync(targetRoot) && !statSync(targetRoot).isDirectory()) { + throw new Error(`Deploy target "${target.name}" is not a directory: "${target.target}".`); + } + return { outputRoot, targetRoot }; +} + +export function previewLocal(config: PortfolioConfig, target: DeployTarget): DeployPreview { + const { outputRoot, targetRoot } = validatePreview(config, target); + return { + targetName: target.name, + targetPath: target.target, + ...compareSnapshots(outputRoot, targetRoot), + }; +} diff --git a/src/deploy/report.ts b/src/deploy/report.ts new file mode 100644 index 0000000..413c008 --- /dev/null +++ b/src/deploy/report.ts @@ -0,0 +1,28 @@ +import type { DeployResult } from "./local.js"; +import type { DeployPreview } from "./preview.js"; + +export type DeployReportMode = "preview" | "sync"; +export type DeployReportTarget = DeployPreview | DeployResult; + +export interface DeployReport { + formatVersion: 1; + mode: DeployReportMode; + generatedAt: string; + outputDir: string; + targets: DeployReportTarget[]; +} + +export function createDeployReport( + mode: DeployReportMode, + generatedAt: string, + outputDir: string, + targets: DeployReportTarget[] +): DeployReport { + return { + formatVersion: 1, + mode, + generatedAt, + outputDir, + targets, + }; +} diff --git a/src/deploy/snapshot.ts b/src/deploy/snapshot.ts new file mode 100644 index 0000000..87b1f7f --- /dev/null +++ b/src/deploy/snapshot.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export interface SnapshotDiff { + added: string[]; + changed: string[]; + removed: string[]; + unchanged: number; + files: number; + sourceDigest: string | null; + targetDigest: string | null; + status: "clean" | "changed"; +} + +export function listSnapshotPaths(root: string): string[] { + if (!existsSync(root)) return []; + + const paths: string[] = []; + const walk = (current: string, prefix: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(join(current, entry.name), relative); + } else if (entry.isFile()) { + paths.push(relative); + } + } + }; + + walk(root, ""); + return paths.sort(); +} + +function digestFile(root: string, relativePath: string): string { + return createHash("sha256") + .update(readFileSync(join(root, relativePath))) + .digest("hex"); +} + +export function digestSnapshot(root: string): string | null { + if (!existsSync(root) || !statSync(root).isDirectory()) return null; + + const digest = createHash("sha256"); + for (const relativePath of listSnapshotPaths(root)) { + digest.update(relativePath); + digest.update("\0"); + digest.update(digestFile(root, relativePath)); + digest.update("\n"); + } + return digest.digest("hex"); +} + +export function compareSnapshots(sourceRoot: string, targetRoot: string): SnapshotDiff { + const sourcePaths = listSnapshotPaths(sourceRoot); + const targetPaths = listSnapshotPaths(targetRoot); + const sourceSet = new Set(sourcePaths); + const targetSet = new Set(targetPaths); + const added = sourcePaths.filter((path) => !targetSet.has(path)); + const removed = targetPaths.filter((path) => !sourceSet.has(path)); + const common = sourcePaths.filter((path) => targetSet.has(path)); + const changed = common.filter((path) => digestFile(sourceRoot, path) !== digestFile(targetRoot, path)); + + return { + added, + changed, + removed, + unchanged: common.length - changed.length, + files: sourcePaths.length, + sourceDigest: digestSnapshot(sourceRoot), + targetDigest: digestSnapshot(targetRoot), + status: added.length || changed.length || removed.length ? "changed" : "clean", + }; +} diff --git a/src/index.ts b/src/index.ts index aea5c27..b47221d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,26 @@ #!/usr/bin/env node -import { existsSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; 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 { builtinGalleryThemes, renderThemeGallery } from "./theme/gallery.js"; +import { deployAll, previewAll, type DeployPreview } from "./deploy/index.js"; +import { createDeployReport } from "./deploy/report.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.6.0"); function resolveConfig(options: { config?: string; data?: string; output?: string }): PortfolioConfig { const base = options.config @@ -35,6 +39,17 @@ function addConfigOption(command: Command): Command { return command.option("-c, --config ", "Configuration file"); } +function printDeployPreview(preview: DeployPreview): void { + console.log( + `Preview ${preview.targetName}: ${preview.status} ` + + `(${preview.added.length} added, ${preview.changed.length} changed, ` + + `${preview.removed.length} removed, ${preview.unchanged} unchanged).` + ); + for (const path of preview.added) console.log(` + ${path}`); + for (const path of preview.changed) console.log(` ~ ${path}`); + for (const path of preview.removed) console.log(` - ${path}`); +} + addConfigOption(program .command("demo") .description("Run the complete fixture pipeline without network access") @@ -64,10 +79,11 @@ 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("Open output/index.html in a browser."); + console.log(`Copied ${result.copiedScreenshots} available preview screenshots.`); + console.log(`Wrote theme gallery to ${result.galleryPath}.`); + console.log(`Wrote RSS feed to ${result.feedPath}.`); + console.log(`Open ${result.indexPath} in a browser.`); })); addConfigOption(program @@ -128,9 +144,10 @@ 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.copiedScreenshots} available preview screenshots.`); + console.log(`Wrote theme gallery to ${result.galleryPath}.`); + console.log(`Wrote RSS feed to ${result.feedPath}.`); })); addConfigOption(program @@ -146,9 +163,96 @@ addConfigOption(program console.log(`Captured ${result.captured} project previews.`); console.log(`Published ${result.published.projectCount} projects to ${result.published.indexPath}.`); console.log(`Copied ${result.copiedScreenshots} available preview screenshots.`); + console.log(`Wrote RSS feed to ${result.published.feedPath}.`); for (const error of result.captureErrors) { console.warn(`Skipped ${error.slug}: ${error.message}`); } + for (const deployed of result.deployed) { + const state = deployed.verified ? "verified" : "unverified"; + console.log(`Deployed ${deployed.targetName}: ${deployed.files} files to ${deployed.targetPath} (${state}).`); + if (deployed.removed > 0) { + console.log(`Removed ${deployed.removed} stale files from ${deployed.targetName}.`); + } + } + })); + +program + .command("themes") + .description("List built-in presentation themes or write a preview gallery") + .option("-p, --preview [file]", "Write a theme gallery HTML page") + .action((options) => { + const themes = listBuiltinThemes(); + if (options.preview) { + const config = existsSync(DEFAULT_CONFIG_PATH) + ? loadPortfolioConfig(DEFAULT_CONFIG_PATH) + : DEFAULT_CONFIG; + const entries = [...builtinGalleryThemes(), { label: "configured", theme: config.theme }]; + const targetPath = options.preview === true + ? join(config.outputDir, "theme-gallery.html") + : options.preview; + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, renderThemeGallery(entries), "utf-8"); + console.log(`Wrote theme gallery with ${entries.length} themes to ${targetPath}.`); + return; + } + console.log(`Built-in themes: ${themes.length}`); + for (const theme of themes) { + console.log(` ${theme.name}: ${theme.description}`); + } + }); + +addConfigOption(program + .command("deploy") + .description("Publish the snapshot and copy it to configured deploy targets") + .option("-d, --data ", "Data directory") + .option("-o, --output ", "Output directory") + .option("--dry-run", "Preview file changes without syncing targets") + .option("--json", "Print a machine-readable deployment report") + .action((options) => { + const config = resolveConfig(options); + mkdirSync(config.dataDir, { recursive: true }); + const result = publishSite(config); + if (!options.json) { + console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`); + console.log(`Copied ${result.copiedScreenshots} available preview screenshots.`); + console.log(`Wrote theme gallery to ${result.galleryPath}.`); + console.log(`Wrote RSS feed to ${result.feedPath}.`); + } + if (options.dryRun) { + const previews = previewAll(config); + if (options.json) { + console.log(JSON.stringify( + createDeployReport('preview', result.generatedAt, config.outputDir, previews), + null, + 2 + )); + return; + } + if (previews.length === 0) { + console.log('No deploy targets configured. Add a "deploy.targets" entry to the configuration.'); + } + for (const preview of previews) printDeployPreview(preview); + return; + } + const results = deployAll(config); + if (options.json) { + console.log(JSON.stringify( + createDeployReport('sync', result.generatedAt, config.outputDir, results), + null, + 2 + )); + return; + } + if (results.length === 0) { + console.log('No deploy targets configured. Add a "deploy.targets" entry to the configuration.'); + } + for (const deployed of results) { + const state = deployed.verified ? "verified" : "unverified"; + console.log(`Deployed ${deployed.targetName}: ${deployed.files} files to ${deployed.targetPath} (${state}).`); + if (deployed.removed > 0) { + console.log(`Removed ${deployed.removed} stale files from ${deployed.targetName}.`); + } + } })); addConfigOption(program diff --git a/src/publish/feed.ts b/src/publish/feed.ts new file mode 100644 index 0000000..0cc01bf --- /dev/null +++ b/src/publish/feed.ts @@ -0,0 +1,69 @@ +export interface RssChannel { + title: string; + link: string; + description: string; + language: string; + lastBuildDate: string; + generator: string; +} + +export interface RssItem { + title: string; + link: string; + guid: string; + description: string; + pubDate: string; + categories: string[]; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function toRfc2822(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toUTCString(); +} + +export function renderRssFeed(channel: RssChannel, items: RssItem[]): string { + const ordered = [...items].sort( + (a, b) => + b.pubDate.localeCompare(a.pubDate) || a.guid.localeCompare(b.guid) + ); + + const lines: string[] = [ + '', + '', + " ", + ` ${escapeXml(channel.title)}`, + ` ${escapeXml(channel.link)}`, + ` ${escapeXml(channel.description)}`, + ` ${escapeXml(channel.language)}`, + ` ${escapeXml(channel.lastBuildDate)}`, + ` ${escapeXml(channel.generator)}`, + ` `, + ]; + + for (const item of ordered) { + lines.push(" "); + lines.push(` ${escapeXml(item.title)}`); + lines.push(` ${escapeXml(item.link)}`); + lines.push(` ${escapeXml(item.guid)}`); + lines.push(` ${escapeXml(item.pubDate)}`); + for (const category of item.categories) { + lines.push(` ${escapeXml(category)}`); + } + lines.push(` ${escapeXml(item.description)}`); + lines.push(" "); + } + + lines.push(" "); + lines.push(""); + return lines.join("\n") + "\n"; +} diff --git a/src/publish/site.ts b/src/publish/site.ts index d382163..7be4ca0 100644 --- a/src/publish/site.ts +++ b/src/publish/site.ts @@ -1,7 +1,17 @@ -import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { formatChangelogMarkdown } from "../changelog/generator.js"; +import { + describeDiffCounts, + formatCommitDiffMarkdown, + summarizeCommitDiffs, + UNRELEASED_VERSION, + type CommitDiffSummary, +} from "../changelog/diff.js"; import { openDatabase } from "../db/client.js"; +import { resolveTheme, themeVariables, type ThemeTokens } from "../theme/palette.js"; +import { builtinGalleryThemes, renderThemeGallery } from "../theme/gallery.js"; +import { renderRssFeed, toRfc2822 } from "./feed.js"; import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js"; function escapeHtml(text: string): string { @@ -56,6 +66,20 @@ function parseTopics(value: string): string[] { } } +function plainText(markdown: string): string { + return markdown + .replace(/```[^`]*```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/[*_~#]/g, "") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +function pluralize(count: number, singular: string, plural: string): string { + return count === 1 ? singular : plural; +} + function publicAuditDetail(detail: string | null): string { if (!detail) return "No detail recorded"; const separator = detail.indexOf(" -> "); @@ -78,6 +102,53 @@ interface ProjectEvidence { releases: number; } +interface ProjectView { + project: ProjectRecord; + changelog: ChangelogEntry[]; + evidence: ProjectEvidence; + diffs: CommitDiffSummary[]; +} + +function commitTrailSummary(summary: CommitDiffSummary): string { + const versionLabel = + summary.version === UNRELEASED_VERSION + ? "Unreleased" + : `${summary.title} (${summary.version})`; + return `
+ ${escapeHtml(versionLabel)} + ${summary.commits.length} ${pluralize(summary.commits.length, "commit", "commits")} / ${escapeHtml(describeDiffCounts(summary.counts))} +
`; +} + +function commitTrailSection(views: ProjectView[]): string { + const withDiffs = views.filter((view) => view.diffs.length > 0); + const header = `

Evidence / 03

Commit trail

Commit-diff summaries map stored commits to release windows.

`; + if (withDiffs.length === 0) { + return `
+ ${header} +

No commit activity is recorded yet. Run ingest, then publish again.

+
`; + } + const cards = withDiffs + .map((view) => { + const summaries = view.diffs + .slice(0, 4) + .map(commitTrailSummary) + .join("\n"); + return ``; + }) + .join("\n"); + return `
+ ${header} +
${cards}
+
`; +} + function projectCard( project: ProjectRecord, changelog: ChangelogEntry[], @@ -124,31 +195,17 @@ function projectCard( `; } -function siteCss(): string { +function siteCss(theme: ThemeTokens): string { return ` -:root { - color-scheme: dark; - --ink: #08111f; - --ink-soft: #0d1a2d; - --panel: #112139; - --panel-strong: #172a46; - --line: rgba(169, 195, 222, 0.18); - --text: #f3f7fb; - --muted: #9db0c7; - --blue: #67b7ff; - --blue-soft: #b8dcff; - --mint: #a7f3d0; - --orange: #ffb86b; - --shadow: 0 24px 60px rgba(0, 0, 0, 0.24); - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} +${themeVariables(theme)} * { box-sizing: border-box; } html { scroll-behavior: smooth; } -body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); line-height: 1.5; } +body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); 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); } +.nav-links { display: flex; gap: 20px; } .brand { display: inline-flex; align-items: center; gap: 12px; font: 700 0.9rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; } .brand-mark { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--blue); color: var(--blue); border-radius: 8px; font-size: 0.72rem; } .nav-link { color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; } @@ -158,7 +215,7 @@ a { color: inherit; } .kicker { color: var(--mint); margin: 0 0 22px; } h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-weight: 650; letter-spacing: -0.08em; line-height: 0.94; } .hero-copy { max-width: 530px; margin: 28px 0 0; color: var(--blue-soft); font-size: 1.12rem; } -.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(23, 42, 70, 0.92), rgba(13, 26, 45, 0.72)); box-shadow: var(--shadow); } +.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--aside-gradient); box-shadow: var(--shadow); } .aside-index { display: flex; justify-content: space-between; color: var(--orange); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.12em; text-transform: uppercase; } .hero-aside p { margin: 24px 0 4px; color: var(--text); font-size: 1rem; } .stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } @@ -166,19 +223,19 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we .stat:last-child { border-right: 0; } .stat strong { display: block; color: var(--text); font-size: 1.8rem; font-weight: 600; letter-spacing: -0.04em; } .stat span { color: var(--muted); font: 0.7rem/1.3 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; } -.audit-panel { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; margin: 26px 0 80px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 10px; background: rgba(17, 33, 57, 0.66); } +.audit-panel { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; margin: 26px 0 80px; padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--audit-bg); } .audit-copy { color: var(--muted); font-size: 0.88rem; } .audit-copy strong { color: var(--text); font-weight: 500; } .audit-time { color: var(--blue); font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; text-align: right; } .index-header { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 24px; } .index-header h2 { margin: 0; font-size: 2rem; font-weight: 550; letter-spacing: -0.05em; } .index-header p { max-width: 350px; margin: 0; color: var(--muted); font-size: 0.88rem; text-align: right; } -.project-card { display: grid; grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr); overflow: hidden; margin-bottom: 24px; border: 1px solid var(--line); border-radius: 16px; background: linear-gradient(135deg, rgba(23, 42, 70, 0.98), rgba(13, 26, 45, 0.96)); box-shadow: var(--shadow); } -.project-visual { position: relative; min-height: 310px; background: #0a1525; } +.project-card { display: grid; grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr); overflow: hidden; margin-bottom: 24px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel-gradient); box-shadow: var(--shadow); } +.project-visual { position: relative; min-height: 310px; background: var(--visual); } .screenshot { display: block; width: 100%; height: 100%; min-height: 310px; object-fit: cover; opacity: 0.9; } -.placeholder { display: flex; min-height: 310px; align-items: center; justify-content: center; flex-direction: column; gap: 7px; color: var(--blue); background: repeating-linear-gradient(135deg, rgba(103, 183, 255, 0.05), rgba(103, 183, 255, 0.05) 1px, transparent 1px, transparent 14px); } +.placeholder { display: flex; min-height: 310px; align-items: center; justify-content: center; flex-direction: column; gap: 7px; color: var(--blue); background: repeating-linear-gradient(135deg, var(--stripe), var(--stripe) 1px, transparent 1px, transparent 14px); } .placeholder small { color: var(--muted); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; text-transform: uppercase; } -.visual-label { position: absolute; right: 16px; bottom: 16px; padding: 7px 9px; border: 1px solid rgba(255,255,255,0.18); border-radius: 5px; background: rgba(8, 17, 31, 0.72); color: var(--blue-soft); } +.visual-label { position: absolute; right: 16px; bottom: 16px; padding: 7px 9px; border: 1px solid var(--label-border); border-radius: 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 +244,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); background: var(--tag-bg); 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,20 +254,29 @@ 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); } .muted { color: var(--muted); } +.commit-trail { margin: 80px 0; } +.trail-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; } +.trail-card { padding: 22px 24px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel-gradient); box-shadow: var(--shadow); } +.trail-topline { display: flex; justify-content: space-between; gap: 12px; color: var(--blue); font: 0.66rem/1.4 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.1em; text-transform: uppercase; } +.trail-card h3 { margin: 14px 0 12px; font-size: 1.15rem; font-weight: 560; letter-spacing: -0.03em; } +.trail-card h3 a { text-decoration: none; } +.trail-summaries { display: grid; gap: 8px; margin-bottom: 14px; } +.trail-summary { display: flex; justify-content: space-between; gap: 12px; padding: 9px 11px; border: 1px solid var(--line-soft); border-radius: 7px; background: var(--audit-bg); color: var(--muted); font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; } +.trail-version { color: var(--mint); } .source-trail { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 36px; margin: 80px 0; padding: 26px 0; border-top: 1px solid var(--line); } .source-trail h2 { margin: 0 0 8px; font-size: 1.2rem; font-weight: 550; } .source-trail p { margin: 0; color: var(--muted); font-size: 0.86rem; } .audit-list { margin: 0; padding: 0; list-style: none; } -.audit-list li { display: grid; grid-template-columns: 150px 72px 1fr; gap: 12px; padding: 8px 0; border-bottom: 1px solid rgba(169, 195, 222, 0.1); color: var(--muted); font: 0.72rem/1.4 "SFMono-Regular", Consolas, monospace; } +.audit-list li { display: grid; grid-template-columns: 150px 72px 1fr; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line-soft); color: var(--muted); font: 0.72rem/1.4 "SFMono-Regular", Consolas, monospace; } .audit-list time { color: var(--blue); } .audit-list strong { color: var(--orange); font-weight: 500; text-transform: uppercase; } .site-footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 40px; border-top: 1px solid var(--line); color: var(--muted); font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; } @@ -239,10 +305,58 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we `; } +export interface SiteManifestProject { + slug: string; + name: string; + url: string; + changelogFile: string | null; + changesFile: string | null; +} + +export interface SiteManifest { + formatVersion: 3; + generatedAt: string; + title: string; + owner: string; + theme: { + name: string; + mode: string; + accent: string; + radius: string; + font: string; + }; + feed: string; + projectCount: number; + projects: SiteManifestProject[]; + files: string[]; + screenshots: string[]; +} + export interface PublishResult { indexPath: string; + manifestPath: string; + galleryPath: string; + feedPath: string; projectCount: number; generatedAt: string; + theme: string; + copiedScreenshots: number; +} + +function relativePosixPaths(outputDir: string): string[] { + const paths: string[] = []; + const walk = (current: string, prefix: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(join(current, entry.name), relative); + } else { + paths.push(relative); + } + } + }; + walk(outputDir, ""); + return paths.sort(); } export function publishSite(config: PortfolioConfig): PublishResult { @@ -250,14 +364,31 @@ export function publishSite(config: PortfolioConfig): PublishResult { try { const projects = db.listProjects(true); const generatedAt = config.clock(); - const views = projects.map((project) => ({ - project, - changelog: db.getChangelog(project.id), - evidence: { - commits: db.countCommits(project.id), - releases: db.countReleases(project.id), - }, - })); + const theme = resolveTheme(config.theme); + const views = projects.map((project) => { + const changelog = db.getChangelog(project.id); + const commits = db.getCommitsAscending(project.id); + return { + project, + changelog, + evidence: { + commits: db.countCommits(project.id), + releases: db.countReleases(project.id), + }, + diffs: summarizeCommitDiffs( + commits.map((commit) => ({ + sha: commit.sha, + message: commit.message, + committed_at: commit.committed_at, + })), + changelog.map((entry) => ({ + version: entry.version, + title: entry.title, + published_at: entry.published_at, + })) + ), + }; + }); const totalStars = projects.reduce((sum, project) => sum + project.stars, 0); const totalCommits = views.reduce((sum, view) => sum + view.evidence.commits, 0); const releaseNotes = views.reduce((sum, view) => sum + view.evidence.releases, 0); @@ -279,14 +410,19 @@ export function publishSite(config: PortfolioConfig): PublishResult { ${escapeHtml(config.title)} / Portfolio - + +
@@ -295,7 +431,7 @@ export function publishSite(config: PortfolioConfig): PublishResult {

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

@@ -314,8 +450,9 @@ export function publishSite(config: PortfolioConfig): PublishResult { ${visibleProjects} ${cards} + ${commitTrailSection(views)}
-

Audit / 03

Refresh trail

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

+

Audit / 04

Refresh trail

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

    ${auditItems}

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

Public output contains visible projects only. No invented metrics.

@@ -344,8 +481,98 @@ export function publishSite(config: PortfolioConfig): PublishResult { writeFileSync(join(config.outputDir, `${view.project.slug}-changelog.md`), markdown, "utf-8"); } + for (const view of views) { + if (view.diffs.length === 0) continue; + const markdown = formatCommitDiffMarkdown(view.project.name, view.diffs); + writeFileSync(join(config.outputDir, `${view.project.slug}-changes.md`), markdown, "utf-8"); + } + + const copiedScreenshots = copyScreenshotsToOutput(config); + + const galleryPath = join(config.outputDir, "theme-gallery.html"); + const galleryEntries = [ + ...builtinGalleryThemes(), + { label: "configured", theme: config.theme }, + ]; + writeFileSync(galleryPath, renderThemeGallery(galleryEntries), "utf-8"); + + const baseUrl = (config.feed.baseUrl ?? `https://github.com/${config.owner}`).replace(/\/+$/, ""); + const feedPath = join(config.outputDir, "feed.xml"); + writeFileSync( + feedPath, + renderRssFeed( + { + title: config.title, + link: baseUrl, + description: config.tagline, + language: "en", + lastBuildDate: toRfc2822(generatedAt), + generator: "engineer-profile/0.6.0", + }, + views.map((view) => { + const latest = view.changelog[0]; + const link = safeExternalUrl(view.project.url) ?? baseUrl; + const descriptionParts: string[] = []; + if (view.project.description) descriptionParts.push(view.project.description); + if (latest) { + descriptionParts.push(`${latest.title} (${latest.version})`); + descriptionParts.push(plainText(latest.body).slice(0, 400)); + } + return { + title: view.project.name, + link, + guid: `${link}#${view.project.slug}`, + description: + descriptionParts.join(" / ").slice(0, 500) || "No description provided.", + pubDate: view.project.last_pushed + ? toRfc2822(view.project.last_pushed) + : toRfc2822(generatedAt), + categories: parseTopics(view.project.topics).slice(0, 5), + }; + }) + ), + "utf-8" + ); + + const publishedFiles = relativePosixPaths(config.outputDir); + const manifest: SiteManifest = { + formatVersion: 3, + generatedAt, + title: config.title, + owner: config.owner, + theme: { + name: theme.name, + mode: theme.mode, + accent: theme.blue, + radius: theme.radius, + font: theme.font, + }, + feed: "feed.xml", + projectCount: projects.length, + projects: views.map((view) => ({ + slug: view.project.slug, + name: view.project.name, + url: view.project.url, + changelogFile: view.changelog.length > 0 ? `${view.project.slug}-changelog.md` : null, + changesFile: view.diffs.length > 0 ? `${view.project.slug}-changes.md` : null, + })), + files: [...new Set([...publishedFiles, "site-manifest.json"])].sort(), + screenshots: publishedFiles.filter((file) => file.startsWith("assets/screenshots/")), + }; + const manifestPath = join(config.outputDir, "site-manifest.json"); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); + db.logIngest("publish", `${projects.length} projects -> ${indexPath}`); - return { indexPath, projectCount: projects.length, generatedAt }; + return { + indexPath, + manifestPath, + galleryPath, + feedPath, + projectCount: projects.length, + generatedAt, + theme: theme.name, + copiedScreenshots, + }; } finally { db.close(); } @@ -367,4 +594,4 @@ export function copyScreenshotsToOutput(config: PortfolioConfig): number { db.close(); } return copied; -} \ No newline at end of file +} diff --git a/src/refresh/run.ts b/src/refresh/run.ts index acc9e53..6143f5b 100644 --- a/src/refresh/run.ts +++ b/src/refresh/run.ts @@ -1,6 +1,7 @@ import { ingestOwnerRepos } from "../ingest/orchestrator.js"; import { captureAllProjects } from "../preview/capture.js"; -import { copyScreenshotsToOutput, publishSite, type PublishResult } from "../publish/site.js"; +import { publishSite, type PublishResult } from "../publish/site.js"; +import { deployAll, type DeployResult } from "../deploy/index.js"; import type { FixtureData } from "../ingest/orchestrator.js"; import { DEFAULT_CONFIG, type PortfolioConfig } from "../types.js"; @@ -14,6 +15,7 @@ export interface RefreshResult { captured: number; copiedScreenshots: number; captureErrors: Array<{ slug: string; message: string }>; + deployed: DeployResult[]; published: PublishResult; } @@ -36,12 +38,14 @@ export async function refreshPortfolio( (slug, error) => captureErrors.push({ slug, message: error.message }) ); const published = publishSite(config); + const deployed = deployAll(config); return { ingested: ingested.length, captured: captured.length, - copiedScreenshots: copyScreenshotsToOutput(config), + copiedScreenshots: published.copiedScreenshots, captureErrors, + deployed, published, }; } diff --git a/src/theme/gallery.ts b/src/theme/gallery.ts new file mode 100644 index 0000000..27032a6 --- /dev/null +++ b/src/theme/gallery.ts @@ -0,0 +1,120 @@ +import type { ThemeConfig } from "../types.js"; +import { + listBuiltinThemes, + resolveTheme, + themePropertyDeclarations, + type ThemeTokens, +} from "./palette.js"; + +export interface GalleryTheme { + label: string; + theme: ThemeConfig; +} + +export function builtinGalleryThemes(): GalleryTheme[] { + return listBuiltinThemes().map((entry) => ({ + label: entry.name, + theme: { name: entry.name }, + })); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function styleAttribute(tokens: ThemeTokens): string { + return themePropertyDeclarations(tokens).replace(/\n\s*/g, " "); +} + +function swatchMeta(tokens: ThemeTokens): string { + return [ + `mode ${tokens.mode}`, + `accent ${tokens.blue}`, + `radius ${tokens.radius}`, + `font ${tokens.font}`, + ].join(" / "); +} + +function themeSwatch(entry: GalleryTheme): string { + const tokens = resolveTheme(entry.theme); + return `
+
+
EPengineer-profile / theme
+

Public repository signal / ${escapeHtml(entry.label)}

+

Evidence before adjectives.

+

A working sample of the theme. Bold facts, inline code, tags, and buttons keep their contrast.

+ +
typescriptnodesqlite
+
+
${escapeHtml(entry.label)}${escapeHtml(swatchMeta(tokens))}
+
`; +} + +function galleryCss(): string { + return ` +* { box-sizing: border-box; } +:root { color-scheme: dark; } +body { margin: 0; background: #0b0f14; color: #e6edf3; font-family: Inter, ui-sans-serif, system-ui, sans-serif; line-height: 1.5; } +.page { width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 48px 0 64px; } +.page-head { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 40px; padding-bottom: 26px; border-bottom: 1px solid #21262d; } +.page-head h1 { margin: 0; font-size: clamp(1.8rem, 4vw, 2.6rem); letter-spacing: -0.04em; } +.page-head p { max-width: 480px; margin: 0; color: #9da7b3; font-size: 0.92rem; text-align: right; } +.swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 28px; } +.swatch { overflow: hidden; border: 1px solid #21262d; border-radius: 16px; background: #11151b; } +.swatch-preview { padding: 26px; } +.swatch-nav { display: flex; justify-content: space-between; align-items: center; font: 700 0.72rem/1 ui-monospace, Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; } +.mark { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--blue); color: var(--blue); border-radius: 8px; font-size: 0.66rem; } +.nav-label { color: var(--muted); } +.kicker { margin: 24px 0 10px; color: var(--mint); font: 0.68rem/1.4 ui-monospace, Consolas, monospace; letter-spacing: 0.12em; text-transform: uppercase; } +.swatch-preview h2 { margin: 0; color: var(--text); font-size: 1.6rem; font-weight: 600; letter-spacing: -0.05em; } +.copy { margin: 12px 0 0; color: var(--blue-soft); font-size: 0.92rem; } +.copy code { padding: 2px 5px; border-radius: 4px; color: var(--mint); background: var(--code-bg); font: 0.78rem ui-monospace, Consolas, monospace; } +.swatch-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; } +.btn { padding: 10px 14px; border-radius: 7px; font: 0.72rem/1 ui-monospace, Consolas, monospace; letter-spacing: 0.04em; text-decoration: none; text-transform: uppercase; } +.btn.primary { background: var(--blue); color: var(--button-text); } +.btn.secondary { border: 1px solid var(--line); color: var(--text); } +.swatch-tags { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 20px; } +.tag { padding: 4px 9px; border: 1px solid var(--tag-border); border-radius: 999px; color: var(--mint); background: var(--tag-bg); font: 0.66rem/1 ui-monospace, Consolas, monospace; } +.swatch-meta { display: flex; justify-content: space-between; gap: 12px; padding: 13px 18px; border-top: 1px solid #21262d; background: #0d1117; color: #9da7b3; font: 0.66rem/1.5 ui-monospace, Consolas, monospace; } +.page-foot { margin-top: 44px; color: #9da7b3; font: 0.72rem/1.5 ui-monospace, Consolas, monospace; } +@media (max-width: 560px) { + .page { width: calc(100% - 28px); } + .page-head { flex-direction: column; align-items: start; } + .page-head p { text-align: left; } + .swatches { grid-template-columns: 1fr; } + .swatch-meta { flex-direction: column; } +} +`; +} + +export function renderThemeGallery(entries: GalleryTheme[]): string { + const swatches = entries.map(themeSwatch).join("\n"); + const names = entries.map((entry) => entry.label).join(", "); + return ` + + + + + + EngineerProfile / Theme gallery + + + +
+
+

EngineerProfile / presentation

Theme gallery

+

Every card renders with its own theme tokens. Compare palettes before you choose a theme.

+
+
+${swatches} +
+
Themes in this gallery: ${escapeHtml(names)}. The published site uses one configured theme.
+
+ +`; +} diff --git a/src/theme/palette.ts b/src/theme/palette.ts new file mode 100644 index 0000000..4307b2c --- /dev/null +++ b/src/theme/palette.ts @@ -0,0 +1,271 @@ +import { DEFAULT_THEME, type ThemeConfig } from "../types.js"; + +export type ThemeMode = "dark" | "light"; + +export interface ThemeTokens { + name: string; + mode: ThemeMode; + ink: string; + inkSoft: string; + panel: string; + panelStrong: string; + line: string; + lineSoft: string; + text: string; + muted: string; + blue: string; + blueSoft: string; + buttonText: string; + mint: string; + orange: string; + shadow: string; + visual: string; + glow: string; + panelGradient: string; + asideGradient: string; + labelBg: string; + labelBorder: string; + auditBg: string; + codeBg: string; + tagBg: string; + tagBorder: string; + stripe: string; + radius: string; + font: string; +} + +interface BuiltinPalette extends Omit { + mode: ThemeMode; +} + +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, + }, + terminal: { + mode: "dark", + ink: "#0a0f0b", + inkSoft: "#0e1611", + panel: "#111b13", + panelStrong: "#182519", + line: "rgba(126, 217, 160, 0.16)", + lineSoft: "rgba(126, 217, 160, 0.08)", + text: "#d7ffe7", + muted: "#7f9f8b", + blue: "#7ee9a0", + blueSoft: "#b8ffcf", + buttonText: "#0a0f0b", + mint: "#a7f3d0", + orange: "#ffd166", + shadow: "0 24px 60px rgba(0, 0, 0, 0.3)", + visual: "#0b120c", + glow: "radial-gradient(circle at 82% -10%, rgba(126, 233, 160, 0.14), transparent 34rem)", + panelGradient: "linear-gradient(135deg, rgba(24, 37, 25, 0.98), rgba(14, 22, 17, 0.96))", + asideGradient: "linear-gradient(145deg, rgba(24, 37, 25, 0.92), rgba(14, 22, 17, 0.72))", + labelBg: "rgba(10, 15, 11, 0.78)", + labelBorder: "rgba(215, 255, 231, 0.18)", + auditBg: "rgba(17, 27, 19, 0.72)", + codeBg: "rgba(167, 243, 208, 0.09)", + tagBg: "rgba(167, 243, 208, 0.07)", + tagBorder: "rgba(167, 243, 208, 0.26)", + stripe: "rgba(126, 233, 160, 0.05)", + radius: "8px", + font: 'ui-monospace, "SFMono-Regular", "Cascadia Code", Consolas, monospace', + }, +}; + +const BUILTIN_DESCRIPTIONS: Record = { + "deep-space": "Dark palette with blue accents. Default.", + paper: "Light palette with dark text and strong contrast.", + terminal: "Dark green-on-black palette with monospace type.", +}; + +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) { + 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 themePropertyDeclarations(tokens: ThemeTokens): string { + return [ + `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};`, + ].join("\n "); +} + +export function themeVariables(tokens: ThemeTokens): string { + return `:root { + ${themePropertyDeclarations(tokens)} +}`; +} diff --git a/src/types.ts b/src/types.ts index 92c30da..36db294 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,27 @@ 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 FeedConfig { + baseUrl?: string; +} + export interface PortfolioConfig { owner: string; title: string; @@ -85,6 +106,9 @@ export interface PortfolioConfig { repositoryLimit?: number; dataDir: string; outputDir: string; + theme: ThemeConfig; + deploy: DeployConfig; + feed: FeedConfig; privacy: PrivacyConfig; clock: () => string; } @@ -95,6 +119,16 @@ export const DEFAULT_PRIVACY: PrivacyConfig = { maxCommitsPerProject: 50, }; +export const DEFAULT_THEME: ThemeConfig = { + name: "deep-space", +}; + +export const DEFAULT_DEPLOY: DeployConfig = { + targets: [], +}; + +export const DEFAULT_FEED: FeedConfig = {}; + export const DEFAULT_CONFIG = { owner: "demo-engineer", title: "EngineerProfile", @@ -102,6 +136,9 @@ export const DEFAULT_CONFIG = { repositoryLimit: 5, dataDir: "data", outputDir: "output", + theme: DEFAULT_THEME, + deploy: DEFAULT_DEPLOY, + feed: DEFAULT_FEED, privacy: DEFAULT_PRIVACY, clock: () => new Date().toISOString(), } satisfies PortfolioConfig; diff --git a/tests/config.test.ts b/tests/config.test.ts index 44e2720..0750026 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -37,4 +37,76 @@ describe("portfolio configuration", () => { 'Configuration field "repositoryLimit" must be an integer from 1 to 100.' ); }); + + it("loads theme and deploy settings", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + theme: { name: "paper", accent: "#0f6bbd", radius: "20px" }, + deploy: { targets: [{ name: "public", type: "local", target: "output/deploy" }] }, + })); + + const config = loadPortfolioConfig(TEST_FILE); + expect(config.theme.name).toBe("paper"); + expect(config.theme.accent).toBe("#0f6bbd"); + expect(config.theme.radius).toBe("20px"); + expect(config.deploy.targets).toEqual([ + { name: "public", type: "local", target: "output/deploy" }, + ]); + }); + + it("rejects unknown theme names", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: { name: "vaporwave" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme.name" must be one of: deep-space, paper, terminal.' + ); + }); + + it("rejects invalid accent colors", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ theme: { name: "deep-space", accent: "blue" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "theme.accent" must be a hex color like "#67b7ff".' + ); + }); + + it("loads feed settings", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + feed: { baseUrl: "https://portfolio.example.com/" }, + })); + + const config = loadPortfolioConfig(TEST_FILE); + expect(config.feed.baseUrl).toBe("https://portfolio.example.com"); + }); + + it("defaults the feed settings", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ owner: "owner" })); + + const config = loadPortfolioConfig(TEST_FILE); + expect(config.feed.baseUrl).toBeUndefined(); + }); + + it("rejects invalid feed base URLs", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ feed: { baseUrl: "not a url" } })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "feed.baseUrl" must be an absolute http(s) URL.' + ); + }); + + it("rejects unsupported deploy adapter types", () => { + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(TEST_FILE, JSON.stringify({ + deploy: { targets: [{ name: "netlify", type: "remote", target: "x" }] }, + })); + + expect(() => loadPortfolioConfig(TEST_FILE)).toThrow( + 'Configuration field "deploy.targets[0].type" must be "local".' + ); + }); }); diff --git a/tests/db.test.ts b/tests/db.test.ts index 87d5c3d..bd3a97c 100644 --- a/tests/db.test.ts +++ b/tests/db.test.ts @@ -122,4 +122,34 @@ describe("PortfolioDatabase", () => { expect(pub[0].slug).toBe("visible"); db.close(); }); + + it("lists commits ascending for diff summaries", () => { + const db = openDatabase(TEST_DATA); + const project = db.upsertProject({ + slug: "ascending", + name: "Ascending", + description: null, + url: "https://github.com/o/a", + homepage: null, + language: null, + stars: 0, + forks: 0, + topics: "[]", + last_pushed: "2026-01-03T00:00:00Z", + visible: 1, + screenshot_path: null, + ingested_at: "2026-01-01T00:00:00Z", + }); + db.upsertCommits(project.id, [ + { sha: "c1", message: "feat: first", author_name: "Dev", author_email: null, committed_at: "2026-01-03T00:00:00Z", url: "https://github.com/o/a/commit/c1" }, + { sha: "c2", message: "fix: second", author_name: "Dev", author_email: null, committed_at: "2026-01-02T00:00:00Z", url: "https://github.com/o/a/commit/c2" }, + { sha: "c3", message: "docs: third", author_name: "Dev", author_email: null, committed_at: "2026-01-01T00:00:00Z", url: "https://github.com/o/a/commit/c3" }, + ]); + + const ascending = db.getCommitsAscending(project.id); + expect(ascending.map((commit) => commit.sha)).toEqual(["c3", "c2", "c1"]); + const limited = db.getCommitsAscending(project.id, 2); + expect(limited.map((commit) => commit.sha)).toEqual(["c3", "c2"]); + db.close(); + }); }); diff --git a/tests/demo.test.ts b/tests/demo.test.ts index 4329839..cd665d9 100644 --- a/tests/demo.test.ts +++ b/tests/demo.test.ts @@ -3,7 +3,7 @@ import { rmSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { ingestOwnerRepos } from "../src/ingest/orchestrator.js"; import { captureLocalHtml, closeBrowser } from "../src/preview/capture.js"; -import { publishSite, copyScreenshotsToOutput } from "../src/publish/site.js"; +import { publishSite } from "../src/publish/site.js"; import { loadAllFixtures } from "../src/fixtures/loader.js"; import { DEFAULT_CONFIG } from "../src/types.js"; @@ -36,10 +36,9 @@ describe("demo pipeline", () => { } const published = publishSite(config); - const copied = copyScreenshotsToOutput(config); expect(published.projectCount).toBe(2); - expect(copied).toBe(2); + expect(published.copiedScreenshots).toBe(2); expect(existsSync(published.indexPath)).toBe(true); const html = readFileSync(published.indexPath, "utf-8"); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts new file mode 100644 index 0000000..50811be --- /dev/null +++ b/tests/deploy.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { deployAll, deployToTarget, previewAll, previewToTarget } from "../src/deploy/index.js"; +import { openDatabase } from "../src/db/client.js"; +import { digestSnapshot } from "../src/deploy/snapshot.js"; +import { DEFAULT_CONFIG } from "../src/types.js"; + +const TEST_DATA = join("data", "test-deploy"); +const TEST_OUTPUT = join("output", "test-deploy"); +const TEST_TARGET = join("deploy", "test-deploy-target"); + +describe("deploy adapters", () => { + beforeEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + rmSync(TEST_TARGET, { recursive: true, force: true }); + }); + + afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + rmSync(TEST_TARGET, { recursive: true, force: true }); + }); + + function publishedConfig() { + const config = { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + deploy: { + targets: [{ name: "public", type: "local" as const, target: TEST_TARGET }], + }, + clock: () => "2026-07-31T00:00:00.000Z", + }; + return config; + } + + function writePublishedSite() { + mkdirSync(join(TEST_OUTPUT, "assets", "screenshots"), { recursive: true }); + writeFileSync(join(TEST_OUTPUT, "index.html"), "portfolio", "utf-8"); + writeFileSync(join(TEST_OUTPUT, "site-manifest.json"), "{}", "utf-8"); + } + + it("copies the published site into a local target", () => { + writePublishedSite(); + const config = publishedConfig(); + const result = deployAll(config); + + expect(result).toHaveLength(1); + expect(result[0].targetName).toBe("public"); + expect(result[0].targetPath).toBe(TEST_TARGET); + expect(result[0].files).toBeGreaterThanOrEqual(2); + expect(result[0].removed).toBe(0); + expect(result[0].verified).toBe(true); + expect(result[0].sourceDigest).toMatch(/^[a-f0-9]{64}$/); + expect(result[0].targetDigest).toBe(result[0].sourceDigest); + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + expect(existsSync(join(TEST_TARGET, "site-manifest.json"))).toBe(true); + }); + + it("removes stale files that are not part of the snapshot", () => { + writePublishedSite(); + mkdirSync(join(TEST_TARGET, "assets"), { recursive: true }); + writeFileSync(join(TEST_TARGET, "old-capture.png"), "stale", "utf-8"); + const config = publishedConfig(); + + const result = deployAll(config); + + expect(result[0].removed).toBe(1); + expect(existsSync(join(TEST_TARGET, "old-capture.png"))).toBe(false); + expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true); + }); + + it("reports an unverified result when the manifest is missing", () => { + mkdirSync(TEST_OUTPUT, { recursive: true }); + writeFileSync(join(TEST_OUTPUT, "index.html"), "partial", "utf-8"); + const config = publishedConfig(); + + const result = deployAll(config); + + expect(result[0].verified).toBe(false); + expect(result[0].files).toBe(1); + }); + + it("previews added, changed, removed, and unchanged files without writing the target", () => { + writePublishedSite(); + writeFileSync(join(TEST_OUTPUT, "new.txt"), "new", "utf-8"); + mkdirSync(TEST_TARGET, { recursive: true }); + writeFileSync(join(TEST_TARGET, "index.html"), "old", "utf-8"); + writeFileSync(join(TEST_TARGET, "site-manifest.json"), "{}", "utf-8"); + writeFileSync(join(TEST_TARGET, "stale.txt"), "stale", "utf-8"); + const config = publishedConfig(); + + const result = previewAll(config); + + expect(result[0]).toMatchObject({ + targetName: "public", + status: "changed", + added: ["new.txt"], + changed: ["index.html"], + removed: ["stale.txt"], + unchanged: 1, + files: 3, + }); + expect(readFileSync(join(TEST_TARGET, "index.html"), "utf-8")).toBe("old"); + expect(existsSync(join(TEST_TARGET, "stale.txt"))).toBe(true); + }); + + it("previews a missing target without creating its directory", () => { + writePublishedSite(); + const config = publishedConfig(); + + const result = previewToTarget(config, config.deploy.targets[0]); + + expect(result.status).toBe("changed"); + expect(result.added).toEqual(["index.html", "site-manifest.json"]); + expect(result.changed).toEqual([]); + expect(result.removed).toEqual([]); + expect(result.sourceDigest).toMatch(/^[a-f0-9]{64}$/); + expect(result.targetDigest).toBeNull(); + expect(existsSync(TEST_TARGET)).toBe(false); + }); + + it("produces a stable digest for the same snapshot and changes it when content changes", () => { + writePublishedSite(); + const first = digestSnapshot(TEST_OUTPUT); + const second = digestSnapshot(TEST_OUTPUT); + + expect(first).toBe(second); + expect(first).toMatch(/^[a-f0-9]{64}$/); + + writeFileSync(join(TEST_OUTPUT, "index.html"), "changed", "utf-8"); + expect(digestSnapshot(TEST_OUTPUT)).not.toBe(first); + }); + + it("reports a clean preview when both snapshots match", () => { + writePublishedSite(); + const config = publishedConfig(); + deployAll(config); + + const result = previewToTarget(config, config.deploy.targets[0]); + + expect(result.status).toBe("clean"); + expect(result.added).toEqual([]); + expect(result.changed).toEqual([]); + expect(result.removed).toEqual([]); + expect(result.unchanged).toBe(result.files); + }); + + it("reports a publish-first error when output is missing", () => { + const config = publishedConfig(); + expect(() => deployToTarget(config, config.deploy.targets[0])).toThrow( + /No published site found/ + ); + }); + + it("rejects a target inside the output directory", () => { + writePublishedSite(); + const config = { + ...publishedConfig(), + deploy: { + targets: [ + { name: "nested", type: "local" as const, target: join(TEST_OUTPUT, "nested") }, + ], + }, + }; + expect(() => deployAll(config)).toThrow(/must be outside the output directory/); + }); + + it("returns an empty result list without targets", () => { + const config = { ...publishedConfig(), deploy: { targets: [] } }; + expect(deployAll(config)).toEqual([]); + }); + + it("records a deploy audit entry", () => { + writePublishedSite(); + const config = publishedConfig(); + deployAll(config); + + const db = openDatabase(TEST_DATA, config.clock); + const log = db.getIngestLog(5); + db.close(); + expect(log[0].action).toBe("deploy"); + expect(log[0].detail).toContain("public"); + }); + + it("copies subdirectories inside the output directory", () => { + writePublishedSite(); + writeFileSync( + join(TEST_OUTPUT, "assets", "screenshots", "demo-engineer-signal-router.png"), + "png", + "utf-8" + ); + const config = publishedConfig(); + deployAll(config); + + const targetFiles = readdirSync(join(TEST_TARGET, "assets", "screenshots")); + expect(targetFiles).toContain("demo-engineer-signal-router.png"); + }); +}); diff --git a/tests/deterministic.test.ts b/tests/deterministic.test.ts index 78fc6ea..b350919 100644 --- a/tests/deterministic.test.ts +++ b/tests/deterministic.test.ts @@ -26,14 +26,20 @@ describe("deterministic publishing", () => { it("produces the same HTML for the same fixture snapshot", async () => { const fixtures = loadAllFixtures(); const html = []; + const galleries = []; + const feeds = []; 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")); + galleries.push(readFileSync(result.galleryPath, "utf-8")); + feeds.push(readFileSync(result.feedPath, "utf-8")); } expect(html[0]).toBe(html[1]); + expect(galleries[0]).toBe(galleries[1]); + expect(feeds[0]).toBe(feeds[1]); expect(html[0]).toContain("2026-07-31 00:00:00 UTC"); }); @@ -45,5 +51,7 @@ describe("deterministic publishing", () => { expect(html).toContain("https://github.com/demo-engineer/signal-router/releases/tag/v0.3.0"); expect(existsSync(join(OUTPUTS[0], "demo-engineer-signal-router-changelog.md"))).toBe(true); + expect(existsSync(join(OUTPUTS[0], "demo-engineer-signal-router-changes.md"))).toBe(true); + expect(existsSync(join(OUTPUTS[0], "feed.xml"))).toBe(true); }); }); \ No newline at end of file diff --git a/tests/diff.test.ts b/tests/diff.test.ts new file mode 100644 index 0000000..e609804 --- /dev/null +++ b/tests/diff.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + describeDiffCounts, + formatCommitDiffMarkdown, + summarizeCommitDiffs, + UNRELEASED_VERSION, + type CommitDiffSummary, +} from "../src/changelog/diff.js"; + +function commit(sha: string, message: string, committed_at: string) { + return { sha, message, committed_at }; +} + +describe("summarizeCommitDiffs", () => { + const commits = [ + commit("aaa", "feat(router): add priority queues", "2026-07-15T10:00:00Z"), + commit("bbb", "fix(buffer): drop oldest entry", "2026-07-10T10:00:00Z"), + commit("ccc", "docs: add topology diagram", "2026-07-05T10:00:00Z"), + commit("ddd", "chore(ci): add typecheck", "2026-06-28T10:00:00Z"), + commit("eee", "unclassified change", "2026-06-10T10:00:00Z"), + ]; + + const versions = [ + { version: "v0.2.0", title: "Typed channels", published_at: "2026-06-01T08:00:00Z" }, + { version: "v0.3.0", title: "Priority queues", published_at: "2026-07-14T10:00:00Z" }, + ]; + + it("buckets commits into version windows", () => { + const summaries = summarizeCommitDiffs(commits, versions); + const byVersion = new Map(summaries.map((summary) => [summary.version, summary])); + + expect(byVersion.get("v0.2.0")).toBeUndefined(); + + const v030 = byVersion.get("v0.3.0"); + expect(v030).toBeDefined(); + expect(v030!.commits.map((item) => item.sha)).toEqual(["bbb", "ccc", "ddd", "eee"]); + expect(v030!.counts).toMatchObject({ fix: 1, docs: 1, chore: 1, other: 1 }); + + const unreleased = byVersion.get(UNRELEASED_VERSION); + expect(unreleased).toBeDefined(); + expect(unreleased!.commits.map((item) => item.sha)).toEqual(["aaa"]); + expect(unreleased!.counts).toMatchObject({ feat: 1 }); + }); + + it("returns summaries newest first", () => { + const summaries = summarizeCommitDiffs(commits, versions); + expect(summaries[0].version).toBe(UNRELEASED_VERSION); + expect(summaries[1].version).toBe("v0.3.0"); + }); + + it("counts non-conventional commits as other", () => { + const summaries = summarizeCommitDiffs( + [ + commit("aaa", "feat: add thing", "2026-07-15T10:00:00Z"), + commit("bbb", "wip on dashboard", "2026-07-16T10:00:00Z"), + ], + [] + ); + expect(summaries).toHaveLength(1); + expect(summaries[0].counts).toMatchObject({ feat: 1, other: 1 }); + }); + + it("returns an empty list for no commits", () => { + expect(summarizeCommitDiffs([], versions)).toEqual([]); + }); + + it("sorts commits before the oldest version into that version window", () => { + const summaries = summarizeCommitDiffs( + [commit("aaa", "feat: initial", "2026-05-01T10:00:00Z")], + versions + ); + expect(summaries[0].version).toBe("v0.2.0"); + expect(summaries[0].counts).toMatchObject({ feat: 1 }); + }); +}); + +describe("describeDiffCounts", () => { + it("lists non-zero types in order", () => { + const counts = { feat: 2, fix: 1, perf: 0, refactor: 0, docs: 0, test: 0, chore: 0, build: 0, ci: 0, other: 0 }; + expect(describeDiffCounts(counts)).toBe("2 features, 1 fix"); + }); + + it("falls back when every type is zero", () => { + const counts = { feat: 0, fix: 0, perf: 0, refactor: 0, docs: 0, test: 0, chore: 0, build: 0, ci: 0, other: 0 }; + expect(describeDiffCounts(counts)).toBe("no typed changes"); + }); +}); + +describe("formatCommitDiffMarkdown", () => { + const summaries: CommitDiffSummary[] = [ + { + version: UNRELEASED_VERSION, + title: "Unreleased", + publishedAt: "2026-07-15T10:00:00Z", + commits: [commit("aabbcc", "feat: add thing", "2026-07-15T10:00:00Z")], + counts: { feat: 1, fix: 0, perf: 0, refactor: 0, docs: 0, test: 0, chore: 0, build: 0, ci: 0, other: 0 }, + }, + ]; + + it("writes a deterministic markdown document", () => { + const markdown = formatCommitDiffMarkdown("signal-router", summaries); + expect(markdown).toContain("# signal-router / Commit trail"); + expect(markdown).toContain("## Unreleased"); + expect(markdown).toContain("Commits: 1. 1 feature."); + expect(markdown).toContain("- feat: add thing (`aabbcc`)"); + expect(markdown).toBe(formatCommitDiffMarkdown("signal-router", summaries)); + }); + + it("handles an empty summary list", () => { + expect(formatCommitDiffMarkdown("signal-router", [])).toContain( + "No commit activity is recorded." + ); + }); +}); diff --git a/tests/feed.test.ts b/tests/feed.test.ts new file mode 100644 index 0000000..835ee44 --- /dev/null +++ b/tests/feed.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { renderRssFeed, toRfc2822, type RssItem } from "../src/publish/feed.js"; + +const channel = { + title: "Evidence Index", + link: "https://github.com/demo-engineer", + description: "A living index of shipped systems", + language: "en", + lastBuildDate: "Wed, 29 Jul 2026 12:00:00 GMT", + generator: "engineer-profile/0.6.0", +}; + +function item(overrides: Partial = {}): RssItem { + return { + title: "signal-router", + link: "https://github.com/demo-engineer/signal-router", + guid: "https://github.com/demo-engineer/signal-router#demo-engineer-signal-router", + description: "Route events between services", + pubDate: "Tue, 14 Jul 2026 10:00:00 GMT", + categories: ["typescript", "messaging"], + ...overrides, + }; +} + +describe("renderRssFeed", () => { + it("renders a valid RSS 2.0 document", () => { + const xml = renderRssFeed(channel, [item()]); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain("Evidence Index"); + expect(xml).toContain("https://github.com/demo-engineer"); + expect(xml).toContain("Wed, 29 Jul 2026 12:00:00 GMT"); + expect(xml).toContain(""); + expect(xml).toContain(""); + expect(xml).toContain("typescript"); + expect(xml).toContain("messaging"); + }); + + it("escapes XML special characters", () => { + const xml = renderRssFeed(channel, [ + item({ + title: "C++ & ", + description: 'He said "go" & ran ', + categories: ["a&b", "cC++ & <Nodes>"); + expect(xml).toContain("He said "go" & ran <fast>"); + expect(xml).toContain("a&b"); + expect(xml).toContain("c<d"); + }); + + it("sorts items by pubDate descending for deterministic output", () => { + const first = renderRssFeed(channel, [ + item({ title: "older", pubDate: "Mon, 01 Jun 2026 08:00:00 GMT" }), + item({ title: "newer", pubDate: "Wed, 15 Jul 2026 10:00:00 GMT" }), + ]); + expect(first.indexOf("newer")).toBeLessThan(first.indexOf("older")); + const second = renderRssFeed(channel, [ + item({ title: "newer", pubDate: "Wed, 15 Jul 2026 10:00:00 GMT" }), + item({ title: "older", pubDate: "Mon, 01 Jun 2026 08:00:00 GMT" }), + ]); + expect(first).toBe(second); + }); + + it("omits category tags when there are none", () => { + const xml = renderRssFeed(channel, [item({ categories: [] })]); + expect(xml).not.toContain(""); + }); +}); + +describe("toRfc2822", () => { + it("converts ISO timestamps to UTC mail format", () => { + expect(toRfc2822("2026-07-14T10:00:00Z")).toBe("Tue, 14 Jul 2026 10:00:00 GMT"); + }); + + it("passes through unparseable values", () => { + expect(toRfc2822("not-a-date")).toBe("not-a-date"); + }); +}); diff --git a/tests/gallery.test.ts b/tests/gallery.test.ts new file mode 100644 index 0000000..b8b4d76 --- /dev/null +++ b/tests/gallery.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { + builtinGalleryThemes, + renderThemeGallery, +} from "../src/theme/gallery.js"; +import { + listBuiltinThemes, + resolveTheme, + themePropertyDeclarations, + themeVariables, +} from "../src/theme/palette.js"; + +describe("theme property declarations", () => { + it("emits color-scheme and every custom property as declarations", () => { + const tokens = resolveTheme({ name: "deep-space" }); + const declarations = themePropertyDeclarations(tokens); + expect(declarations).toContain("color-scheme: dark"); + expect(declarations).toContain("--blue: #67b7ff"); + expect(declarations).toContain("--radius: 16px"); + expect(declarations).not.toContain(":root"); + }); + + it("wraps the declarations in a :root block through themeVariables", () => { + const tokens = resolveTheme({ name: "paper" }); + expect(themeVariables(tokens)).toBe(`:root { + ${themePropertyDeclarations(tokens)} +}`); + }); +}); + +describe("theme gallery", () => { + it("lists every built-in theme plus a configured entry", () => { + const entries = builtinGalleryThemes(); + expect(entries.map((entry) => entry.label)).toEqual(listBuiltinThemes().map((t) => t.name)); + }); + + it("renders each built-in theme name in the gallery", () => { + const html = renderThemeGallery(builtinGalleryThemes()); + for (const entry of builtinGalleryThemes()) { + expect(html).toContain(entry.label); + } + expect(html).toContain("Theme gallery"); + }); + + it("scopes theme custom properties to each swatch", () => { + const html = renderThemeGallery([{ label: "deep-space", theme: { name: "deep-space" } }]); + expect(html).toContain('
color-scheme: dark"); + }); + + it("reflects an accent override in the rendered swatch", () => { + const html = renderThemeGallery([{ label: "brand", theme: { name: "deep-space", accent: "#ff6600" } }]); + expect(html).toContain("--blue: #ff6600;"); + }); + + it("produces deterministic output for the same entries", () => { + const entries = builtinGalleryThemes(); + expect(renderThemeGallery(entries)).toBe(renderThemeGallery(entries)); + }); +}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts new file mode 100644 index 0000000..1582fc1 --- /dev/null +++ b/tests/manifest.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { ingestRepository } from "../src/ingest/orchestrator.js"; +import { publishSite } from "../src/publish/site.js"; +import { loadFixtureRepo, loadFixtureCommits, loadFixtureReleases } from "../src/fixtures/loader.js"; +import { DEFAULT_CONFIG, type PortfolioConfig } from "../src/types.js"; + +const TEST_DATA = join("data", "test-manifest"); +const TEST_OUTPUT = join("output", "test-manifest"); + +function fixtureConfig(): PortfolioConfig { + return { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + theme: { name: "paper", accent: "#0f6bbd" }, + clock: () => "2026-07-31T00:00:00.000Z", + }; +} + +async function ingestSignalRouter(config: PortfolioConfig) { + await ingestRepository( + config, + { owner: "demo-engineer", repo: "signal-router" }, + { + repo: loadFixtureRepo("signal-router"), + commits: loadFixtureCommits("signal-router"), + releases: loadFixtureReleases("signal-router"), + } + ); +} + +describe("site manifest", () => { + beforeEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + }); + + afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + }); + + it("writes a versioned manifest with published project facts", async () => { + const config = fixtureConfig(); + await ingestSignalRouter(config); + const result = publishSite(config); + + expect(existsSync(result.manifestPath)).toBe(true); + const manifest = JSON.parse(readFileSync(result.manifestPath, "utf-8")) as { + formatVersion: number; + theme: { name: string; mode: string; accent: string; radius: string; font: string }; + feed: string; + projectCount: number; + projects: Array<{ slug: string; url: string; changelogFile: string | null; changesFile: string | null }>; + files: string[]; + screenshots: string[]; + }; + + expect(manifest.formatVersion).toBe(3); + expect(manifest.feed).toBe("feed.xml"); + expect(manifest.theme.name).toBe("paper"); + expect(manifest.theme.mode).toBe("light"); + expect(manifest.theme.accent).toBe("#0f6bbd"); + expect(manifest.theme.radius).toBe("16px"); + expect(manifest.projectCount).toBe(1); + expect(manifest.projects[0].slug).toBe("demo-engineer-signal-router"); + expect(manifest.projects[0].url).toBe("https://github.com/demo-engineer/signal-router"); + expect(manifest.projects[0].changelogFile).toBe("demo-engineer-signal-router-changelog.md"); + expect(manifest.projects[0].changesFile).toBe("demo-engineer-signal-router-changes.md"); + expect(manifest.files).toContain("index.html"); + expect(manifest.files).toContain("site-manifest.json"); + expect(manifest.files).toContain("theme-gallery.html"); + expect(manifest.files).toContain("feed.xml"); + expect(manifest.files).toContain("demo-engineer-signal-router-changes.md"); + expect(Array.isArray(manifest.screenshots)).toBe(true); + }); + + it("produces a deterministic manifest for the same snapshot", async () => { + const config = fixtureConfig(); + await ingestSignalRouter(config); + publishSite(config); + const first = readFileSync(join(TEST_OUTPUT, "site-manifest.json"), "utf-8"); + publishSite(config); + const second = readFileSync(join(TEST_OUTPUT, "site-manifest.json"), "utf-8"); + expect(first).toBe(second); + }); + + it("reflects an empty portfolio with zero projects", async () => { + const config = fixtureConfig(); + const result = publishSite(config); + const manifest = JSON.parse(readFileSync(result.manifestPath, "utf-8")) as { + formatVersion: number; + projectCount: number; + }; + expect(manifest.projectCount).toBe(0); + }); +}); diff --git a/tests/report.test.ts b/tests/report.test.ts new file mode 100644 index 0000000..fed11f1 --- /dev/null +++ b/tests/report.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { createDeployReport } from "../src/deploy/report.js"; + +describe("deployment reports", () => { + it("creates deterministic machine-readable metadata", () => { + const target = { + targetName: "public", + targetPath: "deploy/public", + status: "changed" as const, + added: ["index.html"], + changed: [], + removed: [], + unchanged: 0, + files: 1, + sourceDigest: "a".repeat(64), + targetDigest: null, + }; + const report = createDeployReport( + "preview", + "2026-07-31T00:00:00.000Z", + "output", + [target] + ); + + expect(report).toEqual({ + formatVersion: 1, + mode: "preview", + generatedAt: "2026-07-31T00:00:00.000Z", + outputDir: "output", + targets: [target], + }); + expect(JSON.stringify(report)).toBe(JSON.stringify(createDeployReport( + "preview", + "2026-07-31T00:00:00.000Z", + "output", + [target] + ))); + }); + + it("keeps sync reports separate from preview file changes", () => { + const digest = "b".repeat(64); + const report = createDeployReport("sync", "2026-07-31T00:00:00.000Z", "output", [{ + targetName: "public", + targetPath: "deploy/public", + files: 4, + removed: 1, + verified: true, + sourceDigest: digest, + targetDigest: digest, + }]); + + expect(report.mode).toBe("sync"); + expect(report.targets[0]).toMatchObject({ + targetName: "public", + verified: true, + sourceDigest: digest, + targetDigest: digest, + }); + }); +}); diff --git a/tests/site-css.test.ts b/tests/site-css.test.ts new file mode 100644 index 0000000..be57a28 --- /dev/null +++ b/tests/site-css.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { ingestRepository } from "../src/ingest/orchestrator.js"; +import { publishSite } from "../src/publish/site.js"; +import { loadFixtureCommits, loadFixtureReleases, loadFixtureRepo } from "../src/fixtures/loader.js"; +import { DEFAULT_CONFIG, type PortfolioConfig, type ThemeConfig } from "../src/types.js"; + +const TEST_DATA = join("data", "test-site-css"); +const TEST_OUTPUT = join("output", "test-site-css"); + +function themedConfig(theme: ThemeConfig): PortfolioConfig { + return { + ...DEFAULT_CONFIG, + dataDir: TEST_DATA, + outputDir: TEST_OUTPUT, + theme, + clock: () => "2026-07-31T00:00:00.000Z", + }; +} + +async function ingestSignalRouter(config: PortfolioConfig) { + await ingestRepository( + config, + { owner: "demo-engineer", repo: "signal-router" }, + { + repo: loadFixtureRepo("signal-router"), + commits: loadFixtureCommits("signal-router"), + releases: loadFixtureReleases("signal-router"), + } + ); +} + +describe("generated site CSS", () => { + beforeEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + }); + + afterEach(() => { + rmSync(TEST_DATA, { recursive: true, force: true }); + rmSync(TEST_OUTPUT, { recursive: true, force: true }); + }); + + it("applies the theme font token to the page body", async () => { + const config = themedConfig({ name: "deep-space" }); + await ingestSignalRouter(config); + const result = publishSite(config); + + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain("font-family: var(--font);"); + }); + + it("applies the theme radius token to project cards", async () => { + const config = themedConfig({ name: "deep-space" }); + await ingestSignalRouter(config); + const result = publishSite(config); + + const html = readFileSync(result.indexPath, "utf-8"); + const projectCardRule = html.slice(html.indexOf(".project-card"), html.indexOf(".project-card") + 400); + expect(projectCardRule).toContain("border-radius: var(--radius);"); + expect(projectCardRule).not.toContain("border-radius: 16px"); + }); + + it("applies the radius token to panel surfaces", async () => { + const config = themedConfig({ name: "deep-space" }); + await ingestSignalRouter(config); + const result = publishSite(config); + + const html = readFileSync(result.indexPath, "utf-8"); + for (const selector of [".hero-aside", ".audit-panel", ".trail-card"]) { + const rule = html.slice(html.indexOf(selector), html.indexOf(selector) + 320); + expect(rule).toContain("border-radius: var(--radius);"); + } + }); + + it("reflects radius and font overrides in the generated variables", async () => { + const config = themedConfig({ name: "paper", radius: "20px", font: "Georgia, serif" }); + await ingestSignalRouter(config); + const result = publishSite(config); + + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain("--radius: 20px;"); + expect(html).toContain("--font: Georgia, serif;"); + expect(html).toContain("color-scheme: light"); + }); + + it("keeps the default radius and font in the base theme", async () => { + const config = themedConfig({ name: "terminal" }); + await ingestSignalRouter(config); + const result = publishSite(config); + + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain("--radius: 8px;"); + expect(html).toContain("--font:"); + expect(html).toContain("ui-monospace"); + }); +}); diff --git a/tests/site.test.ts b/tests/site.test.ts index 03061cf..0588593 100644 --- a/tests/site.test.ts +++ b/tests/site.test.ts @@ -3,7 +3,7 @@ import { rmSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { openDatabase } from "../src/db/client.js"; import { ingestRepository } from "../src/ingest/orchestrator.js"; -import { publishSite, copyScreenshotsToOutput } from "../src/publish/site.js"; +import { publishSite } from "../src/publish/site.js"; import { loadFixtureRepo, loadFixtureCommits, loadFixtureReleases } from "../src/fixtures/loader.js"; import { DEFAULT_CONFIG } from "../src/types.js"; @@ -87,6 +87,37 @@ describe("database and publish pipeline", () => { expect(html).toContain("total stars"); }); + it("publishes an RSS feed and a commit trail", async () => { + const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; + await ingestRepository( + config, + { owner: "demo-engineer", repo: "signal-router" }, + { + repo: loadFixtureRepo("signal-router"), + commits: loadFixtureCommits("signal-router"), + releases: loadFixtureReleases("signal-router"), + } + ); + + const result = publishSite(config); + expect(existsSync(result.feedPath)).toBe(true); + + const feed = readFileSync(result.feedPath, "utf-8"); + expect(feed).toContain(""); + expect(feed).toContain("signal-router"); + expect(feed).toContain("typescript"); + + const changesPath = join(TEST_OUTPUT, "demo-engineer-signal-router-changes.md"); + expect(existsSync(changesPath)).toBe(true); + const changes = readFileSync(changesPath, "utf-8"); + expect(changes).toContain("Commit trail"); + + const html = readFileSync(result.indexPath, "utf-8"); + expect(html).toContain("Commit trail"); + expect(html).toContain("href=\"feed.xml\""); + }); + it("respects visibility when publishing", async () => { const config = { ...DEFAULT_CONFIG, dataDir: TEST_DATA, outputDir: TEST_OUTPUT }; await ingestRepository( diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..eb0286b --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { + listBuiltinThemes, + isBuiltinTheme, + isValidHexColor, + resolveTheme, + themeVariables, +} from "../src/theme/palette.js"; +import { DEFAULT_THEME } from "../src/types.js"; + +describe("theme catalog", () => { + it("exposes deterministic built-in themes", () => { + const themes = listBuiltinThemes(); + const names = themes.map((theme) => theme.name); + expect(names).toEqual(["deep-space", "paper", "terminal"]); + for (const theme of themes) { + expect(theme.description.length).toBeGreaterThan(0); + } + }); + + it("recognizes built-in theme names", () => { + expect(isBuiltinTheme("deep-space")).toBe(true); + expect(isBuiltinTheme("paper")).toBe(true); + expect(isBuiltinTheme("terminal")).toBe(true); + expect(isBuiltinTheme("vaporwave")).toBe(false); + }); +}); + +describe("hex color validation", () => { + it("accepts short and long hex forms", () => { + expect(isValidHexColor("#fff")).toBe(true); + expect(isValidHexColor("#67b7ff")).toBe(true); + expect(isValidHexColor("67b7ff")).toBe(true); + }); + + it("rejects non-hex values", () => { + expect(isValidHexColor("red")).toBe(false); + expect(isValidHexColor("#12")).toBe(false); + expect(isValidHexColor("#ggg")).toBe(false); + }); +}); + +describe("resolveTheme", () => { + it("defaults to the configured default theme", () => { + const tokens = resolveTheme(); + expect(tokens.name).toBe(DEFAULT_THEME.name); + expect(tokens.mode).toBe("dark"); + }); + + it("resolves a requested built-in theme", () => { + const tokens = resolveTheme({ name: "paper" }); + expect(tokens.name).toBe("paper"); + expect(tokens.mode).toBe("light"); + }); + + it("falls back to the default theme for unknown names", () => { + const tokens = resolveTheme({ name: "not-a-theme" }); + expect(tokens.name).toBe(DEFAULT_THEME.name); + }); + + it("applies an accent override to derived tokens", () => { + const base = resolveTheme({ name: "deep-space" }); + const tokens = resolveTheme({ name: "deep-space", accent: "#ff0000" }); + expect(tokens.blue).toBe("#ff0000"); + expect(tokens.blue).not.toBe(base.blue); + expect(tokens.glow).toContain("rgba(255, 0, 0,"); + expect(tokens.stripe).toContain("rgba(255, 0, 0,"); + }); + + it("applies radius and font overrides", () => { + const tokens = resolveTheme({ name: "paper", radius: "20px", font: "Georgia, serif" }); + expect(tokens.radius).toBe("20px"); + expect(tokens.font).toBe("Georgia, serif"); + }); +}); + +describe("themeVariables", () => { + it("emits a :root block with color-scheme and core tokens", () => { + const tokens = resolveTheme({ name: "deep-space" }); + const css = themeVariables(tokens); + expect(css).toContain(":root {"); + expect(css).toContain("color-scheme: dark"); + expect(css).toContain("--blue: #67b7ff"); + expect(css).toContain("--font:"); + }); + + it("emits deterministic output for the same theme", () => { + const first = themeVariables(resolveTheme({ name: "paper" })); + const second = themeVariables(resolveTheme({ name: "paper" })); + expect(first).toBe(second); + }); +});