ghost-github-portfolio 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,202 +8,200 @@
8
8
  <a href="https://github.com/GeiserX/ghost-github-portfolio/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/GeiserX/ghost-github-portfolio/ci.yml?style=flat-square&logo=github&label=CI" alt="CI"></a>
9
9
  <a href="LICENSE"><img src="https://img.shields.io/github/license/GeiserX/ghost-github-portfolio?style=flat-square" alt="License"></a>
10
10
  <a href="https://github.com/GeiserX/ghost-github-portfolio/stargazers"><img src="https://img.shields.io/github/stars/GeiserX/ghost-github-portfolio?style=flat-square&logo=github" alt="Stars"></a>
11
+ <a href="https://codecov.io/gh/GeiserX/ghost-github-portfolio"><img src="https://codecov.io/gh/GeiserX/ghost-github-portfolio/graph/badge.svg" alt="codecov"></a>
11
12
  </p>
12
13
 
13
- <p align="center"><strong>Auto-sync your GitHub repositories to a Ghost CMS portfolio page.</strong></p>
14
-
15
- ---
14
+ <p align="center"><strong>Keep a Ghost portfolio page in step with your GitHub repositories.</strong></p>
16
15
 
17
16
  ## What it does
18
17
 
19
- `ghost-github-portfolio` fetches your public GitHub repositories, sorts them by stars, generates beautiful portfolio cards with banners and badges, and pushes the result to a Ghost page via the Admin API.
18
+ `ghost-github-portfolio` reads your public GitHub repositories, keeps the most starred ones, and writes them to a Ghost page as a grid of cards through the Ghost Admin API. Run it on a schedule and the page follows your star counts.
19
+
20
+ The page is one Ghost html card with its own scoped style. It holds a short intro, then a card per repository, then one line that points at your GitHub profile. Each card has:
20
21
 
21
- Run it daily via cron, GitHub Actions, or Docker to keep your portfolio always up to date.
22
+ - the repository banner, or a tile with the repository name when there is no usable banner
23
+ - the name, linked to GitHub, and the star count as plain text, such as "215 stars"
24
+ - one line on what the repository does
25
+ - a "Site" link when the repository has a homepage outside github.com
22
26
 
23
- **Features:**
27
+ The grid has three columns on desktop, two on tablets and one on phones. On themes built on Ghost's content grid, such as Alto, Source and Casper, the cards take the wide column and the text stays at reading width. The colours suit a dark theme.
24
28
 
25
- - Fetches repos sorted by stars, filtered by configurable minimum
26
- - Auto-detects banner images (`docs/images/banner.svg`, `media/banner.svg`, etc.)
27
- - Dynamic shields.io badges (stars, forks, license, Docker pulls, website, awesome-list)
28
- - Per-repo overrides (custom description, badges, Docker image, tech stack)
29
- - Ghost lexical editor format (the current Ghost editor)
30
- - Dry-run mode for previewing changes
31
- - Runs as CLI, Docker container, or GitHub Action
29
+ [docs/preview-desktop.png](docs/preview-desktop.png) shows a real run at 1280 px wide, and [docs/preview-mobile.png](docs/preview-mobile.png) the same page on a phone. [docs/preview-config.yml](docs/preview-config.yml) is the config behind them.
32
30
 
33
31
  ## Quick start
34
32
 
35
- ### 1. Install
33
+ Install it, or run it with `npx`:
36
34
 
37
35
  ```bash
38
- # npm (global)
39
36
  npm install -g ghost-github-portfolio
40
-
41
- # npx (no install)
42
- npx ghost-github-portfolio
43
-
44
- # Docker
45
- docker pull drumsergio/ghost-github-portfolio:0.3.0
46
37
  ```
47
38
 
48
- ### 2. Create a config
39
+ Write an example config and edit it:
49
40
 
50
41
  ```bash
51
42
  ghost-github-portfolio init
52
43
  ```
53
44
 
54
- This generates a `config.yml` with all available options. Edit it with your GitHub username and Ghost API credentials.
55
-
56
- ### 3. Get your Ghost Admin API key
45
+ Get an Admin API key in Ghost under Settings, Integrations, "Add custom integration". It looks like `KEY_ID:SECRET`.
57
46
 
58
- 1. Go to your Ghost Admin panel > **Settings** > **Integrations**
59
- 2. Create a new **Custom Integration**
60
- 3. Copy the **Admin API Key** (format: `KEY_ID:SECRET`)
61
-
62
- ### 4. Run
47
+ Preview the page, then sync it:
63
48
 
64
49
  ```bash
65
- # Preview (no changes to Ghost)
66
- ghost-github-portfolio sync --config config.yml --dry-run --verbose
50
+ # Builds the page and writes a standalone preview. Ghost is not touched
51
+ ghost-github-portfolio sync --config config.yml --html-out preview.html
67
52
 
68
- # Sync to Ghost
69
- ghost-github-portfolio sync --config config.yml --verbose
53
+ # Writes the page to Ghost
54
+ GHOST_ADMIN_API_KEY=KEY_ID:SECRET ghost-github-portfolio sync --config config.yml --verbose
70
55
  ```
71
56
 
57
+ `--dry-run` builds the page and lists each card without writing anything. `--json` prints the Ghost document instead. Both write the html card to `/tmp/ghost-portfolio-preview.html`. `--html-out <file>` also wraps it in a dark page that approximates Ghost's Alto theme, so you can open it in a browser.
58
+
72
59
  ## Configuration
73
60
 
74
61
  ```yaml
75
62
  github:
76
63
  username: YOUR_GITHUB_USERNAME
77
- # token: ghp_xxx # Optional: higher rate limits (env: GHOST_GITHUB_TOKEN)
64
+ # token: ghp_xxx # Optional, for higher rate limits (env: GHOST_GITHUB_TOKEN)
65
+ excludePatterns: # Regular expressions matched against repo names
66
+ - "^homebrew-"
78
67
 
79
68
  ghost:
80
69
  url: https://your-ghost-blog.com
81
- adminApiKey: "KEY_ID:SECRET_HEX"
82
- pageSlug: portfolio # or pageId: "hex_id"
70
+ adminApiKey: "KEY_ID:SECRET_HEX" # env GHOST_ADMIN_API_KEY wins over this
71
+ pageSlug: portfolio # Or pageId with the page's hex ID
72
+ # originUrl: http://ghost:2368 # See "Writing through the Ghost origin"
73
+ # hostHeader: your-ghost-blog.com
83
74
 
84
75
  portfolio:
85
- minStars: 2
86
- maxRepos: 50
76
+ minStars: 2 # Only repos with at least this many stars
77
+ maxRepos: 20 # How many cards, counted after every filter
78
+ columns: 3 # Cards per row on desktop, from 1 to 6
87
79
  includeForked: false
88
- badgeStyle: for-the-badge # flat, flat-square, for-the-badge, plastic, social
80
+ includeArchived: false
81
+ excludeAwesomeLists: true # Skip repos named "awesome*" or tagged "awesome-list"
89
82
  showBanner: true
90
- centerContent: true
91
83
  defaultBannerPath: docs/images/banner.svg
84
+ intro: "I build things nobody asked for, one repo at a time. These are the ones that got the most stars on GitHub, with a line on what each one does."
92
85
 
93
- bannerPaths:
94
- awesome-spain: media/banner.svg # Override per repo
86
+ bannerPaths: # Banner path per repo, tried before defaultBannerPath
87
+ my-project: media/banner.svg
95
88
 
96
89
  excludeRepos:
97
90
  - .github
98
91
 
99
- repos:
92
+ repos: # Per-repo overrides
100
93
  my-project:
101
- description: "Custom description"
102
- dockerImage: myuser/my-project # Adds Docker pulls badge
103
- techStack: "Python, Docker, Redis"
104
- badges:
105
- - type: website
106
- url: https://my-project.com
107
- - type: awesome-list
108
- - type: platform
109
- label: macOS
110
- logo: apple
111
- - type: docs
112
- url: https://docs.my-project.com
113
- - type: custom
114
- label: MCP
115
- value: Official Registry
116
- color: E6522C
117
-
118
- footer:
119
- showStats: true
120
- showViewAll: true
94
+ description: "One line that replaces the GitHub description"
95
+ homepage: https://example.com/guide/ # Replaces the GitHub homepage field
96
+ siteLabel: Guide # Link text, "Site" by default
97
+ bannerPath: assets/banner.png
98
+ exclude: false
121
99
  ```
122
100
 
123
- ### Environment variables
101
+ Filters run in this order: minimum stars, forks, archived, `excludeRepos`, per-repo `exclude`, `excludePatterns`, awesome lists. The tool sorts what is left by stars and only then cuts it to `maxRepos`, so an excluded repository never takes a slot.
124
102
 
125
- | Variable | Description |
126
- |----------|-------------|
127
- | `GHOST_GITHUB_TOKEN` | GitHub token for private repos / higher rate limits |
128
- | `GHOST_ADMIN_API_KEY` | Ghost Admin API key (overrides config file) |
103
+ A repository can also carry its own `.ghost-portfolio.yml` at the root of its default branch with `description`, `homepage`, `siteLabel` and `bannerPath`. Values in your `config.yml` win over it.
129
104
 
130
- ## Docker
105
+ | Variable | Meaning |
106
+ |---|---|
107
+ | `GHOST_ADMIN_API_KEY` | Ghost Admin API key. Overrides `ghost.adminApiKey`, which can then be left out. |
108
+ | `GHOST_GITHUB_TOKEN` | GitHub token for higher rate limits. Used when `github.token` is not set. The generic `GITHUB_TOKEN` is ignored on purpose. |
131
109
 
132
- ```bash
133
- docker run --rm \
134
- -v /path/to/config.yml:/config/config.yml \
135
- drumsergio/ghost-github-portfolio:0.3.0
110
+ ## Descriptions and site links
111
+
112
+ A `description` in `config.yml` is used exactly as written, so that is the place for one-liners you want to keep by hand. Otherwise the tool takes the GitHub description, or the one in `.ghost-portfolio.yml`, and cleans it up:
113
+
114
+ - it keeps the first sentence only
115
+ - em and en dashes become commas
116
+ - emoji and `:shortcodes:` are removed
117
+ - it is cut to 140 characters at a word boundary
118
+
119
+ The site link comes from `homepage` in the overrides, else from the repository's homepage field. A homepage on github.com is skipped, since the name already links there.
120
+
121
+ ## Banners
122
+
123
+ For each repository the tool fetches the per-repo banner path if you set one, then `defaultBannerPath`, from `raw.githubusercontent.com` on the default branch. It uses the first one that passes both checks:
124
+
125
+ 1. It answers HTTP 200.
126
+ 2. If it is an SVG, it is self-contained. Every `href`, `xlink:href`, `url(...)` and `@import` must point at a `#id` inside the file or at a `data:` URI. Browsers never load other files referenced by an SVG that is shown through `<img>`, so a banner that pulls in `logo.png` or a web font comes out blank or broken on the page.
127
+
128
+ When no path passes, the card gets a tile with the repository name, and the run prints a warning that names the reason so you can fix the source:
129
+
130
+ ```
131
+ Warning: my-project gets a name tile: docs/images/banner.svg: not self-contained, it loads logo.png
132
+ Warning: other-project gets a name tile: docs/images/banner.svg: missing (HTTP 404)
136
133
  ```
137
134
 
138
- ### Docker Compose (daily cron)
135
+ To make an SVG self-contained, inline the images it references as base64 `data:` URIs, or redraw them as SVG shapes. The card shows the banner in a 9:2 box and crops it around the centre, so a 900x200 banner fits exactly.
136
+
137
+ ## Writing through the Ghost origin
138
+
139
+ Some setups put a web application firewall in front of the public Ghost URL that rejects request bodies containing `<img`, which every portfolio update does. Set `ghost.originUrl` to reach Ghost directly instead, for example on the local network:
139
140
 
140
141
  ```yaml
141
- services:
142
- ghost-portfolio:
143
- image: drumsergio/ghost-github-portfolio:0.3.0
144
- volumes:
145
- - ./config.yml:/config/config.yml:ro
146
- # Run daily at 6 AM via external cron or restart policy
142
+ ghost:
143
+ url: https://your-ghost-blog.com
144
+ originUrl: http://ghost-host:2368
145
+ hostHeader: your-ghost-blog.com # Defaults to the host of url
146
+ ```
147
+
148
+ Admin API requests then go to `originUrl` with `Host: <hostHeader>` and `X-Forwarded-Proto: https`, so Ghost answers as it does for the public site. The token audience stays `/admin/`. The tool does not follow redirects, because a redirect would lead back through the public URL. Without `originUrl` the requests go to `url` as before.
149
+
150
+ ## Running on a schedule
151
+
152
+ The Docker image runs one sync and exits. To keep it running, loop inside the container and let Docker restart it after a reboot:
153
+
154
+ ```bash
155
+ docker run -d --name ghost-portfolio --restart unless-stopped \
156
+ -v /path/to/config.yml:/config/config.yml:ro \
157
+ --env-file /path/to/portfolio.env \
158
+ --entrypoint sh \
159
+ drumsergio/ghost-github-portfolio:0.4.0 \
160
+ -c 'while true; do node dist/index.js sync --config /config/config.yml; sleep 86400; done'
147
161
  ```
148
162
 
149
- ## GitHub Action
163
+ `portfolio.env` holds `GHOST_ADMIN_API_KEY=KEY_ID:SECRET` and, if you want it, `GHOST_GITHUB_TOKEN`.
164
+
165
+ Or run it from the host's cron, once a day at 06:00:
166
+
167
+ ```
168
+ 0 6 * * * docker run --rm -v /path/to/config.yml:/config/config.yml:ro --env-file /path/to/portfolio.env drumsergio/ghost-github-portfolio:0.4.0
169
+ ```
150
170
 
151
- Create `.github/workflows/portfolio.yml` in any repo:
171
+ A GitHub Actions schedule works too, as long as the runner can reach your Ghost URL. A GitHub-hosted runner cannot reach an `originUrl` on a private network.
152
172
 
153
173
  ```yaml
154
- name: Update Portfolio
174
+ name: Update portfolio
155
175
 
156
176
  on:
157
177
  schedule:
158
- - cron: "0 6 * * *" # Daily at 6 AM UTC
159
- workflow_dispatch: # Manual trigger
178
+ - cron: "0 6 * * *"
179
+ workflow_dispatch:
160
180
 
161
181
  jobs:
162
182
  sync:
163
183
  runs-on: ubuntu-latest
164
184
  steps:
165
185
  - uses: actions/checkout@v4
166
-
167
186
  - uses: actions/setup-node@v4
168
187
  with:
169
188
  node-version: 22
170
-
171
- - run: npx ghost-github-portfolio sync --config config.yml --verbose
189
+ - run: npx ghost-github-portfolio@0.4.0 sync --config config.yml --verbose
172
190
  env:
173
- GHOST_GITHUB_TOKEN: ${{ secrets.GHOST_GITHUB_TOKEN }}
191
+ GHOST_ADMIN_API_KEY: ${{ secrets.GHOST_ADMIN_API_KEY }}
192
+ GHOST_GITHUB_TOKEN: ${{ github.token }}
174
193
  ```
175
194
 
176
- Store `config.yml` in the repo (without secrets) and use environment variables for the API keys.
195
+ Keep `config.yml` in the repository without the key, and store the key as a secret.
177
196
 
178
197
  ## How it works
179
198
 
180
- 1. **Fetches** all public repos for the configured GitHub user via the REST API
181
- 2. **Filters** by minimum stars, excludes forks and blocklisted repos
182
- 3. **Sorts** by star count (descending)
183
- 4. **Detects** banner images by checking common paths (`docs/images/banner.svg`, `media/banner.svg`, etc.)
184
- 5. **Generates** HTML cards with: banner, name, dynamic badges, description, tech stack
185
- 6. **Composes** a Ghost lexical JSON document (the format Ghost's editor uses internally)
186
- 7. **Updates** the target Ghost page via the Admin API with JWT authentication
187
-
188
- Badges are dynamic (served by shields.io) — stars, forks, and Docker pulls update automatically on every page view without re-running the tool.
189
-
190
- ## Badge types
191
-
192
- | Type | Auto-detected | Description |
193
- |------|--------------|-------------|
194
- | Stars | Yes | Always shown |
195
- | Forks | Yes | Always shown |
196
- | License | Yes | From GitHub repo metadata |
197
- | Docker pulls | Config | Set `dockerImage` in repo overrides |
198
- | Website | Yes | From GitHub homepage field |
199
- | Awesome list | Yes | If repo has `awesome-list` topic |
200
- | Docs | Yes | If homepage is a GitHub Pages URL |
201
- | Platform | Config | Custom platform badge (macOS, Linux, etc.) |
202
- | Custom | Config | Any shields.io-compatible badge |
203
-
204
- ## Roadmap
205
-
206
- See [docs/ROADMAP.md](docs/ROADMAP.md) for the full roadmap — themes, multi-CMS support, AI features, analytics, team portfolios, and more.
199
+ 1. Fetches every public repository the user owns from the GitHub REST API. The API cannot sort by stars, so it reads all pages and sorts locally.
200
+ 2. Applies the filters, sorts by stars and keeps `maxRepos`.
201
+ 3. Reads each repository's `.ghost-portfolio.yml`, if any.
202
+ 4. Checks each banner as described above.
203
+ 5. Builds one html card and wraps it in the Ghost lexical document format.
204
+ 6. Fetches the page to get its `updated_at`, then writes the new content through the Admin API with a short-lived JWT.
207
205
 
208
206
  ## License
209
207
 
package/dist/config.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Config } from "./types.js";
2
+ export declare const DEFAULT_INTRO = "I build things nobody asked for, one repo at a time. These are the ones that got the most stars on GitHub, with a line on what each one does.";
3
+ export declare const DEFAULT_EXCLUDE_PATTERNS: string[];
2
4
  export declare function loadConfig(path: string): Config;
3
5
  export declare function generateExampleConfig(): string;
4
6
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAmB,MAAM,YAAY,CAAC;AAiB1D,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAkD/C;AAED,wBAAgB,qBAAqB,IAAI,MAAM,CAuD9C"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAmB,MAAM,YAAY,CAAC;AAE1D,eAAO,MAAM,aAAa,kJACuH,CAAC;AAElJ,eAAO,MAAM,wBAAwB,UAAiB,CAAC;AAqBvD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA8E/C;AAgBD,wBAAgB,qBAAqB,IAAI,MAAM,CA2C9C"}
package/dist/config.js CHANGED
@@ -1,118 +1,146 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { parse } from "yaml";
3
+ export const DEFAULT_INTRO = "I build things nobody asked for, one repo at a time. These are the ones that got the most stars on GitHub, with a line on what each one does.";
4
+ export const DEFAULT_EXCLUDE_PATTERNS = ["^homebrew-"];
3
5
  const DEFAULT_PORTFOLIO = {
4
6
  minStars: 2,
5
- maxRepos: 50,
7
+ maxRepos: 20,
8
+ columns: 3,
6
9
  excludeRepos: [],
7
10
  includeForked: false,
8
- excludeAwesomeLists: false,
9
- badgeStyle: "for-the-badge",
11
+ includeArchived: false,
12
+ excludeAwesomeLists: true,
10
13
  showBanner: true,
11
- centerContent: true,
12
14
  defaultBannerPath: "docs/images/banner.svg",
13
15
  bannerPaths: {},
16
+ intro: DEFAULT_INTRO,
14
17
  repos: {},
15
- footer: { showStats: true, showViewAll: true },
16
18
  };
19
+ function trimSlash(url) {
20
+ return url.replace(/\/+$/, "");
21
+ }
17
22
  export function loadConfig(path) {
18
23
  const raw = readFileSync(path, "utf-8");
19
- const parsed = parse(raw);
20
- if (!parsed.github || !parsed.github.username) {
24
+ const parsed = (parse(raw) ?? {});
25
+ const github = (parsed.github ?? {});
26
+ const ghost = (parsed.ghost ?? {});
27
+ if (!github.username) {
21
28
  throw new Error("Config: github.username is required");
22
29
  }
23
- if (!parsed.ghost || !parsed.ghost.url) {
30
+ if (!ghost.url) {
24
31
  throw new Error("Config: ghost.url is required");
25
32
  }
26
- if (!parsed.ghost.adminApiKey) {
27
- throw new Error("Config: ghost.adminApiKey is required");
33
+ // The environment wins over the config file, so the key can stay out of it.
34
+ const ghostKey = process.env.GHOST_ADMIN_API_KEY || ghost.adminApiKey;
35
+ if (!ghostKey) {
36
+ throw new Error("Config: ghost.adminApiKey is required (or set GHOST_ADMIN_API_KEY)");
28
37
  }
29
- const ghost = parsed.ghost;
30
38
  if (!ghost.pageId && !ghost.pageSlug) {
31
39
  throw new Error("Config: ghost.pageId or ghost.pageSlug is required");
32
40
  }
33
- const github = parsed.github;
34
- const portfolio = (parsed.portfolio ?? {});
35
- // Env var overrides (GHOST_GITHUB_TOKEN only — GITHUB_TOKEN is not used to
36
- // avoid picking up unrelated tokens from gh CLI or CI environments)
41
+ // GHOST_GITHUB_TOKEN only. GITHUB_TOKEN is not used, to avoid picking up
42
+ // unrelated tokens from the gh CLI or CI environments.
37
43
  const ghToken = github.token ?? process.env.GHOST_GITHUB_TOKEN;
38
- const ghostKey = ghost.adminApiKey ?? process.env.GHOST_ADMIN_API_KEY;
44
+ const excludePatterns = (github.excludePatterns ??
45
+ DEFAULT_EXCLUDE_PATTERNS);
46
+ if (!Array.isArray(excludePatterns)) {
47
+ throw new Error("Config: github.excludePatterns must be a list");
48
+ }
49
+ for (const pattern of excludePatterns) {
50
+ try {
51
+ new RegExp(String(pattern), "i");
52
+ }
53
+ catch {
54
+ throw new Error(`Config: github.excludePatterns has an invalid pattern: ${pattern}`);
55
+ }
56
+ }
57
+ const portfolio = (parsed.portfolio ?? {});
58
+ const merged = {
59
+ ...DEFAULT_PORTFOLIO,
60
+ ...portfolio,
61
+ bannerPaths: { ...portfolio.bannerPaths },
62
+ repos: { ...portfolio.repos },
63
+ excludeRepos: portfolio.excludeRepos ?? [],
64
+ };
65
+ if (!Number.isInteger(merged.columns) || merged.columns < 1 || merged.columns > 6) {
66
+ throw new Error("Config: portfolio.columns must be a whole number from 1 to 6");
67
+ }
68
+ if (!Number.isInteger(merged.maxRepos) || merged.maxRepos < 1) {
69
+ throw new Error("Config: portfolio.maxRepos must be a whole number above 0");
70
+ }
39
71
  return {
40
72
  github: {
41
73
  username: github.username,
42
74
  token: ghToken,
75
+ excludePatterns: excludePatterns.map(String),
43
76
  },
44
77
  ghost: {
45
- url: ghost.url.replace(/\/$/, ""),
78
+ url: trimSlash(ghost.url),
46
79
  adminApiKey: ghostKey,
47
80
  pageId: ghost.pageId,
48
81
  pageSlug: ghost.pageSlug,
82
+ originUrl: ghost.originUrl ? validateOriginUrl(ghost.originUrl) : undefined,
83
+ hostHeader: ghost.hostHeader,
49
84
  },
50
- portfolio: {
51
- ...DEFAULT_PORTFOLIO,
52
- ...portfolio,
53
- footer: { ...DEFAULT_PORTFOLIO.footer, ...portfolio.footer },
54
- bannerPaths: {
55
- ...DEFAULT_PORTFOLIO.bannerPaths,
56
- ...portfolio.bannerPaths,
57
- },
58
- repos: { ...DEFAULT_PORTFOLIO.repos, ...portfolio.repos },
59
- },
85
+ portfolio: merged,
60
86
  };
61
87
  }
88
+ /** The origin route carries the Admin key in a JWT, so the scheme must be plain http: or https:. */
89
+ function validateOriginUrl(value) {
90
+ let parsed;
91
+ try {
92
+ parsed = new URL(value);
93
+ }
94
+ catch {
95
+ throw new Error("Config: ghost.originUrl must be a full URL such as http://ghost:2368");
96
+ }
97
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
98
+ throw new Error("Config: ghost.originUrl must start with http:// or https://");
99
+ }
100
+ return trimSlash(value);
101
+ }
62
102
  export function generateExampleConfig() {
63
103
  return `# ghost-github-portfolio configuration
64
104
  github:
65
105
  username: YOUR_GITHUB_USERNAME
66
- # token: ghp_xxx # Optional: for private repos or higher rate limits (env: GHOST_GITHUB_TOKEN)
106
+ # token: ghp_xxx # Optional, for higher rate limits (env: GHOST_GITHUB_TOKEN)
107
+ excludePatterns: # Regular expressions matched against repo names
108
+ - "^homebrew-"
67
109
 
68
110
  ghost:
69
111
  url: https://your-ghost-blog.com
70
- adminApiKey: "KEY_ID:SECRET_HEX" # From Ghost Admin > Integrations (env: GHOST_ADMIN_API_KEY)
71
- pageSlug: portfolio # Update this page (or use pageId for the hex ID)
112
+ adminApiKey: "KEY_ID:SECRET_HEX" # Ghost Admin > Integrations (env GHOST_ADMIN_API_KEY wins)
113
+ pageSlug: portfolio # Or pageId with the page's hex ID
114
+ # originUrl: http://ghost:2368 # Send Admin API requests straight to the Ghost origin
115
+ # hostHeader: your-ghost-blog.com # Host header for originUrl (defaults to the host of url)
72
116
 
73
117
  portfolio:
74
- minStars: 2 # Only show repos with at least this many stars
75
- maxRepos: 50 # Maximum number of repos to display
76
- includeForked: false # Skip forked repos
77
- excludeAwesomeLists: false # Skip repos starting with "awesome" or tagged "awesome-list"
78
- badgeStyle: for-the-badge # shields.io style: flat, flat-square, for-the-badge, plastic, social
79
- showBanner: true # Show repo banner images (SVG/PNG from the repo)
80
- centerContent: true # Center project names and badges
118
+ minStars: 2 # Only repos with at least this many stars
119
+ maxRepos: 20 # How many cards to show, counted after every filter
120
+ columns: 3 # Cards per row on desktop (two on tablets, one on phones)
121
+ includeForked: false
122
+ includeArchived: false
123
+ excludeAwesomeLists: true # Skip repos named "awesome*" or tagged "awesome-list"
124
+ showBanner: true # Use the repo banner when it passes the checks, else a name tile
125
+ defaultBannerPath: docs/images/banner.svg
81
126
 
82
- defaultBannerPath: docs/images/banner.svg # Default location to look for banners
127
+ intro: "${DEFAULT_INTRO}"
83
128
 
84
- # Override banner path for specific repos
85
- bannerPaths:
86
- # awesome-spain: media/banner.svg
129
+ # Banner path for specific repos, tried before defaultBannerPath
130
+ bannerPaths: {}
131
+ # my-project: media/banner.svg
87
132
 
88
133
  excludeRepos:
89
- - .github # Profile config repo
90
- # - my-private-project
134
+ - .github
91
135
 
92
136
  # Per-repo overrides
93
137
  repos: {}
94
138
  # my-project:
95
- # description: "Custom description (overrides GitHub)"
96
- # dockerImage: drumsergio/my-project # Adds Docker pulls badge
97
- # techStack: "Python, Docker, Redis"
139
+ # description: "One line that replaces the GitHub description"
140
+ # homepage: https://example.com/guide/ # Replaces the GitHub homepage field
141
+ # siteLabel: Guide # Link text, defaults to "Site"
98
142
  # bannerPath: assets/banner.png
99
- # badges:
100
- # - type: website
101
- # url: https://my-project.com
102
- # - type: awesome-list
103
- # - type: platform
104
- # label: macOS
105
- # logo: apple
106
- # - type: docs
107
- # url: https://docs.my-project.com
108
- # - type: custom
109
- # label: MCP
110
- # value: Official Registry
111
- # color: E6522C
112
-
113
- footer:
114
- showStats: true # Show total stars and repo count
115
- showViewAll: true # Show "View All Repositories" link
143
+ # exclude: false
116
144
  `;
117
145
  }
118
146
  //# sourceMappingURL=config.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,KAAK,EAAE,MAAM,MAAM,CAAC;AAG7B,MAAM,iBAAiB,GAAoB;IACzC,QAAQ,EAAE,CAAC;IACX,QAAQ,EAAE,EAAE;IACZ,YAAY,EAAE,EAAE;IAChB,aAAa,EAAE,KAAK;IACpB,mBAAmB,EAAE,KAAK;IAC1B,UAAU,EAAE,eAAe;IAC3B,UAAU,EAAE,IAAI;IAChB,aAAa,EAAE,IAAI;IACnB,iBAAiB,EAAE,wBAAwB;IAC3C,WAAW,EAAE,EAAE;IACf,KAAK,EAAE,EAAE;IACT,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE;CAC/C,CAAC;AAEF,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IACxC,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAA4B,CAAC;IAErD,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,CAAE,MAAM,CAAC,MAAkC,CAAC,QAAQ,EAAE,CAAC;QAC3E,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;IACzD,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,KAAK,IAAI,CAAE,MAAM,CAAC,KAAiC,CAAC,GAAG,EAAE,CAAC;QACpE,MAAM,IAAI,KAAK,CAAC,+BAA+B,CAAC,CAAC;IACnD,CAAC;IACD,IAAI,CAAE,MAAM,CAAC,KAAiC,CAAC,WAAW,EAAE,CAAC;QAC3D,MAAM,IAAI,KAAK,CAAC,uCAAuC,CAAC,CAAC;IAC3D,CAAC;IAED,MAAM,KAAK,GAAG,MAAM,CAAC,KAAgC,CAAC;IACtD,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACxE,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,MAAgC,CAAC;IACvD,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,SAAS,IAAI,EAAE,CAA6B,CAAC;IAEvE,2EAA2E;IAC3E,oEAAoE;IACpE,MAAM,OAAO,GAAG,MAAM,CAAC,KAAK,IAAI,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC;IAC/D,MAAM,QAAQ,GACX,KAAK,CAAC,WAAsB,IAAI,OAAO,CAAC,GAAG,CAAC,mBAAmB,CAAC;IAEnE,OAAO;QACL,MAAM,EAAE;YACN,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,KAAK,EAAE,OAAO;SACf;QACD,KAAK,EAAE;YACL,GAAG,EAAG,KAAK,CAAC,GAAc,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC;YAC7C,WAAW,EAAE,QAAQ;YACrB,MAAM,EAAE,KAAK,CAAC,MAA4B;YAC1C,QAAQ,EAAE,KAAK,CAAC,QAA8B;SAC/C;QACD,SAAS,EAAE;YACT,GAAG,iBAAiB;YACpB,GAAG,SAAS;YACZ,MAAM,EAAE,EAAE,GAAG,iBAAiB,CAAC,MAAM,EAAE,GAAG,SAAS,CAAC,MAAM,EAAE;YAC5D,WAAW,EAAE;gBACX,GAAG,iBAAiB,CAAC,WAAW;gBAChC,GAAG,SAAS,CAAC,WAAW;aACzB;YACD,KAAK,EAAE,EAAE,GAAG,iBAAiB,CAAC,KAAK,EAAE,GAAG,SAAS,CAAC,KAAK,EAAE;SAC1D;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,qBAAqB;IACnC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqDR,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,39 @@
1
1
  import type { Config, GitHubRepo, LexicalDocument } from "./types.js";
2
+ export declare const MAX_DESCRIPTION_LENGTH = 140;
3
+ /**
4
+ * Turn a GitHub description into one short line: emoji and :shortcodes:
5
+ * removed, em and en dashes turned into commas, first sentence only,
6
+ * at most 140 characters.
7
+ */
8
+ export declare function cleanDescription(text: string, max?: number): string;
9
+ export declare function formatStars(count: number): string;
10
+ export interface SiteLink {
11
+ url: string;
12
+ label: string;
13
+ }
14
+ /** The card's site link: the override, else the GitHub homepage unless it points at github.com. */
15
+ export declare function siteLink(repo: GitHubRepo, config: Config): SiteLink | null;
16
+ export declare function repoDescription(repo: GitHubRepo, config: Config): string;
2
17
  export declare function generateCard(repo: GitHubRepo, bannerUrl: string | null, config: Config): string;
3
- export declare function generateFooter(allRepos: GitHubRepo[], config: Config): string | null;
4
- export declare function buildLexical(cards: string[], footer: string | null): LexicalDocument;
18
+ export interface PortfolioInput {
19
+ /** Card HTML from generateCard, in display order. */
20
+ cards: string[];
21
+ /** Number of public repositories the user owns, used for the closing line. */
22
+ totalRepos: number;
23
+ }
24
+ /**
25
+ * The whole page as the HTML of one Ghost html card. The `kg-width-wide`
26
+ * class lets themes built on Ghost's content grid (Alto, Source, Casper)
27
+ * give the cards the wide column; the text stays at reading width.
28
+ */
29
+ export declare function generatePortfolioHtml(input: PortfolioInput, config: Config): string;
30
+ export declare function buildLexical(html: string): LexicalDocument;
31
+ /** The html card as Ghost renders it, with its begin and end markers. */
32
+ export declare function wrapHtmlCard(html: string): string;
33
+ /**
34
+ * A standalone dark page that approximates Ghost's Alto theme in dark mode
35
+ * (content grid, widths, the .gh-content rules that reach into an html
36
+ * card), for checking the output in a browser before syncing.
37
+ */
38
+ export declare function buildPreviewPage(html: string, title?: string): string;
5
39
  //# sourceMappingURL=generator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../src/generator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,MAAM,EACN,UAAU,EACV,eAAe,EAEhB,MAAM,YAAY,CAAC;AAyGpB,wBAAgB,YAAY,CAC1B,IAAI,EAAE,UAAU,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,MAAM,EAAE,MAAM,GACb,MAAM,CA+DR;AAuCD,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,UAAU,EAAE,EACtB,MAAM,EAAE,MAAM,GACb,MAAM,GAAG,IAAI,CAsCf;AAED,wBAAgB,YAAY,CAC1B,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,MAAM,GAAG,IAAI,GACpB,eAAe,CA0BjB"}
1
+ {"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../src/generator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEtE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAK1C;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,GAAG,SAAyB,GAC3B,MAAM,CA4BR;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,MAAM,WAAW,QAAQ;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AAED,mGAAmG;AACnG,wBAAgB,QAAQ,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAgB1E;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAIxE;AAED,wBAAgB,YAAY,CAC1B,IAAI,EAAE,UAAU,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,MAAM,EAAE,MAAM,GACb,MAAM,CA0BR;AAsCD,MAAM,WAAW,cAAc;IAC7B,qDAAqD;IACrD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,8EAA8E;IAC9E,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,MAAM,GACb,MAAM,CAiBR;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,CAW1D;AAED,yEAAyE;AACzE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,SAAc,GAAG,MAAM,CAwC1E"}