headreel 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ariful Alam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # headreel
2
+
3
+ [![npm](https://img.shields.io/npm/v/headreel)](https://www.npmjs.com/package/headreel)
4
+ [![CI](https://github.com/arifszn/headreel/actions/workflows/ci.yml/badge.svg)](https://github.com/arifszn/headreel/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+
7
+ An animated banner for your GitHub profile, made from your own GitHub activity.
8
+
9
+ ![Contribution City banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/contribution-city.gif)
10
+
11
+ headreel turns your contributions into a looping GIF. You can use it in two ways:
12
+
13
+ - **One command on your computer.** Run `npx headreel` and get your banner. You do not install or set up anything. It works on macOS, Windows, and Linux.
14
+ - **A GitHub Action that keeps it current.** Add one workflow file, and your banner updates every day by itself.
15
+
16
+ Every style works on both light and dark GitHub themes.
17
+
18
+ ## Make your banner now
19
+
20
+ Run this command. Replace `octocat` with your GitHub username.
21
+
22
+ ```bash
23
+ npx headreel --style contribution-city --user octocat
24
+ ```
25
+
26
+ The command saves your banner as `headreel.gif` in the current folder. The first run downloads headreel. After that, a banner takes a few seconds.
27
+
28
+ Add your tagline and website:
29
+
30
+ ```bash
31
+ npx headreel --style contribution-city --user octocat \
32
+ --tagline "Open source maintainer" \
33
+ --website https://example.com
34
+ ```
35
+
36
+ You need [Node.js](https://nodejs.org) 22 (22.22.2 or later), 24 (24.15.0 or later), or 26 and later. You also need a GitHub token. If you use the [GitHub CLI](https://cli.github.com), log in with `gh auth login`, and headreel finds the token. For other ways, see [Command options](#command-options).
37
+
38
+ To put the banner on your profile, add `headreel.gif` to your profile repository. Then add this line to its `README.md`:
39
+
40
+ ```markdown
41
+ ![My GitHub activity](headreel.gif)
42
+ ```
43
+
44
+ ## Keep it updated with the GitHub Action
45
+
46
+ A banner that you make on your computer does not change. The Action makes a new banner every day.
47
+
48
+ Your profile repository has the same name as your username, for example `octocat/octocat`. Do these steps in that repository.
49
+
50
+ 1. Create the file `.github/workflows/headreel.yml` with this content:
51
+
52
+ ```yaml
53
+ name: headreel
54
+
55
+ on:
56
+ schedule:
57
+ - cron: '0 0 * * *' # every day at 00:00 UTC
58
+ workflow_dispatch: # lets you run it from the Actions tab
59
+
60
+ permissions:
61
+ contents: write
62
+
63
+ jobs:
64
+ banner:
65
+ runs-on: ubuntu-latest
66
+ steps:
67
+ - uses: actions/checkout@v7
68
+ - uses: arifszn/headreel@v1
69
+ with:
70
+ style: contribution-city
71
+ tagline: Senior Software Engineer · Distributed Systems
72
+ website: https://example.com
73
+ ```
74
+
75
+ 2. Open the **Actions** tab of the repository. Select **headreel**, then select **Run workflow**.
76
+ 3. Wait for the run to finish. The workflow adds `headreel.gif` to the repository.
77
+ 4. Add this line to your `README.md`:
78
+
79
+ ```markdown
80
+ ![My GitHub activity](headreel.gif)
81
+ ```
82
+
83
+ > [!NOTE]
84
+ > The workflow makes a commit only when the banner changes. Your contribution count changes most days, so expect about one commit each day.
85
+
86
+ ## Action inputs
87
+
88
+ | Input | Default | Description |
89
+ | ---------------- | ------------------------------- | ------------------------------------------------------------------ |
90
+ | `style` | (required) | The banner style. See [Styles](#styles). |
91
+ | `username` | repository owner | The GitHub user to show. |
92
+ | `tagline` | empty | One line under your name. Empty means no tagline. |
93
+ | `website` | empty | Your website, for example `https://example.com`. Empty means none. |
94
+ | `handle` | empty | A handle, for styles that show one. |
95
+ | `options` | empty | Style settings, one `key: value` on each line. |
96
+ | `output` | `headreel.gif` | The path of the banner in your repository. |
97
+ | `commit_to` | the checked-out branch | The branch that gets the banner. |
98
+ | `commit_message` | `chore: update headreel banner` | The commit message for each update. |
99
+ | `token` | `github.token` | The token that reads your contribution data. |
100
+
101
+ Your name comes from your GitHub profile. If your profile has no name, the banner shows your username.
102
+
103
+ ## Styles
104
+
105
+ ### Contribution City
106
+
107
+ `style: contribution-city`
108
+
109
+ Each day of the last 12 months is one building. A taller building means more contributions on that day. A light beam moves across the city, and beacons glow on your busiest days.
110
+
111
+ The banner shows your name, your tagline, your total contributions, and your website.
112
+
113
+ | Option | Default | Description |
114
+ | --------- | ------- | -------------------------------------------------- |
115
+ | `beacons` | `8` | The number of busiest days with a beacon, 0 to 10. |
116
+
117
+ Example:
118
+
119
+ ```yaml
120
+ - uses: arifszn/headreel@v1
121
+ with:
122
+ style: contribution-city
123
+ options: |
124
+ beacons: 5
125
+ ```
126
+
127
+ ## Command options
128
+
129
+ | Option | Description |
130
+ | ---------------------- | ----------------------------------------------------- |
131
+ | `--style <id>` | The banner style (required). See [Styles](#styles). |
132
+ | `--user <login>` | Your GitHub username (required). |
133
+ | `--out <file>` | Where to save the GIF. The default is `headreel.gif`. |
134
+ | `--tagline <text>` | One line under your name. |
135
+ | `--website <url>` | Your website. |
136
+ | `--handle <text>` | A handle, for styles that show one. |
137
+ | `--option <key=value>` | A style setting. Use it again for each setting. |
138
+ | `--config <file>` | A JSON file with your settings. |
139
+ | `--token <token>` | A GitHub token. See the token order below. |
140
+
141
+ headreel needs a GitHub token to read your contributions. It looks for a token in this order:
142
+
143
+ 1. The `--token` option.
144
+ 2. The `GITHUB_TOKEN` environment variable.
145
+ 3. The [GitHub CLI](https://cli.github.com), if you are logged in with `gh auth login`.
146
+
147
+ You can keep your settings in a JSON file:
148
+
149
+ ```json
150
+ {
151
+ "style": "contribution-city",
152
+ "user": "octocat",
153
+ "tagline": "Open source maintainer",
154
+ "website": "https://example.com",
155
+ "options": { "beacons": 5 }
156
+ }
157
+ ```
158
+
159
+ ```bash
160
+ npx headreel --config headreel.json
161
+ ```
162
+
163
+ Options on the command line replace the values in the file. To see all options, run `npx headreel --help`.
164
+
165
+ ## Troubleshooting
166
+
167
+ **The workflow fails with "Permission denied" or a 403 error.**
168
+ Make sure that the workflow file contains `permissions: contents: write`.
169
+
170
+ **The workflow fails with "Could not resolve to a User".**
171
+ Check the `username` input. It must be a GitHub username.
172
+
173
+ **The command fails with "No GitHub token found".**
174
+ Log in with `gh auth login`, or set the `GITHUB_TOKEN` environment variable.
@@ -0,0 +1,93 @@
1
+ Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,133 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parseArgs } from 'node:util';
3
+ import { z } from 'zod';
4
+ export const USAGE = `Usage: headreel --style <style> --user <login> [options]
5
+
6
+ Renders an animated GitHub profile banner.
7
+
8
+ Options:
9
+ --style <id> Banner style (required). Available: {styles}
10
+ --user <login> GitHub login (required unless --fixture is used)
11
+ --out <file> Output GIF path (default: headreel.gif)
12
+ --tagline <text> One line under your name
13
+ --website <url> Website shown on the banner
14
+ --handle <text> Handle shown on the banner, if the style uses it
15
+ --option <key=value> Style option; repeat for more than one
16
+ --options <text> Style options as "key: value" lines
17
+ --config <file> JSON file with any of the settings above
18
+ --token <token> GitHub token (default: GITHUB_TOKEN, then \`gh auth token\`)
19
+ --fixture <file> Render from a saved data file instead of the GitHub API
20
+ -h, --help Show this help
21
+ -v, --version Show the version`;
22
+ const configSchema = z
23
+ .object({
24
+ style: z.string(),
25
+ user: z.string(),
26
+ out: z.string(),
27
+ tagline: z.string(),
28
+ website: z.string(),
29
+ handle: z.string(),
30
+ fixture: z.string(),
31
+ options: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
32
+ })
33
+ .partial()
34
+ .strict();
35
+ /** Parses "key: value" lines. Blank lines and lines starting with # are ignored. */
36
+ export function parseOptionLines(text) {
37
+ const out = {};
38
+ for (const [i, raw] of text.split(/\r?\n/).entries()) {
39
+ const line = raw.trim();
40
+ if (!line || line.startsWith('#'))
41
+ continue;
42
+ const sep = line.indexOf(':');
43
+ if (sep <= 0)
44
+ throw new Error(`Invalid option on line ${i + 1}: "${line}". Use "key: value".`);
45
+ out[line.slice(0, sep).trim()] = line.slice(sep + 1).trim();
46
+ }
47
+ return out;
48
+ }
49
+ function parseOptionPairs(pairs) {
50
+ const out = {};
51
+ for (const pair of pairs) {
52
+ const sep = pair.indexOf('=');
53
+ if (sep <= 0)
54
+ throw new Error(`Invalid --option "${pair}". Use key=value.`);
55
+ out[pair.slice(0, sep).trim()] = pair.slice(sep + 1).trim();
56
+ }
57
+ return out;
58
+ }
59
+ async function readConfig(path) {
60
+ let raw;
61
+ try {
62
+ raw = JSON.parse(await readFile(path, 'utf8'));
63
+ }
64
+ catch (err) {
65
+ throw new Error(`Cannot read config ${path}: ${err.message}`);
66
+ }
67
+ const parsed = configSchema.safeParse(raw);
68
+ if (!parsed.success)
69
+ throw new Error(`Invalid config ${path}:\n${z.prettifyError(parsed.error)}`);
70
+ return parsed.data;
71
+ }
72
+ /** Treats empty strings as unset, so empty Action inputs fall through to defaults. */
73
+ function value(v) {
74
+ const trimmed = v?.trim();
75
+ return trimmed ? trimmed : undefined;
76
+ }
77
+ /** Resolves flags and the optional config file. Flags override config values. */
78
+ export async function parseCommand(argv) {
79
+ const { values } = parseArgs({
80
+ args: argv,
81
+ options: {
82
+ style: { type: 'string' },
83
+ user: { type: 'string' },
84
+ out: { type: 'string' },
85
+ tagline: { type: 'string' },
86
+ website: { type: 'string' },
87
+ handle: { type: 'string' },
88
+ option: { type: 'string', multiple: true },
89
+ options: { type: 'string' },
90
+ config: { type: 'string' },
91
+ token: { type: 'string' },
92
+ fixture: { type: 'string' },
93
+ help: { type: 'boolean', short: 'h' },
94
+ version: { type: 'boolean', short: 'v' },
95
+ },
96
+ strict: true,
97
+ allowPositionals: false,
98
+ });
99
+ if (values.help)
100
+ return { kind: 'help' };
101
+ if (values.version)
102
+ return { kind: 'version' };
103
+ const configPath = value(values.config);
104
+ const config = configPath ? await readConfig(configPath) : {};
105
+ const configOptions = Object.fromEntries(Object.entries(config.options ?? {}).map(([k, v]) => [k, String(v)]));
106
+ const style = value(values.style) ?? config.style;
107
+ if (!style)
108
+ throw new Error('Missing --style.');
109
+ const settings = {
110
+ style,
111
+ out: value(values.out) ?? config.out ?? 'headreel.gif',
112
+ options: {
113
+ ...configOptions,
114
+ ...parseOptionLines(values.options ?? ''),
115
+ ...parseOptionPairs(values.option ?? []),
116
+ },
117
+ };
118
+ const optional = {
119
+ user: value(values.user) ?? config.user,
120
+ tagline: value(values.tagline) ?? config.tagline,
121
+ website: value(values.website) ?? config.website,
122
+ handle: value(values.handle) ?? config.handle,
123
+ token: value(values.token),
124
+ fixture: value(values.fixture) ?? config.fixture,
125
+ };
126
+ for (const [key, v] of Object.entries(optional)) {
127
+ if (v)
128
+ settings[key] = v;
129
+ }
130
+ if (!settings.user && !settings.fixture)
131
+ throw new Error('Missing --user.');
132
+ return { kind: 'render', settings };
133
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './run.js';
3
+ process.exitCode = await run(process.argv.slice(2), {
4
+ log: (line) => console.log(line),
5
+ warn: (line) => console.error(line),
6
+ now: () => new Date(),
7
+ });
@@ -0,0 +1,83 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, extname } from 'node:path';
3
+ import { createGraphQLClient } from '../core/data/graphql.js';
4
+ import { loadFixture } from '../core/data/fixture.js';
5
+ import { fetchProfile } from '../core/data/profile.js';
6
+ import { resolveToken } from '../core/data/token.js';
7
+ import { parseOptions, renderBanner } from '../core/pipeline.js';
8
+ import { styles } from '../styles/index.js';
9
+ import { parseCommand, USAGE } from './args.js';
10
+ const MiB = 1024 * 1024;
11
+ /** GitHub warns on pushed files over 50 MiB and blocks files over 100 MiB. */
12
+ const WARN_BYTES = 50 * MiB;
13
+ const MAX_BYTES = 100 * MiB;
14
+ async function version() {
15
+ const pkg = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
16
+ return pkg.version;
17
+ }
18
+ async function render(settings, io) {
19
+ const style = styles[settings.style];
20
+ if (!style) {
21
+ throw new Error(`Unknown style "${settings.style}". Available: ${Object.keys(styles).join(', ')}.`);
22
+ }
23
+ if (extname(settings.out).toLowerCase() !== '.gif') {
24
+ throw new Error(`Output must be a .gif file, got "${settings.out}".`);
25
+ }
26
+ // Fail on bad options before any API call.
27
+ parseOptions(style, settings.options);
28
+ let login;
29
+ let name;
30
+ let data;
31
+ if (settings.fixture) {
32
+ const fixture = await loadFixture(settings.fixture, style.data.schema);
33
+ ({ login, name } = fixture.profile);
34
+ data = fixture.data;
35
+ }
36
+ else {
37
+ const client = createGraphQLClient({ token: resolveToken({ flag: settings.token }) });
38
+ const user = settings.user;
39
+ const [profile, fetched] = await Promise.all([
40
+ fetchProfile(client, user),
41
+ style.data.fetch(client, user, io.now()),
42
+ ]);
43
+ ({ login, name } = profile);
44
+ data = fetched;
45
+ }
46
+ const identity = { name };
47
+ if (settings.tagline)
48
+ identity.tagline = settings.tagline;
49
+ if (settings.website)
50
+ identity.website = settings.website;
51
+ if (settings.handle)
52
+ identity.handle = settings.handle;
53
+ const gif = await renderBanner(style, { login, data, identity, options: settings.options });
54
+ if (gif.length > MAX_BYTES) {
55
+ throw new Error(`Banner is ${(gif.length / MiB).toFixed(1)} MiB; GitHub rejects files over 100 MiB.`);
56
+ }
57
+ if (gif.length > WARN_BYTES) {
58
+ io.warn(`Banner is ${(gif.length / MiB).toFixed(1)} MiB; GitHub warns on files over 50 MiB.`);
59
+ }
60
+ await mkdir(dirname(settings.out), { recursive: true });
61
+ await writeFile(settings.out, gif);
62
+ io.log(`Wrote ${settings.out} (${(gif.length / MiB).toFixed(2)} MiB) for @${login}.`);
63
+ }
64
+ /** Runs the CLI and returns the process exit code. */
65
+ export async function run(argv, io) {
66
+ try {
67
+ const command = await parseCommand(argv);
68
+ if (command.kind === 'help') {
69
+ io.log(USAGE.replace('{styles}', Object.keys(styles).join(', ')));
70
+ }
71
+ else if (command.kind === 'version') {
72
+ io.log(await version());
73
+ }
74
+ else {
75
+ await render(command.settings, io);
76
+ }
77
+ return 0;
78
+ }
79
+ catch (err) {
80
+ io.warn(`headreel: ${err.message}`);
81
+ return 1;
82
+ }
83
+ }
@@ -0,0 +1,2 @@
1
+ /** Fixed banner size (SPEC decision 13). */
2
+ export const CANVAS = { width: 1280, height: 400 };
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ const QUERY = /* GraphQL */ `
3
+ query Contributions($login: String!, $from: DateTime!, $to: DateTime!) {
4
+ user(login: $login) {
5
+ contributionsCollection(from: $from, to: $to) {
6
+ contributionCalendar {
7
+ totalContributions
8
+ weeks {
9
+ contributionDays {
10
+ date
11
+ weekday
12
+ contributionCount
13
+ }
14
+ }
15
+ }
16
+ }
17
+ }
18
+ }
19
+ `;
20
+ const daySchema = z.object({
21
+ /** YYYY-MM-DD */
22
+ date: z.iso.date(),
23
+ /** 0 = Sunday ... 6 = Saturday */
24
+ weekday: z.number().int().min(0).max(6),
25
+ count: z.number().int().min(0),
26
+ });
27
+ export const contributionsSchema = z.object({
28
+ /** First and last day of the window, YYYY-MM-DD, inclusive. */
29
+ from: z.iso.date(),
30
+ to: z.iso.date(),
31
+ total: z.number().int().min(0),
32
+ /** Calendar columns, oldest first. First and last weeks may be partial. */
33
+ weeks: z.array(z.array(daySchema)),
34
+ });
35
+ const DAY_MS = 86_400_000;
36
+ /**
37
+ * The 365 days ending on `now` (UTC), matching GitHub's profile calendar.
38
+ * GitHub rejects spans over one year, so the window is 364 days back plus today.
39
+ */
40
+ export function contributionWindow(now) {
41
+ const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
42
+ return {
43
+ from: new Date(today - 364 * DAY_MS).toISOString().slice(0, 10),
44
+ to: new Date(today).toISOString().slice(0, 10),
45
+ };
46
+ }
47
+ export async function fetchContributions(client, login, now) {
48
+ const { from, to } = contributionWindow(now);
49
+ const { user } = await client(QUERY, {
50
+ login,
51
+ from: `${from}T00:00:00Z`,
52
+ to: `${to}T23:59:59Z`,
53
+ });
54
+ const calendar = user.contributionsCollection.contributionCalendar;
55
+ return {
56
+ from,
57
+ to,
58
+ total: calendar.totalContributions,
59
+ weeks: calendar.weeks.map((week) => week.contributionDays
60
+ .map((d) => ({ date: d.date, weekday: d.weekday, count: d.contributionCount }))
61
+ .sort((a, b) => a.date.localeCompare(b.date))),
62
+ };
63
+ }