⚠️ IMPORTANT: Do NOT update this file unless the user explicitly says to. Only the user can authorize changes to AGENTS.md.
📦 RELEASE REMINDER: npm publishing and Docker push are handled by GitHub Actions on tag push (
v*). Do NOT runnpm publishlocally. Do NOT create git tags manually. Push a tag and the workflow handles npm + Docker Hub + GitHub Release.
🔒 SECURITY WARNING: This repository is PUBLIC at github.com/GeiserX/ghost-github-portfolio. NEVER commit secrets, API keys, Ghost admin tokens, or any sensitive data. All secrets must go through:
- GitHub Secrets (for CI/CD and GitHub Action inputs)
- Environment variables (
GHOST_GITHUB_TOKEN,GHOST_ADMIN_API_KEY)- Local
config.yml(gitignored)
ghost-github-portfolio is a CLI tool, Docker image, and GitHub Action that auto-syncs GitHub repositories to a Ghost CMS portfolio page. It fetches repos via the GitHub REST API, sorts by stars, generates HTML cards with dynamic banners and shields.io badges, and updates a Ghost page via the Admin API using the lexical editor format.
- npm:
ghost-github-portfolio - Docker Hub:
drumsergio/ghost-github-portfolio - Repository: https://github.com/GeiserX/ghost-github-portfolio
- License: GPL-3.0
| Technology | Purpose |
|---|---|
| TypeScript | Language (strict mode, ES2022, NodeNext) |
| Node.js 18+ | Runtime (native fetch, crypto) |
| Commander | CLI framework |
| YAML | Config file parsing |
| Vitest | Test framework |
| Docker | Multi-stage Alpine container |
| GitHub Actions | CI (Node 18/20/22 matrix), Release (npm + Docker + GH Release) |
Zero external HTTP dependencies - uses only Node.js native fetch and crypto. No axios, no node-fetch, no jsonwebtoken.
src/
├── index.ts # CLI entry point (commander: sync + init commands)
├── config.ts # YAML config loader, defaults, env var overrides, validation
├── github.ts # GitHub REST API: fetch all repos (paginated), sort client-side, detect banners via HEAD
├── ghost.ts # Ghost Admin API: JWT generation (HS256), fetch page, update page (lexical format)
├── generator.ts # HTML card generation: banners, badges, footer, lexical document builder
└── types.ts # TypeScript interfaces: Config, GitHubRepo, LexicalDocument, CustomBadge
-
Client-side star sorting: GitHub REST API
/users/{user}/reposdoes NOT supportsort=stars. All pages are fetched, then sorted in memory. Do NOT addsort=starsto the API URL - it is an invalid parameter that causes unpredictable ordering. -
GHOST_GITHUB_TOKENonly: The env var is intentionally NOTGITHUB_TOKEN. The standardGITHUB_TOKENenv var is often set by CI runners orghCLI and may hold tokens with wrong scopes. Using a dedicated name avoids accidental token pickup. -
Ghost lexical format: Ghost uses lexical (NOT mobiledoc) for its editor. The document is a JSON AST with
htmlnodes andhorizontalrulenodes. Seetypes.tsfor the schema. -
JWT authentication: Ghost Admin API uses HS256 JWT with the key ID in the
kidheader field. The secret is hex-decoded. Tokens expire in 5 minutes. Implementation is inghost.tsusing onlynode:crypto. -
Banner detection: Checks multiple candidate paths via HEAD requests to
raw.githubusercontent.com. Config overrides take priority, then default path, then candidates list. All checks are parallel per repo. -
Dynamic badges: All shields.io badges are live URLs — stars, forks, Docker pulls update on every page view without re-running the tool. Only the page structure (which repos, order, banners) requires re-syncing.
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
Push to main, PRs | Build + lint + test on Node 18/20/22; Docker build + verify |
release.yml |
Tag push v* |
npm publish, Docker multi-arch build (amd64+arm64) to Docker Hub, GitHub Release |
stale.yml |
Daily schedule | Auto-close stale issues (14d stale + 14d close) |
# 1. Bump version in package.json
npm version minor --no-git-tag-version # or patch/major
# 2. Commit
git add package.json package-lock.json
git commit -m "feat: description of changes"
# 3. Create and push tag
git tag v1.1.0
git push origin main --tags
# 4. release.yml handles: npm publish + Docker push + GH ReleaseNEVER run npm publish locally or create GitHub Releases manually.
- TypeScript strict mode (
strict: truein tsconfig) - ESM modules (
"type": "module"in package.json) - All imports use
.jsextension (NodeNext resolution) - No external HTTP libraries — native
fetchonly - No JWT libraries — manual HS256 via
node:crypto - Tests use Vitest with
.test.tssuffix - Config file is YAML (not JSON, not TOML)
- Source:
src/*.ts(flat structure, no nested directories) - Tests:
src/*.test.ts(co-located with source) - Config:
config.yml(gitignored, user-provided)
- Throw descriptive
Errorwith context (API status, body preview) - CLI catches at top level and exits with code 1
- No silent failures — banner detection returns
nullon failure, but HTTP errors throw
- All user-provided strings go through
escapeHtml()(XSS prevention) - Badges use shields.io dynamic URLs (not static SVGs)
- Cards use inline styles (Ghost strips
<style>blocks and classes) - Ghost lexical format requires specific node types — do NOT invent new node types
The config YAML has three top-level keys:
github:
username: string # Required
token: string # Optional (env: GHOST_GITHUB_TOKEN)
ghost:
url: string # Required (trailing slash stripped)
adminApiKey: string # Required, format "KEY_ID:SECRET_HEX" (env: GHOST_ADMIN_API_KEY)
pageId: string # One of pageId or pageSlug required
pageSlug: string # One of pageId or pageSlug required
portfolio: # All optional, has defaults
minStars: 2
maxRepos: 50
includeForked: false
badgeStyle: for-the-badge
showBanner: true
centerContent: true
defaultBannerPath: docs/images/banner.svg
bannerPaths: {} # repo-name: path overrides
excludeRepos: []
repos: {} # Per-repo overrides (description, dockerImage, badges, techStack, keyFeatures)
footer:
showStats: true
showViewAll: truenpm test # Run all tests (vitest)
npm run test:watch # Watch modesrc/config.test.ts— Config loading, defaults, validation errorssrc/generator.test.ts— Card generation, badges, banners, centering, escaping, footer, lexical structuresrc/ghost.test.ts— JWT structure, header kid, payload aud, signature verification
- Config validation (missing fields throw descriptive errors)
- Badge generation for all types (stars, forks, license, docker, website, awesome-list, custom)
- HTML escaping of user-provided content
- Lexical document structure matches Ghost's expected format
- Banner URL construction with overrides
Multi-stage build:
- Builder:
node:22-alpine,npm ci,tsc - Runtime:
node:22-alpine, production deps only, copiesdist/
# Build
docker build -t ghost-github-portfolio .
# Run
docker run --rm -v /path/to/config.yml:/config/config.yml ghost-github-portfolioEntrypoint is node dist/index.js, default CMD is sync --config /config/config.yml.
Composite action that installs Node 22, builds from source, and runs sync. Inputs:
config-path(default:config.yml)ghost-url,ghost-admin-api-key,ghost-page-slug(override config)github-username,min-stars
- Ghost redirects to canonical URL: Ghost 301-redirects all API requests to its configured canonical URL. Always use the public Ghost URL, not localhost.
updated_atconcurrency: Ghost uses optimistic concurrency. The PUT request must include the currentupdated_atfrom a fresh GET. Stale values cause 409 errors.- GitHub pagination: The API returns max 100 repos per page. Must loop until
repos.length < perPage. Never assume a single page is enough. - Inline styles only: Ghost strips CSS classes and
<style>tags. All styling must use inlinestyle=""attributes. - Banner HEAD requests:
raw.githubusercontent.comreturns 404 for missing files — no error page. HEAD requests are cheap and reliable for detection.
Before completing a task, verify:
- TypeScript compiles without errors (
npm run lint) - All tests pass (
npm test) - No secrets in code or config files
- HTML output uses
escapeHtml()for user content - Ghost lexical format is valid (html + horizontalrule nodes only)
- Changes work with Node 18, 20, and 22
- Docker build succeeds
- README updated if public API changed
Generated by LynxPrompt CLI