Compare commits

...
38 Commits
Author SHA1 Message Date
MartinMarco Pasqualettigithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Shaurya SharmaMarco Pasqualettishuaixr
84835caea1 merge master into release for v2.1.5 (#409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shaurya Sharma <65916881+shaurya-sharma-dev@users.noreply.github.com>
Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
Co-authored-by: shuaixr <1025sxr@gmail.com>
2026-07-27 19:38:59 +02:00
Martin dc9c926800 bump core version to 2.1.4 (#398) 2026-07-24 20:24:20 +02:00
Martin 986eaad1a8 release: longer prerender caching 2 (#394)
see https://github.com/stats-organization/github-stats-extended/pull/393
2026-07-23 21:34:31 +02:00
Martin 5403eefbd3 release: switch from stargazers.totalCount to stargazerCount (#392)
release
https://github.com/stats-organization/github-stats-extended/pull/391 as
hotfix
2026-07-23 20:43:32 +02:00
Martin 12b27566be release: longer prerender caching (#361)
see https://github.com/stats-organization/github-stats-extended/pull/360
2026-07-13 10:48:31 +02:00
Martin 40097713b9 release: adjust repeat-recent (#350)
Cherry-pick of
https://github.com/stats-organization/github-stats-extended/pull/341.

no GitHub release planned for this merge
2026-07-08 20:16:06 +02:00
Martin 2168c1f91e release: fix primary color in step 4 (#318)
no GitHub release planned for this merge
2026-06-30 09:10:34 +02:00
Martin fa82216dfe fix primary color in step 4 (#317) 2026-06-30 09:04:48 +02:00
Martin 9cf716c85f bring latest changes to github-stats-extended.vercel.app (#316)
no GitHub release planned for this merge
2026-06-30 08:30:38 +02:00
Marco Pasqualettiandmartin-mfg ef3f207dbf feat(frontend): add light/dark theming with theme picker (#300)
Adds light/dark theming to the frontend using the existing DaisyUI +
Tailwind setup.

- Theme picker dropdown in the header; selection is stored in Redux and
persisted to `localStorage`.
The toggle and each option show a sun/moon icon (shared `ThemeIcon`
component) so light/dark are easy to tell apart.
- Migrated hardcoded colors to DaisyUI tokens so the whole UI (and
toasts) follow the theme.
- `Button` now uses `variant` / `size` / `outline` props instead of raw
`btn-*` classes.
- Theme preview cards: (Select Theme grid and the final Display card)
sit on a two-tone backdrop (light or dark), chosen by the card theme so
previews stay consistent regardless of the app theme.
- Smooth theme-switch transitions: surfaces fade gently while
interactive elements stay snappy; respects `prefers-reduced-motion`.
- Added an e2e test for the picker.


<details><summary>Step 1</summary>
<p>


<img width="1710" height="811" alt="Screenshot 2026-06-24 at 06 54 40"
src="https://github.com/user-attachments/assets/38d629b5-eaa3-4fb1-b976-c56e7a840ced"
/>

<img width="1706" height="808" alt="Screenshot 2026-06-24 at 06 54 32"
src="https://github.com/user-attachments/assets/3811a574-016c-4a93-82a9-a340519f5257"
/>


</p>
</details> 

<details><summary>Step 4</summary>
<p>


<img width="1709" height="899" alt="Screenshot 2026-06-24 at 06 56 22"
src="https://github.com/user-attachments/assets/4053b938-1faa-427b-b621-e79ce7b2c7b1"
/>

<img width="1705" height="894" alt="Screenshot 2026-06-24 at 06 55 41"
src="https://github.com/user-attachments/assets/8aace78a-f1ca-4d29-b57e-938f7b473e67"
/>

</p>
</details>

---------

Co-authored-by: martin-mfg <2026226+martin-mfg@users.noreply.github.com>
2026-06-29 10:04:00 +02:00
Marco Pasqualetti 35fbec8004 feat(core): begin TypeScript migration of common modules (#292)
Starts migrating `packages/core` to TypeScript (#140).

- Converted the `common/` modules to `.ts`: 
- `Card`, `I18n`, `color`, `config`, `constants`, `error`, `fmt`,
`html`, `icons`, `retryer`, `translations`.
- Replaced JSDoc types and `@ts-check`/`@ts-ignore` comments with real
TypeScript types.
- Enabled type-checking on the source and moved `noCheck` into the build
config.
- Converted the tests that target those modules to `.ts`: 
   - `fmt`, `i18n`, `card`, `color`, `html`, `retryer`.
- Converted `tests/_setup.ts` and moved the jest-dom matcher typing
there: augmenting vitest's `Matchers` interface (per the vitest docs) so
matchers like `toHaveAttribute` are typed on `expect(...)`.
- Added a `tests/css-to-object.d.ts` shim so `@uppercod/css-to-object`
resolves under `nodenext` (it ships types but doesn't expose them via
`exports`).
2026-06-22 19:27:14 +02:00
Marco Pasqualetti 3ef27f872c chore(vscode): update prettier extension id (#293)
Update the Prettier extension ID.

Avoids prompting the user to install "Prettier (DO NOT USE)" extension.

<img width="473" height="130" alt="image"
src="https://github.com/user-attachments/assets/ea422ba1-262c-402b-a8f0-cf4cbcbff011"
/>
2026-06-21 10:15:43 +02:00
Martin 9f4ad921aa release v2.1.3, again (#291) 2026-06-19 00:11:49 +02:00
Martin 5557e9c772 upgrade several actions (#290) 2026-06-19 00:04:12 +02:00
Martin 570fc4aaed bump core version to 2.1.3 (#289) 2026-06-18 23:42:01 +02:00
MartinMarco Pasqualettigithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b6f4183ddd release v2.1.3 (#288)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 21:32:46 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2ac454a177 build(deps-dev): Bump turbo from 2.9.17 to 2.9.18 (#286)
Bumps [turbo](https://github.com/vercel/turborepo) from 2.9.17 to
2.9.18.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vercel/turborepo/releases">turbo's
releases</a>.</em></p>
<blockquote>
<h2>Turborepo v2.9.18</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>Changelog</h3>
<ul>
<li>release(turborepo): 2.9.17 by <a
href="https://github.com/github-actions"><code>@​github-actions</code></a>[bot]
in <a
href="https://redirect.github.com/vercel/turborepo/pull/13047">vercel/turborepo#13047</a></li>
<li>ci: Fetch version.txt via API in docs alias failure notification by
<a href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a>
in <a
href="https://redirect.github.com/vercel/turborepo/pull/13050">vercel/turborepo#13050</a></li>
<li>fix: Harden cache archive symlink restore by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13051">vercel/turborepo#13051</a></li>
<li>chore: Remove web UI mode by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13052">vercel/turborepo#13052</a></li>
<li>fix: Harden query server file access by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13053">vercel/turborepo#13053</a></li>
<li>fix: Confine prune patch paths by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13054">vercel/turborepo#13054</a></li>
<li>fix: Prevent git argument injection in SCM refs by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13055">vercel/turborepo#13055</a></li>
<li>fix: Strip special mode bits from cache restore by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13056">vercel/turborepo#13056</a></li>
<li>fix: Contain incremental cache outputs by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13057">vercel/turborepo#13057</a></li>
<li>fix(turborepo): Normalize Windows daemon path hash by <a
href="https://github.com/Balance8"><code>@​Balance8</code></a> in <a
href="https://redirect.github.com/vercel/turborepo/pull/13020">vercel/turborepo#13020</a></li>
<li>fix: Preserve vt100 cell byte counts by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13058">vercel/turborepo#13058</a></li>
<li>fix: Separate artifact signature fields by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13059">vercel/turborepo#13059</a></li>
<li>fix: Validate OidHash hex buffers by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13060">vercel/turborepo#13060</a></li>
<li>fix: Block self-hosted login URLs from attempting to use Vercel's
SSO by <a
href="https://github.com/anthonyshew"><code>@​anthonyshew</code></a> in
<a
href="https://redirect.github.com/vercel/turborepo/pull/13061">vercel/turborepo#13061</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Balance8"><code>@​Balance8</code></a>
made their first contribution in <a
href="https://redirect.github.com/vercel/turborepo/pull/13020">vercel/turborepo#13020</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/vercel/turborepo/compare/v2.9.17...v2.9.18">https://github.com/vercel/turborepo/compare/v2.9.17...v2.9.18</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vercel/turborepo/commit/3bdce3277d2e61cdbf29f244a515dd4b896d2556"><code>3bdce32</code></a>
publish 2.9.18 to registry</li>
<li><a
href="https://github.com/vercel/turborepo/commit/2a76556b9457c514fa597a09fef23da2e1250c8c"><code>2a76556</code></a>
fix: Block self-hosted login URLs from attempting to use Vercel's SSO
(<a
href="https://redirect.github.com/vercel/turborepo/issues/13061">#13061</a>)</li>
<li><a
href="https://github.com/vercel/turborepo/commit/da8e3487acce10039425180c42875d7ccc484ed5"><code>da8e348</code></a>
fix: Validate OidHash hex buffers (<a
href="https://redirect.github.com/vercel/turborepo/issues/13060">#13060</a>)</li>
<li><a
href="https://github.com/vercel/turborepo/commit/3018717c28fc8f1ecffa2c92e3260be0dc0165aa"><code>3018717</code></a>
fix: Separate artifact signature fields (<a
href="https://redirect.github.com/vercel/turborepo/issues/13059">#13059</a>)</li>
<li><a
href="https://github.com/vercel/turborepo/commit/34514e278822ccbe3a083730b7032709ef16f85c"><code>34514e2</code></a>
fix: Preserve vt100 cell byte counts (<a
href="https://redirect.github.com/vercel/turborepo/issues/13058">#13058</a>)</li>
<li><a
href="https://github.com/vercel/turborepo/commit/24e2d3498f54fa1f8b729f18764f63cb05072bc6"><code>24e2d34</code></a>
fix(turborepo): Normalize Windows daemon path hash (<a
href="https://redirect.github.com/vercel/turborepo/issues/13020">#13020</a>)</li>
<li><a
href="https://github.com/vercel/turborepo/commit/16dc881cabf5c13d02c27a5d7cfdfeb60736206c"><code>16dc881</code></a>
fix: Contain incremental cache outputs (<a
href="https://redirect.github.com/vercel/turborepo/issues/13057">#13057</a>)</li>
<li><a
href="https://github.com/vercel/turborepo/commit/92e1f8e5365ea5a95e47c3698deb2f7adaa55eba"><code>92e1f8e</code></a>
fix: Strip special mode bits from cache restore (<a
href="https://redirect.github.com/vercel/turborepo/issues/13056">#13056</a>)</li>
<li><a
href="https://github.com/vercel/turborepo/commit/f46f896ef414823582131304c9749e9be011fe37"><code>f46f896</code></a>
fix: Prevent git argument injection in SCM refs (<a
href="https://redirect.github.com/vercel/turborepo/issues/13055">#13055</a>)</li>
<li><a
href="https://github.com/vercel/turborepo/commit/7f353ca3a6f28fbbcce931aeab7be0efe718c466"><code>7f353ca</code></a>
fix: Confine prune patch paths (<a
href="https://redirect.github.com/vercel/turborepo/issues/13054">#13054</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vercel/turborepo/compare/v2.9.17...v2.9.18">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=turbo&package-manager=npm_and_yarn&previous-version=2.9.17&new-version=2.9.18)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:57:59 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b837c50885 build(deps-dev): Bump @types/node from 24.13.1 to 24.13.2 in the typescript group (#285)
Bumps the typescript group with 1 update:
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).

Updates `@types/node` from 24.13.1 to 24.13.2
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@types/node&package-manager=npm_and_yarn&previous-version=24.13.1&new-version=24.13.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:57:37 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dfb07a69b9 ci(deps): Bump actions/checkout from 6.0.2 to 6.0.3 (#270)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2
to 6.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/releases">actions/checkout's
releases</a>.</em></p>
<blockquote>
<h2>v6.0.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Update changelog by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2357">actions/checkout#2357</a></li>
<li>fix: expand merge commit SHA regex and add SHA-256 test cases by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
<li>Fix checkout init for SHA-256 repositories by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2439">actions/checkout#2439</a></li>
<li>Update changelog for v6.0.3 by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2446">actions/checkout#2446</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/yaananth"><code>@​yaananth</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v6...v6.0.3">https://github.com/actions/checkout/compare/v6...v6.0.3</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/blob/main/CHANGELOG.md">actions/checkout's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>v6.0.3</h2>
<ul>
<li>Fix checkout init for SHA-256 repositories by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2439">actions/checkout#2439</a></li>
<li>fix: expand merge commit SHA regex and add SHA-256 test cases by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
</ul>
<h2>v6.0.2</h2>
<ul>
<li>Fix tag handling: preserve annotations and explicit fetch-tags by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2356">actions/checkout#2356</a></li>
</ul>
<h2>v6.0.1</h2>
<ul>
<li>Add worktree support for persist-credentials includeIf by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2327">actions/checkout#2327</a></li>
</ul>
<h2>v6.0.0</h2>
<ul>
<li>Persist creds to a separate file by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2286">actions/checkout#2286</a></li>
<li>Update README to include Node.js 24 support details and requirements
by <a href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2248">actions/checkout#2248</a></li>
</ul>
<h2>v5.0.1</h2>
<ul>
<li>Port v6 cleanup to v5 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2301">actions/checkout#2301</a></li>
</ul>
<h2>v5.0.0</h2>
<ul>
<li>Update actions checkout to use node 24 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2226">actions/checkout#2226</a></li>
</ul>
<h2>v4.3.1</h2>
<ul>
<li>Port v6 cleanup to v4 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2305">actions/checkout#2305</a></li>
</ul>
<h2>v4.3.0</h2>
<ul>
<li>docs: update README.md by <a
href="https://github.com/motss"><code>@​motss</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1971">actions/checkout#1971</a></li>
<li>Add internal repos for checking out multiple repositories by <a
href="https://github.com/mouismail"><code>@​mouismail</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1977">actions/checkout#1977</a></li>
<li>Documentation update - add recommended permissions to Readme by <a
href="https://github.com/benwells"><code>@​benwells</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2043">actions/checkout#2043</a></li>
<li>Adjust positioning of user email note and permissions heading by <a
href="https://github.com/joshmgross"><code>@​joshmgross</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2044">actions/checkout#2044</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2194">actions/checkout#2194</a></li>
<li>Update CODEOWNERS for actions by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2224">actions/checkout#2224</a></li>
<li>Update package dependencies by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2236">actions/checkout#2236</a></li>
</ul>
<h2>v4.2.2</h2>
<ul>
<li><code>url-helper.ts</code> now leverages well-known environment
variables by <a href="https://github.com/jww3"><code>@​jww3</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/1941">actions/checkout#1941</a></li>
<li>Expand unit test coverage for <code>isGhes</code> by <a
href="https://github.com/jww3"><code>@​jww3</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1946">actions/checkout#1946</a></li>
</ul>
<h2>v4.2.1</h2>
<ul>
<li>Check out other refs/* by commit if provided, fall back to ref by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1924">actions/checkout#1924</a></li>
</ul>
<h2>v4.2.0</h2>
<ul>
<li>Add Ref and Commit outputs by <a
href="https://github.com/lucacome"><code>@​lucacome</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1180">actions/checkout#1180</a></li>
<li>Dependency updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>- <a
href="https://redirect.github.com/actions/checkout/pull/1777">actions/checkout#1777</a>,
<a
href="https://redirect.github.com/actions/checkout/pull/1872">actions/checkout#1872</a></li>
</ul>
<h2>v4.1.7</h2>
<ul>
<li>Bump the minor-npm-dependencies group across 1 directory with 4
updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1739">actions/checkout#1739</a></li>
<li>Bump actions/checkout from 3 to 4 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1697">actions/checkout#1697</a></li>
<li>Check out other refs/* by commit by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1774">actions/checkout#1774</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/checkout/commit/df4cb1c069e1874edd31b4311f1884172cec0e10"><code>df4cb1c</code></a>
Update changelog for v6.0.3 (<a
href="https://redirect.github.com/actions/checkout/issues/2446">#2446</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/1cce3390c2bfda521930d01229c073c7ff920824"><code>1cce339</code></a>
Fix checkout init for SHA-256 repositories (<a
href="https://redirect.github.com/actions/checkout/issues/2439">#2439</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/900f2210b1d28bbbd0bd22d17926b9e224e8f231"><code>900f221</code></a>
fix: expand merge commit SHA regex and add SHA-256 test cases (<a
href="https://redirect.github.com/actions/checkout/issues/2414">#2414</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/0c366fd6a839edf440554fa01a7085ccba70ac98"><code>0c366fd</code></a>
Update changelog (<a
href="https://redirect.github.com/actions/checkout/issues/2357">#2357</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=6.0.2&new-version=6.0.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 22:41:07 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 41b51dcfd9 build(deps-dev): Bump @types/node from 24.13.0 to 24.13.1 in the typescript group across 1 directory (#276)
Bumps the typescript group with 1 update in the / directory:
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).

Updates `@types/node` from 24.13.0 to 24.13.1
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 22:39:53 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7687c47d85 build(deps-dev): Bump the eslint group across 1 directory with 2 updates (#284)
Bumps the eslint group with 2 updates in the / directory:
[eslint-plugin-jsdoc](https://github.com/gajus/eslint-plugin-jsdoc) and
[typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).

Updates `eslint-plugin-jsdoc` from 63.0.1 to 63.0.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/gajus/eslint-plugin-jsdoc/releases">eslint-plugin-jsdoc's
releases</a>.</em></p>
<blockquote>
<h2>v63.0.2</h2>
<h2><a
href="https://github.com/gajus/eslint-plugin-jsdoc/compare/v63.0.1...v63.0.2">63.0.2</a>
(2026-06-06)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>allow typedef returns that may be void; fixes <a
href="https://redirect.github.com/gajus/eslint-plugin-jsdoc/issues/1390">#1390</a>
(<a
href="https://redirect.github.com/gajus/eslint-plugin-jsdoc/issues/1699">#1699</a>)
(<a
href="https://github.com/gajus/eslint-plugin-jsdoc/commit/319e84b21dfc21213f42998beee26e199660747c">319e84b</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/gajus/eslint-plugin-jsdoc/commit/50a7fbcf4abf7b27ca2774f65397f2f74395ea41"><code>50a7fbc</code></a>
chore: update semver and devDeps.</li>
<li><a
href="https://github.com/gajus/eslint-plugin-jsdoc/commit/6041995ba873b5641e3b9bd1f95de004b4b9e7da"><code>6041995</code></a>
docs: fix in output</li>
<li><a
href="https://github.com/gajus/eslint-plugin-jsdoc/commit/319e84b21dfc21213f42998beee26e199660747c"><code>319e84b</code></a>
fix: allow typedef returns that may be void; fixes <a
href="https://redirect.github.com/gajus/eslint-plugin-jsdoc/issues/1390">#1390</a>
(<a
href="https://redirect.github.com/gajus/eslint-plugin-jsdoc/issues/1699">#1699</a>)</li>
<li>See full diff in <a
href="https://github.com/gajus/eslint-plugin-jsdoc/compare/v63.0.1...v63.0.2">compare
view</a></li>
</ul>
</details>
<br />

Updates `typescript-eslint` from 8.60.1 to 8.61.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases">typescript-eslint's
releases</a>.</em></p>
<blockquote>
<h2>v8.61.0</h2>
<h2>8.61.0 (2026-06-08)</h2>
<h3>🚀 Features</h3>
<ul>
<li><strong>ast-spec:</strong> change type of
<code>UnaryExpression.prefix</code> to always <code>true</code> (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12372">#12372</a>)</li>
<li><strong>ast-spec:</strong> tighten types of
<code>ArrowFunction</code>, <code>YieldExpression</code>,
<code>TSTypePredicate</code> (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12373">#12373</a>)</li>
</ul>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>rule-schema-to-typescript-types:</strong> respect ECMAScript
line terminators (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12374">#12374</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Kirk Waiblinger <a
href="https://github.com/kirkwaiblinger"><code>@​kirkwaiblinger</code></a></li>
<li>lumir</li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.61.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md">typescript-eslint's
changelog</a>.</em></p>
<blockquote>
<h2>8.61.0 (2026-06-08)</h2>
<p>This was a version bump only for typescript-eslint to align it with
other projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.61.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/typescript-eslint/typescript-eslint/commit/16a5b247affc32af21b695cf96dfd75d7ded50a3"><code>16a5b24</code></a>
chore(release): publish 8.61.0</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.0/packages/typescript-eslint">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 21:01:53 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7917d54fdf build(deps-dev): Bump @types/react from 19.2.16 to 19.2.17 in the react group across 1 directory (#283)
Bumps the react group with 1 update in the / directory:
[@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react).

Updates `@types/react` from 19.2.16 to 19.2.17
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@types/react&package-manager=npm_and_yarn&previous-version=19.2.16&new-version=19.2.17)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 21:01:03 +02:00
Martin 7771a34790 several dependency upgrades (#282) 2026-06-17 20:52:00 +02:00
Martin 1c266ad51e dependency upgrades (#265) 2026-06-07 18:24:46 +02:00
Martin 6258d90604 fix non-mocked frontend requests (#263) 2026-06-07 10:04:19 +02:00
Martin 6d857d0f34 upgrade dependencies (#262) 2026-06-06 12:23:41 +02:00
github-actions[bot]andmartin-mfg e15deeac76 Update languages JSON (#254)
The
[update-langs](https://github.com/stats-organization/github-stats-extended/actions/workflows/update-langs.yml)
action found new/updated languages in the [upstream languages JSON
file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml).

Co-authored-by: martin-mfg <2026226+martin-mfg@users.noreply.github.com>
2026-06-06 09:24:43 +02:00
Martin b5d50fc28f debug playwright (#261) 2026-06-06 09:23:54 +02:00
Marco Pasqualetti 1d394c7fad chore(actions): don’t repeat-recent-requests on forks (#259)
I received a notification about a failed workflow run on my fork of this
repository:

*
https://github.com/marcalexiei/github-stats-extended/actions/runs/26996444955

It looks like the `repeat-recent-requests` workflow is being triggered
on forks, even though it should only run on the canonical repositories.
This PR adds an `if` condition to prevent it from running on forks.

The condition matches the one already used in the `update-languages`
workflow:


https://github.com/stats-organization/github-stats-extended/blob/96d141e8c717f42a1a29e67f64984a19182dd575/.github/workflows/update-langs.yml#L31-L36

---

Additionally, this PR renames `update_tags.yml` to `update-tags.yml` for
consistency with the repository's workflow naming convention.
2026-06-05 19:32:58 +02:00
Martin 76e09ce3b0 improve 'Responsive Card Theme' docs (#260)
- reorder subsections
- shorten `<picture>` code
2026-06-05 18:17:02 +02:00
Martin 96d141e8c7 update documentation, step 2 (#243) 2026-05-21 23:28:09 +02:00
martin-mfg 6310073428 update documentation, step 2 2026-05-21 21:45:14 +02:00
Martin 1d8aaf0fc8 update documentation (#242) 2026-05-21 21:21:31 +02:00
martin-mfg 4a2785e286 update documentation 2026-05-21 21:19:35 +02:00
Martin 73189400ee set correct version for 'core' package (#219) 2026-05-08 12:58:37 +02:00
Martin 16ea9afebe set correct version for 'core' package (#218) 2026-05-08 12:54:54 +02:00
Martin 8a239313d3 ignore git checks when publishing (#217) 2026-05-08 12:20:04 +02:00
Martin c707e6b7b1 ignore git checks when publishing (#216) 2026-05-08 12:11:03 +02:00
149 changed files with 6499 additions and 5413 deletions
@@ -14,11 +14,11 @@ runs:
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js (via input)
if: ${{ inputs.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ inputs.node-version }}
cache: "pnpm"
@@ -26,7 +26,7 @@ runs:
- name: Setup Node.js (via .nvmrc)
if: ${{ !inputs.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: "pnpm"
+7 -5
View File
@@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
@@ -59,7 +59,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
@@ -67,8 +67,10 @@ jobs:
- name: Build packages
run: pnpm run build:packages
# Only install chromium since it is the only browser enabled in
# apps/frontend/playwright.config.ts. Update this if more projects are added.
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
run: pnpm exec playwright install --with-deps chromium
- name: Run Playwright tests
run: pnpm --filter ./apps/frontend/ run test:e2e
@@ -85,7 +87,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
@@ -106,7 +108,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
@@ -27,4 +27,4 @@ jobs:
pnpm --filter @stats-organization/github-readme-stats-core pack --dry-run
- name: Publish core package to npm
run: pnpm --filter @stats-organization/github-readme-stats-core publish --provenance --access public
run: pnpm --filter @stats-organization/github-readme-stats-core publish --provenance --access public --no-git-checks
+8 -2
View File
@@ -10,17 +10,23 @@ on:
# │ │ │ │ │
# │ │ │ │ │
# * * * * *
- cron: "45 * * * *"
- cron: "15,45 * * * *"
workflow_dispatch:
permissions: {}
jobs:
triggerRepeatRecent:
if: |
github.repository == 'anuraghazra/github-readme-stats' ||
github.repository == 'stats-organization/github-readme-stats' ||
github.repository == 'stats-organization/github-stats-extended'
name: Trigger server to update cached data.
runs-on: ubuntu-latest
steps:
- name: Make Request
id: myRequest
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
uses: fjogeleit/http-request-action@f98bb26551cd1af7d3eb2674578a80754ece88db # v2
with:
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
method: "POST"
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
@@ -14,5 +14,5 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"yzhang.markdown-all-in-one",
"prettier.prettier-vscode",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"github.vscode-github-actions"
]
+1 -2
View File
@@ -3,8 +3,7 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript]": {
"editor.tabSize": 2,
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"cSpell.words": ["Wakatime"]
"cSpell.words": ["retryer", "Wakatime"]
}
+19 -8
View File
@@ -5,13 +5,14 @@
<a href="https://github-stats-extended.vercel.app/api?username=anuraghazra"><img src="https://github-stats-extended.vercel.app/api?username=anuraghazra"></a>
</div>
This project is an [extended version](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
This project is the [extended, actively maintained successor](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
# Table of Contents
- [Quick Start](#quick-start)
- [Migration from github-readme-stats](#migration-from-github-readme-stats)
- [Card Types](#card-types)
- [Advanced Customization](#advanced-customization)
- [Self-Hosting](#self-hosting)
- [Run It Yourself](#run-it-yourself)
- [Acknowledgements](#acknowledgements)
- [Contributing](#contributing)
@@ -27,6 +28,16 @@ This project is an [extended version](docs/fork.md) of [github-readme-stats](htt
As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) to create your custom stats card. Copy the generated markdown code and paste it into your [GitHub profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme). Done!
# Migration from github-readme-stats
To migrate from [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) you only need to change the domain from `github-readme-stats.vercel.app` to `github-stats-extended.vercel.app`:
```diff
- https://github-readme-stats.vercel.app/api?username=octocat&theme=radical
+ https://github-stats-extended.vercel.app/api?username=octocat&theme=radical
```
GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For more details see [Compatibility Notes](docs/fork.md#compatibility-notes).
# Card Types
- Show your GitHub statistics:
@@ -55,13 +66,13 @@ As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://
# Advanced Customization
The [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) offers some essential customization options. For more advanced customization check out the [advanced documentation](docs/advanced_documentation.md).
# Run It Yourself
If you want to run GitHub-Stats-Extended on your own, there are two main deployment options: you can use [github-readme-stats-action](https://github.com/stats-organization/github-readme-stats-action) to generate cards in your own GitHub Actions workflow. Or you can self-host GitHub-Stats-Extended on Vercel.
See [Run It Yourself](docs/deploy.md) for detailed instructions.
# Acknowledgements
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of their functionality I added several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend I added to the project is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
# Self-Hosting
Since the GitHub API only allows a limited number of requests per hour, the public instance of GitHub-Stats-Extended at https://github-stats-extended.vercel.app/api could possibly hit the rate limiter. If you host your own instance you do not have to worry about anything. Also, if you don't want to give my GitHub-Stats-Extended instance access to your private contributions but still want to include these contributions in your stats, you can simply host your own instance.
See [Deploy on your own](docs/deploy.md) for various deployment options.
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of that project's functionality GitHub-Stats-Extended adds several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend added to GitHub-Stats-Extended is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
# Contributing
Contributions are welcome!
@@ -1,5 +1,5 @@
{
"expiration": 39600,
"expiration": 255600,
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
"passQuery": true
}
@@ -1,5 +1,5 @@
{
"expiration": 39600,
"expiration": 255600,
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
"passQuery": true
}
@@ -1,5 +1,5 @@
{
"expiration": 39600,
"expiration": 255600,
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
"passQuery": true
}
@@ -1,5 +1,5 @@
{
"expiration": 39600,
"expiration": 255600,
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
"passQuery": true
}
@@ -1,5 +1,5 @@
{
"expiration": 39600,
"expiration": 255600,
"bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n",
"passQuery": true
}
+3 -3
View File
@@ -18,12 +18,12 @@
"devDependencies": {
"axios-mock-adapter": "2.1.0",
"express": "5.2.1",
"jsdom": "29.0.2",
"jsdom": "catalog:default",
"vitest": "catalog:default"
},
"dependencies": {
"axios": "^1.15.2",
"@stats-organization/github-readme-stats-core": "workspace:^",
"pg": "^8.20.0"
"axios": "catalog:default",
"pg": "^8.22.0"
}
}
+1 -1
View File
@@ -66,6 +66,6 @@ export async function repeatRecentRequests() {
if (urls.length === 0) {
console.log("No recent requests found.");
} else {
await makeRequests(urls, 5);
await makeRequests(urls, 3);
}
}
+2 -4
View File
@@ -29,7 +29,7 @@ const STATS_MOCK_RESPONSE = {
followers: { totalCount: 0 },
repositories: {
totalCount: 1,
nodes: [{ name: REPO, stargazers: { totalCount: 1 } }],
nodes: [{ name: REPO, stargazerCount: 1 }],
pageInfo: {
hasNextPage: false,
endCursor: "cursor",
@@ -110,9 +110,7 @@ const REPO_MOCK_RESPONSE = {
isPrivate: false,
isArchived: false,
isTemplate: false,
stargazers: {
totalCount: 1,
},
stargazerCount: 1,
description: "Simple cra test repo.",
primaryLanguage: {
color: "#f1e05a",
@@ -443,7 +443,7 @@ exports[`Test /api contract > should match the public many-params response snaps
</g>
</svg>",
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection (from: $startTime) {\\n totalCommitContributions,\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n \\n repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {\\n totalCount\\n nodes {\\n name\\n stargazers {\\n totalCount\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n\\n }\\n }\\n","variables":{"login":"anuraghazra","first":100,"after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection (from: $startTime) {\\n totalCommitContributions,\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n \\n repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {\\n totalCount\\n nodes {\\n name\\n stargazerCount\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n\\n }\\n }\\n","variables":{"login":"anuraghazra","first":100,"after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
"headers": [
[
"Cache-Control",
@@ -341,7 +341,7 @@ exports[`Test /api/pin contract > should match the public many-params response s
</g>
</svg>",
"graphqlRequest": "{"query":"\\n fragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazers {\\n totalCount\\n }\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n }\\n query getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","repo":"convoychat"}}",
"graphqlRequest": "{"query":"\\n fragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazerCount\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n }\\n query getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","repo":"convoychat"}}",
"headers": [
[
"Cache-Control",
+4 -6
View File
@@ -26,9 +26,9 @@ export const data_stats = {
repositories: {
totalCount: 3,
nodes: [
{ name: "repo-keep-1", stargazers: { totalCount: 1500 } },
{ name: "repo-exclude-me", stargazers: { totalCount: 9999 } },
{ name: "repo-keep-2", stargazers: { totalCount: 2600 } },
{ name: "repo-keep-1", stargazerCount: 1500 },
{ name: "repo-exclude-me", stargazerCount: 9999 },
{ name: "repo-keep-2", stargazerCount: 2600 },
],
pageInfo: {
hasNextPage: false,
@@ -73,9 +73,7 @@ export const data_user = {
username: "anuraghazra",
name: "convoychat",
nameWithOwner: "anuraghazra/convoychat",
stargazers: {
totalCount: 38000,
},
stargazerCount: 38000,
description:
"Help us take over the world with a deeply customizable React, TypeScript and GraphQL chat app that has enough text to wrap across multiple lines in the repository card.",
primaryLanguage: {
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
/**
* Puts the SPA into an authenticated state without contacting GitHub by
* stubbing the OAuth code exchange and the follow-up user-access lookup:
* `authenticate` returns a userId (which flips `isAuthenticated` to true), and
* `user-access` returns metadata so AppTrends does not immediately log back out.
* @param page - The Playwright page to install the route handlers on.
*/
async function mockAuthEndpoints(page: Page): Promise<void> {
await page.route("**/api/authenticate**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ userId: "octocat", needDowngrade: false }),
}),
);
await page.route("**/api/user-access**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ token: "test-token", privateAccess: "false" }),
}),
);
}
test.describe("AppTrends auth-driven stage transition", () => {
test("auto-advances from Login to Select a Card once authenticated", async ({
page,
}) => {
await mockAuthEndpoints(page);
// Land on the app as GitHub does after the OAuth redirect (URL carries `code`).
await page.goto("?code=test-oauth-code");
// AppTrends starts unauthenticated on stage 0 ("Login"). When the code
// exchange flips `isAuthenticated` false -> true, the render-phase
// transition must advance the stepper to stage 1 ("Select a Card").
await expect(page.getByRole("heading", { level: 1 })).toContainText(
"Select a Card",
);
});
test("keeps a manually selected earlier step instead of re-forcing it", async ({
page,
}) => {
await mockAuthEndpoints(page);
await page.goto("?code=test-oauth-code");
const heading = page.getByRole("heading", { level: 1 });
await expect(heading).toContainText("Select a Card");
// Going back to "Login" while still authenticated must stick: the transition
// fires only on an auth *change*, so a later render must not snap the user
// forward again. A bug that dropped the previous-value guard would bounce
// the heading straight back to "Select a Card" here.
await page.getByRole("button", { name: "Login" }).click();
await expect(heading).toContainText("Login");
});
});
+41 -2
View File
@@ -12,8 +12,8 @@ test("load initial page correctly", async ({ page }) => {
// Logo exists
await expect(page.locator('img[alt="logo"]')).toBeVisible();
// Star on GitHub button
const starButton = page.getByRole("button", { name: /star on/i });
// Star on GitHub link
const starButton = page.getByRole("link", { name: /star on/i });
await expect(starButton).toBeVisible();
const githubLink = page.locator(
@@ -39,6 +39,45 @@ test("load initial page correctly", async ({ page }) => {
await expect(guestBtn).toBeEnabled();
});
test("theme picker switches and persists the theme", async ({ page }) => {
await page.goto("");
const html = page.locator("html");
const trigger = page.getByRole("button", { name: /choose theme/i });
const options = page.getByRole("menuitemradio");
// The menu is closed initially.
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute("aria-expanded", "false");
await expect(options).toHaveCount(0);
// Opening it reveals the options (portaled to body, above the stepper).
await trigger.click();
await expect(trigger).toHaveAttribute("aria-expanded", "true");
// Read the themes off the options instead of hardcoding their names.
const firstTheme = await options.first().getAttribute("data-theme-value");
const lastTheme = await options.last().getAttribute("data-theme-value");
// Selecting an option applies the theme, persists it, and closes the menu.
await options.last().click();
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
await expect(options).toHaveCount(0);
await expect(
page.evaluate(() => localStorage.getItem("theme")),
).resolves.toBe(lastTheme);
// The choice survives a reload and is marked as selected on reopen.
await page.reload();
await expect(html).toHaveAttribute("data-theme", lastTheme ?? "");
await trigger.click();
await expect(options.last()).toHaveAttribute("aria-checked", "true");
// Switching to another option works too.
await options.first().click();
await expect(html).toHaveAttribute("data-theme", firstTheme ?? "");
});
test("navigates between steps", async ({ page }) => {
await page.goto("");
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";
test("selecting 'None' progress style hides the rank circle", async ({
page,
}) => {
await page.goto("");
// Stage 0 -> 2: go to customization
await page.getByRole("button", { name: "Modify Parameters" }).click();
await expect(page.getByRole("heading", { level: 1 })).toContainText(
"Modify Card Parameters",
);
const preview = page.locator("#svgWrapper");
const rankCircle = preview.locator('[data-testid="rank-circle"]');
// Default "Rank" progress style renders the rank circle.
await expect(preview).toBeAttached();
await expect(rankCircle).toHaveCount(1);
await page.getByRole("combobox").selectOption({ label: "None" });
await expect(rankCircle).toHaveCount(0);
});
+18
View File
@@ -37,6 +37,24 @@
<title>GitHub Stats Extended</title>
</head>
<body>
<script>
// Apply the persisted theme from localStorage before first paint. This is
// what makes a saved theme survive a reload when it differs from the OS
// preference (DaisyUI's `--prefersdark` only covers the OS default, and
// nothing in React sets `data-theme` on mount).
// Also suppress color transitions until after the first paint so elements
// don't animate from their unstyled state on load.
(function () {
try {
var stored = localStorage.getItem("theme");
const matchesDarkTheme = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
var theme = stored || (matchesDarkTheme ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
} catch (e) {}
})();
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
+14 -14
View File
@@ -13,30 +13,30 @@
"typecheck": "tsc -p tsconfig.typecheck.json"
},
"dependencies": {
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.2.2",
"axios": "^1",
"axios-cache-interceptor": "^1",
"daisyui": "^5.5.19",
"@reduxjs/toolkit": "^2.12.0",
"@stats-organization/github-readme-stats-backend": "workspace:^",
"@stats-organization/github-readme-stats-core": "workspace:^",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-icons": "^5.6.0",
"react-loading-skeleton": "^3.3.1",
"react-redux": "^9.2.0",
"@tailwindcss/vite": "^4.3.3",
"axios": "catalog:default",
"axios-cache-interceptor": "^1.12.0",
"daisyui": "^5.6.18",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.7.0",
"react-loading-skeleton": "^3.5.0",
"react-redux": "^9.3.0",
"react-spinners": "^0.17.0",
"react-toastify": "^11.1.0",
"redux": "^5.0.1",
"save-svg-as-png": "^1.4.17",
"uuid": "^14.0.0"
"uuid": "^14.0.1"
},
"devDependencies": {
"@types/react": "19.2.14",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"@vitejs/plugin-react": "6.0.3",
"clsx": "2.1.1",
"tailwindcss": "4.2.2",
"tailwindcss": "4.3.3",
"vite": "catalog:default",
"vitest": "catalog:default"
},
+45 -8
View File
@@ -1,39 +1,76 @@
import { clsx } from "clsx";
import type { JSX } from "react";
import type { CSSProperties, JSX } from "react";
import type { CardUrlBuilder } from "../../models/CardUrl";
import { CardImage } from "./CardImage";
import { LIGHT_CARD_BG } from "./themeBackdrop";
interface CardProps {
title: string;
description: string;
imageSrc: string;
card: CardUrlBuilder;
stage: number;
selected?: boolean;
compact?: boolean;
fixedSize?: boolean;
/** CSS background for the card surface (e.g. a theme's own background). */
backgroundColor?: string;
/** Title color, used when the card sits on a custom background. */
titleColor?: string;
}
export const Card = ({
title,
description,
imageSrc,
card,
stage,
selected = false,
compact = false,
fixedSize = false,
backgroundColor,
titleColor,
}: CardProps): JSX.Element => {
const customBackground = backgroundColor
? selected
? `color-mix(in oklab, var(--color-primary) ${backgroundColor === LIGHT_CARD_BG ? "10%" : "25%"}, ${backgroundColor})`
: backgroundColor
: undefined;
const cardStyle = customBackground
? ({ "--card-bg": customBackground } as CSSProperties)
: undefined;
return (
<div
className={clsx("p-6 rounded border-2", {
className={clsx("p-6 rounded border-4", {
"h-[370px] w-[510px]": fixedSize,
"border-blue-500 bg-blue-50": selected,
"border-gray-200 bg-white hover:bg-gray-50": !selected,
"border-primary": selected,
"border-base-300": !selected,
"bg-[var(--card-bg)]": !!customBackground,
// Token backgrounds only apply when no custom background is given.
"bg-primary/10": selected && !customBackground,
"bg-base-100 hover:bg-base-200": !selected && !customBackground,
"hover:bg-base-300": !selected && !!customBackground,
})}
style={cardStyle}
data-theme={
stage == 3
? backgroundColor === LIGHT_CARD_BG || !backgroundColor
? "light"
: "dark"
: undefined
}
>
<h2 className="text-xl font-medium title-font text-gray-900">{title}</h2>
<h2
className="text-xl font-medium title-font text-base-content"
style={titleColor ? { color: titleColor } : undefined}
>
{title}
</h2>
<p className="text-base leading-relaxed mt-2 mb-4">{description}</p>
<CardImage
imageSrc={imageSrc}
card={card}
compact={compact}
className={clsx({ "flex justify-center": fixedSize })}
stage={stage}
@@ -1,23 +1,25 @@
import { clsx } from "clsx";
import { HOST } from "../../constants";
import type { CardUrlBuilder } from "../../models/CardUrl";
import { SvgInline } from "./SvgInline";
interface CardImageProps {
imageSrc: string;
card: CardUrlBuilder;
stage: number;
compact?: boolean;
className?: string;
}
export const CardImage = ({
imageSrc,
card,
stage,
compact = false,
className,
}: CardImageProps) => {
const fullImageSrc = `https://${HOST}/api${imageSrc}&client=wizard`;
// `client=wizard` marks requests coming from the wizard preview.
const fullImageSrc = card.client("wizard").toApiUrl(HOST);
return (
<div className={clsx("w-full relative", className)}>
@@ -50,7 +50,7 @@ export function SvgInline(props: SvgInlineProps): JSX.Element {
FETCH_MULTI_PAGE_STARS: "10",
PAT_1: userToken as string, // even if it's null, core's retryer.js sees there is 1 PAT and sets `RETRIES` accordingly
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
loadConfigFromEnv(config);
setLoaded(false);
@@ -0,0 +1,90 @@
import { themes } from "@stats-organization/github-readme-stats-core";
// We use the same background colors here which GitHub uses.
export const LIGHT_CARD_BG = "#ffffff";
const DARK_CARD_BG = "#0d1117";
// Themes that look good in either mode (their text/icon colors read on both a
// light and a dark backdrop), so their backdrop follows the app theme. Note we
// only list themes that are genuinely mode-agnostic here: other transparent
// themes (e.g. shadow_*) have dark text and must stay on a light backdrop.
const ADAPTIVE_THEMES = ["transparent", "ambient_gradient"];
/** Expand a shorthand `#abc` hex to `#aabbcc`. */
function expandHex(hex: string): string {
if (hex.length === 3) {
return hex
.split("")
.map((char) => char + char)
.join("");
}
return hex;
}
/** Whether a hex color (3-, 6- or 8-digit) is dark by perceived luminance. */
function isDarkHex(hex: string): boolean {
const normalized = expandHex(hex);
if (normalized.length < 6) {
return false;
}
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance < 0.5;
}
/** Whether a theme's `bg_color` (hex or `angle,c1,c2,…` gradient) is dark. */
function isDarkBg(bgColor: string): boolean {
const parts = bgColor.split(",");
// For gradients use the first color stop (index 0 is the angle).
return isDarkHex((parts.length > 1 ? parts[1] : parts[0]) ?? "");
}
/** A theme whose backdrop should follow the app mode rather than its own bg. */
function isAdaptiveTheme(themeName: string): boolean {
return ADAPTIVE_THEMES.includes(themeName);
}
/**
* Whether the backdrop a card actually gets is dark, for the given app mode.
* Adaptive themes follow the app mode; the rest follow their own background.
*/
function isCardBackdropDark(themeName: string, appIsDark: boolean): boolean {
const theme = themes[themeName as keyof typeof themes];
if (isAdaptiveTheme(themeName)) {
return appIsDark;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!theme) {
return appIsDark;
}
return isDarkBg(theme.bg_color);
}
/**
* Sort rank for the theme grid: light themes first (0), adaptive themes in the
* middle (1), dark themes last (2). Adaptive cards sit between the groups so
* they blend with whichever side matches their backdrop in the current mode.
*/
export function getThemeSortRank(themeName: string): number {
if (isAdaptiveTheme(themeName)) {
return 1;
}
const theme = themes[themeName as keyof typeof themes];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!theme) {
return 0;
}
return isDarkBg(theme.bg_color) ? 2 : 0;
}
/** The backdrop color a card rendered with the given theme should sit on. */
export function getCardThemeBackdrop(
themeName: string,
appIsDark: boolean,
): string {
return isCardBackdropDark(themeName, appIsDark)
? DARK_CARD_BG
: LIGHT_CARD_BG;
}
@@ -1,18 +1,32 @@
import { clsx } from "clsx";
import type { HTMLProps, JSX, ReactNode } from "react";
interface ButtonProps extends HTMLProps<HTMLButtonElement> {
type ButtonVariant = "primary" | "soft" | "error";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, "size"> {
children: ReactNode;
/** DaisyUI color variant. Omit for the default neutral button. */
variant?: ButtonVariant | undefined;
size?: ButtonSize;
}
export function Button(props: ButtonProps): JSX.Element {
const { className, children, ...rest } = props;
const { className, children, variant, size = "md", ...rest } = props;
return (
<button
{...rest}
type="button"
className={clsx(
"border-0 py-2 px-6 inline-flex focus:outline-none rounded-[0.25rem] text-lg",
"btn text-lg",
{
"btn-primary": variant === "primary",
"btn-soft": variant === "soft",
"btn-error": variant === "error",
"btn-sm": size === "sm",
"btn-lg": size === "lg",
},
className,
)}
>
@@ -19,7 +19,7 @@ export function Checkbox({
type="checkbox"
disabled={disabled}
checked={checked}
className="checkbox bg-white text-[#18181b] mr-2"
className="checkbox checkbox-primary mr-2"
onChange={() => {
onCheckedChange(!checked);
}}
@@ -27,10 +27,7 @@ export function Select({
}: SelectProps): JSX.Element {
return (
<select
className={clsx(
"text-gray-700 text-base bg-white select select-sm w-40 rounded-sm mt-4",
className,
)}
className={clsx("text-base select select-sm w-40 mt-4", className)}
value={selectedOption.value}
onChange={(e) => {
onOptionChange(options[e.target.selectedIndex] as SelectOption);
@@ -43,7 +40,7 @@ export function Select({
value={option.value}
disabled={option.disabled}
className={clsx({
"bg-blue-200": option.value === selectedOption.value,
"bg-primary/20": option.value === selectedOption.value,
})}
>
{option.label}
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { JSX } from "react";
import { createPortal } from "react-dom";
import {
FaCheck as CheckIcon,
FaMoon as MoonIcon,
FaSun as SunIcon,
} from "react-icons/fa";
import { useTheme } from "../../redux/selectors/themeSelectors";
import { THEMES } from "../../redux/slices/theme";
interface ThemeIconProps {
isDark: boolean;
className: string;
}
/** Sun for light themes, moon for dark ones. */
function ThemeIcon({ isDark, className }: ThemeIconProps): JSX.Element {
return isDark ? (
<MoonIcon className={className} />
) : (
<SunIcon className={className} />
);
}
export function ThemePicker(): JSX.Element {
const { theme, isDark, setTheme } = useTheme();
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, right: 0 });
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLUListElement>(null);
const handleToggle = useCallback(() => {
if (!open && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}
setOpen((value) => !value);
}, [open]);
useEffect(() => {
if (!open) {
return undefined;
}
const close = () => {
setOpen(false);
};
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (
!menuRef.current?.contains(target) &&
!buttonRef.current?.contains(target)
) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", close);
// Close on scroll so the fixed menu can't detach from its button.
window.addEventListener("scroll", close, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
};
}, [open]);
return (
<>
<button
ref={buttonRef}
type="button"
className="btn btn-ghost btn-circle not-[&:hover]:text-current"
aria-label="Choose theme"
aria-haspopup="menu"
aria-expanded={open}
title="Choose theme"
onClick={handleToggle}
>
<ThemeIcon isDark={isDark} className="w-4 h-4" />
</button>
{open &&
createPortal(
<ul
ref={menuRef}
role="menu"
className="menu bg-base-200 text-base-content rounded-box fixed z-[9999] w-48 p-2 shadow-lg"
style={{ top: position.top, right: position.right }}
>
{THEMES.map((option) => (
<li key={option.name}>
<button
type="button"
role="menuitemradio"
aria-checked={option.name === theme}
data-theme-value={option.name}
className="flex items-center justify-between"
onClick={() => {
setTheme(option.name);
setOpen(false);
}}
>
<span className="flex items-center gap-2">
<ThemeIcon isDark={option.isDark} className="w-4 h-4" />
{option.label}
</span>
{option.name === theme && <CheckIcon className="w-3 h-3" />}
</button>
</li>
))}
</ul>,
document.body,
)}
</>
);
}
@@ -42,7 +42,7 @@ const options: Array<SelectOption> = [
id: 6,
label: "Only Languages",
disabled: false,
value: "compact&hide_progress=true",
value: "hide_progress",
},
];
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { JSX, ReactNode } from "react";
import { useDebouncedField } from "../../hooks/useDebouncedField";
import { Section } from "./Section";
interface NumericSectionProps {
@@ -26,45 +27,21 @@ export function NumericSection({
disabled = false,
placeholder,
}: NumericSectionProps): JSX.Element {
const [internalValue, setInternalValue] = useState(() => value?.toString());
const debounceTimeout = useRef<number | null>(null);
useEffect(() => {
// Debounce onValueChange
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
if (internalValue === value) {
return undefined;
}
debounceTimeout.current = window.setTimeout(() => {
const maybeNumber = internalValue && parseInt(internalValue, 10);
if (typeof maybeNumber !== "number" || Number.isNaN(maybeNumber)) {
onValueChange(undefined);
} else {
onValueChange(maybeNumber);
}
}, 700);
return () => {
clearTimeout(debounceTimeout.current as number);
};
}, [internalValue, onValueChange, value]);
useEffect(() => {
setInternalValue(value?.toString());
}, [value]);
const { inputValue, setInputValue } = useDebouncedField({
value,
onValueChange,
type: "number",
});
return (
<Section title={title}>
<p>{description}</p>
<input
type="number"
className="border border-gray-300 bg-white rounded px-2 py-1 mt-2 w-1/4"
value={internalValue ?? ""}
className="border border-base-content/20 rounded px-2 py-1 mt-2 w-1/4 bg-base-100"
value={inputValue}
onChange={(e) => {
setInternalValue(e.target.value);
setInputValue(e.target.value);
}}
min={min}
max={max}
+33 -17
View File
@@ -24,7 +24,7 @@ function ProgressSection({
<button
className={clsx(
"w-1/4 flex flex-col mx-2 p-2 cursor-pointer",
passed ? "border-blue-500" : "border-gray-500",
passed ? "border-primary" : "border-base-content/60",
isActive ? "border-t-[14px] -mt-[5px]" : "border-t-4",
)}
type="button"
@@ -32,14 +32,16 @@ function ProgressSection({
>
<div
className={clsx("text-lg font-bold", {
"text-blue-500": passed,
"text-gray-500": !passed,
"text-primary": passed,
"text-base-content/60": !passed,
"-mt-[4px]": isActive,
})}
>
{`Step ${num + 1}`}
</div>
<div className={passed ? "text-gray-700" : "text-gray-500"}>{item}</div>
<div className={passed ? "text-base-content/80" : "text-base-content/60"}>
{item}
</div>
</button>
);
}
@@ -59,16 +61,23 @@ export function ProgressBar({
const rightDisabled = currItemIndex === items.length - 1;
return (
<div className="w-full flex items-center sticky top-0 bg-gray-200 z-50 pt-3 pb-1 px-1 md:px-20 shadow-md">
<LeftArrowIcon
className={clsx("w-8 h-8", {
"text-gray-400 cursor-not-allowed": leftDisabled,
"text-gray-700 cursor-pointer": !leftDisabled,
})}
<div className="w-full flex items-center sticky top-0 bg-base-300 z-50 pt-3 pb-1 px-1 md:px-20 shadow-md">
<button
type="button"
aria-label="Previous step"
disabled={leftDisabled}
className="btn btn-ghost btn-circle"
onClick={() => {
onItemClick(Math.max(currItemIndex - 1, 0));
}}
/>
>
<LeftArrowIcon
className={clsx("w-8 h-8", {
"text-base-content/30": leftDisabled,
"text-base-content/70": !leftDisabled,
})}
/>
</button>
<div className="px-2 flex-grow flex flex-row">
{items.map((item, index) => {
return (
@@ -85,15 +94,22 @@ export function ProgressBar({
);
})}
</div>
<RightArrowIcon
className={clsx("w-8 h-8", {
"text-gray-400 cursor-not-allowed": rightDisabled,
"text-gray-700 cursor-pointer": !rightDisabled,
})}
<button
type="button"
aria-label="Next step"
disabled={rightDisabled}
className="btn btn-ghost btn-circle"
onClick={() => {
onItemClick(Math.min(currItemIndex + 1, items.length - 1));
}}
/>
>
<RightArrowIcon
className={clsx("w-8 h-8", {
"text-base-content/30": rightDisabled,
"text-base-content/70": !rightDisabled,
})}
/>
</button>
</div>
);
}
@@ -10,14 +10,14 @@ export function Section({ title, children }: SectionProps): JSX.Element {
return (
<div className="flex relative pb-12">
<div className="h-full w-10 absolute inset-0 flex items-center justify-center">
<div className="h-full w-1 bg-gray-200 pointer-events-none" />
<div className="h-full w-1 bg-base-300 pointer-events-none" />
</div>
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-blue-500 inline-flex items-center justify-center text-white relative z-10">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary inline-flex items-center justify-center text-primary-content relative z-10">
<LightningIcon className="w-5 h-5" />
</div>
<div className="flex-grow pl-4">
<h2 className="font-medium title-font text-sm text-gray-900 mb-1 tracking-wider">
<h2 className="font-medium title-font text-sm text-base-content mb-1 tracking-wider">
{title}
</h2>
{children}
@@ -16,7 +16,7 @@ const options: Array<SelectOption> = [
DEFAULT_OPTION,
{ id: 2, label: "Percentile", value: "percentile", disabled: false },
{ id: 3, label: "GitHub", value: "github", disabled: false },
{ id: 4, label: "None", value: "default&hide_rank=true", disabled: false },
{ id: 4, label: "None", value: "hide_rank", disabled: false },
];
interface StatsRankSectionProps {
@@ -1,7 +1,8 @@
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import type { ClipboardEventHandler, JSX, ReactNode } from "react";
import { useDebouncedField } from "../../hooks/useDebouncedField";
import { Section } from "./Section";
interface TextSectionProps {
@@ -25,29 +26,11 @@ export function TextSection({
placeholder,
onPaste,
}: TextSectionProps): JSX.Element {
const [internalValue, setInternalValue] = useState(value);
const debounceTimeout = useRef<number | null>(null);
useEffect(() => {
setInternalValue(value);
}, [value]);
useEffect(() => {
// Debounce onValueChange
if (debounceTimeout.current) {
window.clearTimeout(debounceTimeout.current);
}
if (internalValue === value) {
return undefined;
}
debounceTimeout.current = window.setTimeout(() => {
onValueChange(internalValue);
}, 700);
// return cleanup function:
return () => {
window.clearTimeout(debounceTimeout.current as number);
};
}, [internalValue, onValueChange, value]);
const { inputValue, setInputValue } = useDebouncedField({
value,
onValueChange,
type: "text",
});
return (
<Section title={title}>
@@ -55,12 +38,12 @@ export function TextSection({
<input
type="text"
className={clsx(
"border border-gray-300 rounded px-2 py-1 mt-2 w-3/4 min-w-48 max-w-xl",
{ "cursor-not-allowed bg-gray-100": disabled, "bg-white": !disabled },
"border border-base-content/20 rounded px-2 py-1 mt-2 w-3/4 min-w-48 max-w-xl",
disabled ? "cursor-not-allowed bg-base-200" : "bg-base-100",
)}
value={internalValue}
value={inputValue}
onChange={(e) => {
setInternalValue(e.target.value);
setInputValue(e.target.value);
}}
disabled={disabled}
placeholder={placeholder}
@@ -24,7 +24,7 @@ const options: Array<SelectOption> = [
id: 3,
label: "Text Only",
disabled: false,
value: "default&hide_progress=true&card_width=315",
value: "hide_progress",
},
];
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from "react";
type DebouncedFieldType = "text" | "number";
/** Value type handled by the hook for a given input {@link DebouncedFieldType} */
type DebouncedFieldValue<T extends DebouncedFieldType> = T extends "number"
? number | undefined
: string;
interface UseDebouncedFieldResult {
/** Controlled value for the `<input>` */
inputValue: string;
/** Updates the local value; the parent is notified after the debounce */
setInputValue: (input: string) => void;
}
const DEFAULT_DELAY_MS = 700;
/** Render any supported field value as the string shown in the `<input>` */
const toInputString = (value: string | number | undefined): string =>
value == null ? "" : String(value);
/** Parse an edited input string into a number (or `undefined` if empty/NaN) */
const toNumber = (input: string): number | undefined => {
if (!input) {
return undefined;
}
const parsed = parseInt(input, 10);
return Number.isNaN(parsed) ? undefined : parsed;
};
/**
* Local, debounced state for a controlled text input that mirrors an external value.
* Keeps a string copy of `value`, resets that copy when `value` changes,
* and notifies `onValueChange` once the user stops typing for `delay` milliseconds.
* `type` decides how the edited string is parsed before it is emitted
* (kept as a string for `"text"`, parsed to a number for `"number"`).
*/
export function useDebouncedField<T extends DebouncedFieldType>({
value,
onValueChange,
type,
delay = DEFAULT_DELAY_MS,
}: {
value: DebouncedFieldValue<T>;
onValueChange: (value: DebouncedFieldValue<T>) => void;
type: T;
delay?: number;
}): UseDebouncedFieldResult {
const [inputValue, setInputValue] = useState(() => toInputString(value));
const debounceTimeoutRef = useRef<number | null>(null);
// Reset the local value when the incoming prop changes by adjusting state
// during render — React's recommended alternative to a syncing effect:
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevValue, setPrevValue] = useState(value);
if (value !== prevValue) {
setPrevValue(value);
setInputValue(toInputString(value));
}
useEffect(() => {
if (debounceTimeoutRef.current) {
window.clearTimeout(debounceTimeoutRef.current);
}
// Nothing to emit while the input still mirrors the source value
if (inputValue === toInputString(value)) {
return undefined;
}
debounceTimeoutRef.current = window.setTimeout(() => {
const nextValue = type === "number" ? toNumber(inputValue) : inputValue;
// `type` guarantees `nextValue` matches the caller's value variant
onValueChange(nextValue as DebouncedFieldValue<T>);
}, delay);
return () => {
window.clearTimeout(debounceTimeoutRef.current as number);
};
}, [inputValue, value, onValueChange, type, delay]);
return { inputValue, setInputValue };
}
+103 -2
View File
@@ -1,14 +1,115 @@
/* ./src/index.css */
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui" {
themes:
light --default,
dark --prefersdark;
}
/*
Override primary color with default blue from github-stats-extended.
Override background color in dark mode with GitHub's dark mode background color.
*/
@plugin "daisyui/theme" {
name: "light";
--color-primary: #2f80ed;
--color-error: var(--color-red-700);
}
@plugin "daisyui/theme" {
name: "dark";
--color-primary: #2f80ed;
--color-error: var(--color-red-900);
--color-base-100: #0d1117;
}
:root {
/* Snappy duration for interactive elements (buttons, hovers). */
--transition-fast: 100ms;
/* Slower, deliberate fade for large surfaces during a theme switch. */
--transition-surface: 300ms;
}
body {
margin: 0;
font-family: "Segoe UI", Ubuntu, Sans-Serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--color-base-100);
color: var(--color-base-content);
}
button {
cursor: pointer;
}
/* Smoothly animate color changes when switching themes / on hover. */
*,
*::before,
*::after {
transition-property: background-color, border-color, color, fill, stroke;
/*
* An ease-out curve: animations start at full speed so quick interactions
* like button hovers feel responsive instead of laggy.
*/
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
transition-duration: var(--transition-fast);
}
/*
* Large surface backgrounds fade more slowly so a theme switch reads as a
* deliberate fade rather than an instant flip. Durations reference the custom
* properties above so the reduced-motion override below zeroes them out
* regardless of selector specificity.
*/
body,
.bg-base-100,
.bg-base-200,
.bg-base-300 {
transition-duration: var(--transition-surface);
}
/*
* DaisyUI's `.btn` ships its own 200ms transition which felt slow. The global
* rule above is unlayered, so it would otherwise clobber the button's property
* list and timing function. Restore both here (so fill, text and border animate
* together on outline buttons) and only speed up the duration.
*/
.btn {
transition-property: color, background-color, border-color, box-shadow;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
transition-duration: var(--transition-fast);
}
/*
* DaisyUI's default hover is very subtle; make it noticeable. Primary always darkens,
* while soft lightens in dark mode and darkens in light mode, so the change shows.
*/
.btn-primary:hover {
background-color: color-mix(in oklab, var(--color-primary), #000 30%);
}
.btn-soft {
background-color: color-mix(in oklab, var(--color-base-100), #888 35%);
}
.btn-soft:hover {
background-color: color-mix(in oklab, var(--color-base-100), #888 60%);
}
.btn-error {
color: var(--color-neutral-content);
}
.btn-error:hover {
color: var(--color-neutral-content);
background-color: color-mix(in oklab, var(--color-error), #f00 40%);
}
input:focus,
select:focus {
outline: 0;
border: var(--color-primary) solid 1px;
}
/* Respect users who prefer reduced motion. */
@media (prefers-reduced-motion: reduce) {
:root {
--transition-fast: 0ms;
--transition-surface: 0ms;
}
}
@@ -4,75 +4,45 @@
"repositories": {
"totalCount": 139,
"nodes": [
{ "name": "classicLogo", "stargazers": { "totalCount": 1 } },
{ "name": "creativechat", "stargazers": { "totalCount": 1 } },
{ "name": "verlet_tests", "stargazers": { "totalCount": 1 } },
{
"name": "blade-checkout-ui-demo",
"stargazers": { "totalCount": 0 }
},
{
"name": "tslint-plugin-analytics",
"stargazers": { "totalCount": 0 }
},
{ "name": "stargazer", "stargazers": { "totalCount": 0 } },
{
"name": "react-smooth-range-input",
"stargazers": { "totalCount": 0 }
},
{
"name": "csb-git-integration-bug",
"stargazers": { "totalCount": 0 }
},
{ "name": "covid-nepal-web", "stargazers": { "totalCount": 0 } },
{
"name": "shrijan00003.github.io",
"stargazers": { "totalCount": 0 }
},
{ "name": "TheBigCB.com", "stargazers": { "totalCount": 0 } },
{ "name": "html2commonmark", "stargazers": { "totalCount": 0 } },
{ "name": "test-myaction", "stargazers": { "totalCount": 0 } },
{ "name": "react-emoji-render", "stargazers": { "totalCount": 0 } },
{ "name": "sajilo-editor", "stargazers": { "totalCount": 0 } },
{
"name": "coder-aayush.github.io",
"stargazers": { "totalCount": 0 }
},
{ "name": "codesandbox-client", "stargazers": { "totalCount": 0 } },
{ "name": "product-sans", "stargazers": { "totalCount": 0 } },
{ "name": "NodejsBlogSystem", "stargazers": { "totalCount": 0 } },
{ "name": "native-javascript", "stargazers": { "totalCount": 0 } },
{ "name": "p5.js", "stargazers": { "totalCount": 0 } },
{ "name": "gatsbytutorials.com", "stargazers": { "totalCount": 0 } },
{
"name": "hello-world-docker-action",
"stargazers": { "totalCount": 0 }
},
{ "name": "glugmvit.github.io", "stargazers": { "totalCount": 0 } },
{
"name": "react-map-interaction",
"stargazers": { "totalCount": 0 }
},
{ "name": "react-external-image", "stargazers": { "totalCount": 0 } },
{ "name": "gatsby-boilerplate", "stargazers": { "totalCount": 0 } },
{ "name": "YouBackup", "stargazers": { "totalCount": 0 } },
{ "name": "npm-expansions", "stargazers": { "totalCount": 0 } },
{
"name": "Toy-Neural-Network-JS",
"stargazers": { "totalCount": 0 }
},
{ "name": "LSystemCreator", "stargazers": { "totalCount": 0 } },
{ "name": "Happy-2019", "stargazers": { "totalCount": 0 } },
{ "name": "jquery-navScroll", "stargazers": { "totalCount": 0 } },
{ "name": "strml.net", "stargazers": { "totalCount": 0 } },
{ "name": "Logo", "stargazers": { "totalCount": 0 } },
{ "name": "website", "stargazers": { "totalCount": 0 } },
{
"name": "anuraghazra.github.io-old",
"stargazers": { "totalCount": 0 }
},
{ "name": "Quintus", "stargazers": { "totalCount": 0 } },
{ "name": "Electron_experiments", "stargazers": { "totalCount": 0 } }
{ "name": "classicLogo", "stargazerCount": 1 },
{ "name": "creativechat", "stargazerCount": 1 },
{ "name": "verlet_tests", "stargazerCount": 1 },
{ "name": "blade-checkout-ui-demo", "stargazerCount": 0 },
{ "name": "tslint-plugin-analytics", "stargazerCount": 0 },
{ "name": "stargazer", "stargazerCount": 0 },
{ "name": "react-smooth-range-input", "stargazerCount": 0 },
{ "name": "csb-git-integration-bug", "stargazerCount": 0 },
{ "name": "covid-nepal-web", "stargazerCount": 0 },
{ "name": "shrijan00003.github.io", "stargazerCount": 0 },
{ "name": "TheBigCB.com", "stargazerCount": 0 },
{ "name": "html2commonmark", "stargazerCount": 0 },
{ "name": "test-myaction", "stargazerCount": 0 },
{ "name": "react-emoji-render", "stargazerCount": 0 },
{ "name": "sajilo-editor", "stargazerCount": 0 },
{ "name": "coder-aayush.github.io", "stargazerCount": 0 },
{ "name": "codesandbox-client", "stargazerCount": 0 },
{ "name": "product-sans", "stargazerCount": 0 },
{ "name": "NodejsBlogSystem", "stargazerCount": 0 },
{ "name": "native-javascript", "stargazerCount": 0 },
{ "name": "p5.js", "stargazerCount": 0 },
{ "name": "gatsbytutorials.com", "stargazerCount": 0 },
{ "name": "hello-world-docker-action", "stargazerCount": 0 },
{ "name": "glugmvit.github.io", "stargazerCount": 0 },
{ "name": "react-map-interaction", "stargazerCount": 0 },
{ "name": "react-external-image", "stargazerCount": 0 },
{ "name": "gatsby-boilerplate", "stargazerCount": 0 },
{ "name": "YouBackup", "stargazerCount": 0 },
{ "name": "npm-expansions", "stargazerCount": 0 },
{ "name": "Toy-Neural-Network-JS", "stargazerCount": 0 },
{ "name": "LSystemCreator", "stargazerCount": 0 },
{ "name": "Happy-2019", "stargazerCount": 0 },
{ "name": "jquery-navScroll", "stargazerCount": 0 },
{ "name": "strml.net", "stargazerCount": 0 },
{ "name": "Logo", "stargazerCount": 0 },
{ "name": "website", "stargazerCount": 0 },
{ "name": "anuraghazra.github.io-old", "stargazerCount": 0 },
{ "name": "Quintus", "stargazerCount": 0 },
{ "name": "Electron_experiments", "stargazerCount": 0 }
],
"pageInfo": {
"hasNextPage": false,
+1 -3
View File
@@ -7,9 +7,7 @@
"isPrivate": false,
"isArchived": false,
"isTemplate": false,
"stargazers": {
"totalCount": 76994
},
"stargazerCount": 76994,
"description": ":zap: Dynamically generated stats for your github readmes",
"primaryLanguage": {
"color": "#f1e05a",
+100 -597
View File
@@ -36,606 +36,109 @@
"repositories": {
"totalCount": 139,
"nodes": [
{
"name": "github-readme-stats",
"stargazers": {
"totalCount": 77025
}
},
{
"name": "Verly.js",
"stargazers": {
"totalCount": 696
}
},
{
"name": "anuraghazra.github.io",
"stargazers": {
"totalCount": 608
}
},
{
"name": "anuraghazra",
"stargazers": {
"totalCount": 376
}
},
{
"name": "type-trident",
"stargazers": {
"totalCount": 359
}
},
{
"name": "BugVilla",
"stargazers": {
"totalCount": 232
}
},
{
"name": "convoychat",
"stargazers": {
"totalCount": 214
}
},
{
"name": "CanvasFun",
"stargazers": {
"totalCount": 211
}
},
{
"name": "EvolutionAquerium",
"stargazers": {
"totalCount": 192
}
},
{
"name": "typelevel-parser",
"stargazers": {
"totalCount": 147
}
},
{
"name": "VerlyRangeSlider",
"stargazers": {
"totalCount": 135
}
},
{
"name": "vscode-strip-ts-copy",
"stargazers": {
"totalCount": 72
}
},
{
"name": "react-folder-tree",
"stargazers": {
"totalCount": 54
}
},
{
"name": "Atomic.js",
"stargazers": {
"totalCount": 53
}
},
{
"name": "ToyLang",
"stargazers": {
"totalCount": 51
}
},
{
"name": "design-patterns-everyday",
"stargazers": {
"totalCount": 46
}
},
{
"name": "gatsby",
"stargazers": {
"totalCount": 36
}
},
{
"name": "Slime",
"stargazers": {
"totalCount": 35
}
},
{
"name": "Candy.js",
"stargazers": {
"totalCount": 34
}
},
{
"name": "ShaderExpo",
"stargazers": {
"totalCount": 33
}
},
{
"name": "Nothing",
"stargazers": {
"totalCount": 32
}
},
{
"name": "peerlist-profile-action",
"stargazers": {
"totalCount": 30
}
},
{
"name": "facebook-reaction-animation",
"stargazers": {
"totalCount": 29
}
},
{
"name": "react-stripe-dropdown",
"stargazers": {
"totalCount": 27
}
},
{
"name": "QuickerPoll",
"stargazers": {
"totalCount": 27
}
},
{
"name": "playground-format-on-save",
"stargazers": {
"totalCount": 24
}
},
{
"name": "ParticleBrush",
"stargazers": {
"totalCount": 23
}
},
{
"name": "graphql-oauth",
"stargazers": {
"totalCount": 21
}
},
{
"name": "parasites",
"stargazers": {
"totalCount": 21
}
},
{
"name": "twitter-banner-jokes",
"stargazers": {
"totalCount": 18
}
},
{
"name": "verly-cpp",
"stargazers": {
"totalCount": 16
}
},
{
"name": "gatsby-github-issues-blog",
"stargazers": {
"totalCount": 16
}
},
{
"name": "NikeHyperAce",
"stargazers": {
"totalCount": 12
}
},
{
"name": "rust-particles",
"stargazers": {
"totalCount": 11
}
},
{
"name": "VerletDrawing",
"stargazers": {
"totalCount": 10
}
},
{
"name": "graphql-jwt-auth-test",
"stargazers": {
"totalCount": 9
}
},
{ "name": "github-readme-stats", "stargazerCount": 77025 },
{ "name": "Verly.js", "stargazerCount": 696 },
{ "name": "anuraghazra.github.io", "stargazerCount": 608 },
{ "name": "anuraghazra", "stargazerCount": 376 },
{ "name": "type-trident", "stargazerCount": 359 },
{ "name": "BugVilla", "stargazerCount": 232 },
{ "name": "convoychat", "stargazerCount": 214 },
{ "name": "CanvasFun", "stargazerCount": 211 },
{ "name": "EvolutionAquerium", "stargazerCount": 192 },
{ "name": "typelevel-parser", "stargazerCount": 147 },
{ "name": "VerlyRangeSlider", "stargazerCount": 135 },
{ "name": "vscode-strip-ts-copy", "stargazerCount": 72 },
{ "name": "react-folder-tree", "stargazerCount": 54 },
{ "name": "Atomic.js", "stargazerCount": 53 },
{ "name": "ToyLang", "stargazerCount": 51 },
{ "name": "design-patterns-everyday", "stargazerCount": 46 },
{ "name": "gatsby", "stargazerCount": 36 },
{ "name": "Slime", "stargazerCount": 35 },
{ "name": "Candy.js", "stargazerCount": 34 },
{ "name": "ShaderExpo", "stargazerCount": 33 },
{ "name": "Nothing", "stargazerCount": 32 },
{ "name": "peerlist-profile-action", "stargazerCount": 30 },
{ "name": "facebook-reaction-animation", "stargazerCount": 29 },
{ "name": "react-stripe-dropdown", "stargazerCount": 27 },
{ "name": "QuickerPoll", "stargazerCount": 27 },
{ "name": "playground-format-on-save", "stargazerCount": 24 },
{ "name": "ParticleBrush", "stargazerCount": 23 },
{ "name": "graphql-oauth", "stargazerCount": 21 },
{ "name": "parasites", "stargazerCount": 21 },
{ "name": "twitter-banner-jokes", "stargazerCount": 18 },
{ "name": "verly-cpp", "stargazerCount": 16 },
{ "name": "gatsby-github-issues-blog", "stargazerCount": 16 },
{ "name": "NikeHyperAce", "stargazerCount": 12 },
{ "name": "rust-particles", "stargazerCount": 11 },
{ "name": "VerletDrawing", "stargazerCount": 10 },
{ "name": "graphql-jwt-auth-test", "stargazerCount": 9 },
{
"name": "anuraghazra.github.io-old-react",
"stargazers": {
"totalCount": 9
}
"stargazerCount": 9
},
{
"name": "react18-ssr-test",
"stargazers": {
"totalCount": 8
}
},
{
"name": "GyroDodge",
"stargazers": {
"totalCount": 8
}
},
{
"name": "go-phyllotaxis",
"stargazers": {
"totalCount": 6
}
},
{
"name": "graphql-oauth-client",
"stargazers": {
"totalCount": 6
}
},
{
"name": "code-runner-action",
"stargazers": {
"totalCount": 6
}
},
{
"name": "GithubActionsPlayground",
"stargazers": {
"totalCount": 6
}
},
{
"name": "Loader.js",
"stargazers": {
"totalCount": 6
}
},
{
"name": "ts-essentials",
"stargazers": {
"totalCount": 5
}
},
{
"name": "chakra-ui",
"stargazers": {
"totalCount": 5
}
},
{
"name": "text-to-handwriting",
"stargazers": {
"totalCount": 5
}
},
{
"name": "build-your-own-x",
"stargazers": {
"totalCount": 5
}
},
{
"name": "nodejs-dynamic-restapi",
"stargazers": {
"totalCount": 5
}
},
{
"name": "DevYouTubeList",
"stargazers": {
"totalCount": 5
}
},
{
"name": "FlockingBlackHole",
"stargazers": {
"totalCount": 5
}
},
{
"name": "webgpu-boids",
"stargazers": {
"totalCount": 4
}
},
{
"name": "test-githubblock",
"stargazers": {
"totalCount": 4
}
},
{
"name": "renderless-components",
"stargazers": {
"totalCount": 4
}
},
{
"name": "reakit",
"stargazers": {
"totalCount": 4
}
},
{
"name": "micro-open-graph",
"stargazers": {
"totalCount": 4
}
},
{
"name": "rest-and-graphql",
"stargazers": {
"totalCount": 4
}
},
{
"name": "abell-issues-blog",
"stargazers": {
"totalCount": 4
}
},
{
"name": "VanillaMVC",
"stargazers": {
"totalCount": 4
}
},
{
"name": "covid19-sim",
"stargazers": {
"totalCount": 4
}
},
{
"name": "YoutubeDownloader",
"stargazers": {
"totalCount": 4
}
},
{
"name": "verlet.js",
"stargazers": {
"totalCount": 4
}
},
{
"name": "test-github-ts",
"stargazers": {
"totalCount": 3
}
},
{
"name": "tw-dynamic-interpolation",
"stargazers": {
"totalCount": 3
}
},
{
"name": "ts-string-interpolator",
"stargazers": {
"totalCount": 3
}
},
{
"name": "AnuReact",
"stargazers": {
"totalCount": 3
}
},
{
"name": "CanvasBalls",
"stargazers": {
"totalCount": 3
}
},
{
"name": "circleci-test",
"stargazers": {
"totalCount": 3
}
},
{
"name": "gatsby-starter-netlify-cms",
"stargazers": {
"totalCount": 3
}
},
{
"name": "WebGL.js",
"stargazers": {
"totalCount": 3
}
},
{
"name": "test-fork-workflow",
"stargazers": {
"totalCount": 2
}
},
{
"name": "test-codesandbox-projects",
"stargazers": {
"totalCount": 2
}
},
{
"name": "codesandbox-template-astro",
"stargazers": {
"totalCount": 2
}
},
{
"name": "apollo",
"stargazers": {
"totalCount": 2
}
},
{
"name": "api-docs",
"stargazers": {
"totalCount": 2
}
},
{
"name": "Buddies-movie",
"stargazers": {
"totalCount": 2
}
},
{
"name": "2k76",
"stargazers": {
"totalCount": 2
}
},
{
"name": "gatsby-remark-embedder",
"stargazers": {
"totalCount": 2
}
},
{
"name": "weird-hello-worlds",
"stargazers": {
"totalCount": 2
}
},
{
"name": "rn-tabview-test-repro",
"stargazers": {
"totalCount": 1
}
},
{
"name": "tailwindcss-jit",
"stargazers": {
"totalCount": 1
}
},
{
"name": "DefinitelyTyped",
"stargazers": {
"totalCount": 1
}
},
{
"name": "server-components-demo",
"stargazers": {
"totalCount": 1
}
},
{
"name": "volant-vo",
"stargazers": {
"totalCount": 1
}
},
{
"name": "react-spectrum",
"stargazers": {
"totalCount": 1
}
},
{
"name": "webhooks-test",
"stargazers": {
"totalCount": 1
}
},
{
"name": "react-showdown",
"stargazers": {
"totalCount": 1
}
},
{
"name": "transcode",
"stargazers": {
"totalCount": 1
}
},
{
"name": "awake-yet-action",
"stargazers": {
"totalCount": 1
}
},
{
"name": "bradgarropy.com",
"stargazers": {
"totalCount": 1
}
},
{
"name": "gatsby-plugin-social-banners",
"stargazers": {
"totalCount": 1
}
},
{
"name": "Tour360",
"stargazers": {
"totalCount": 1
}
},
{
"name": "blizard.js",
"stargazers": {
"totalCount": 1
}
},
{
"name": "keep-a-changelog",
"stargazers": {
"totalCount": 1
}
},
{
"name": "vulnerabilities",
"stargazers": {
"totalCount": 1
}
},
{
"name": "harleydavidson",
"stargazers": {
"totalCount": 1
}
},
{
"name": "awesome-webgl",
"stargazers": {
"totalCount": 1
}
},
{
"name": "NewtonsInterpolation",
"stargazers": {
"totalCount": 1
}
},
{
"name": "now-github-starter",
"stargazers": {
"totalCount": 1
}
},
{
"name": "simplerockets",
"stargazers": {
"totalCount": 1
}
}
{ "name": "react18-ssr-test", "stargazerCount": 8 },
{ "name": "GyroDodge", "stargazerCount": 8 },
{ "name": "go-phyllotaxis", "stargazerCount": 6 },
{ "name": "graphql-oauth-client", "stargazerCount": 6 },
{ "name": "code-runner-action", "stargazerCount": 6 },
{ "name": "GithubActionsPlayground", "stargazerCount": 6 },
{ "name": "Loader.js", "stargazerCount": 6 },
{ "name": "ts-essentials", "stargazerCount": 5 },
{ "name": "chakra-ui", "stargazerCount": 5 },
{ "name": "text-to-handwriting", "stargazerCount": 5 },
{ "name": "build-your-own-x", "stargazerCount": 5 },
{ "name": "nodejs-dynamic-restapi", "stargazerCount": 5 },
{ "name": "DevYouTubeList", "stargazerCount": 5 },
{ "name": "FlockingBlackHole", "stargazerCount": 5 },
{ "name": "webgpu-boids", "stargazerCount": 4 },
{ "name": "test-githubblock", "stargazerCount": 4 },
{ "name": "renderless-components", "stargazerCount": 4 },
{ "name": "reakit", "stargazerCount": 4 },
{ "name": "micro-open-graph", "stargazerCount": 4 },
{ "name": "rest-and-graphql", "stargazerCount": 4 },
{ "name": "abell-issues-blog", "stargazerCount": 4 },
{ "name": "VanillaMVC", "stargazerCount": 4 },
{ "name": "covid19-sim", "stargazerCount": 4 },
{ "name": "YoutubeDownloader", "stargazerCount": 4 },
{ "name": "verlet.js", "stargazerCount": 4 },
{ "name": "test-github-ts", "stargazerCount": 3 },
{ "name": "tw-dynamic-interpolation", "stargazerCount": 3 },
{ "name": "ts-string-interpolator", "stargazerCount": 3 },
{ "name": "AnuReact", "stargazerCount": 3 },
{ "name": "CanvasBalls", "stargazerCount": 3 },
{ "name": "circleci-test", "stargazerCount": 3 },
{ "name": "gatsby-starter-netlify-cms", "stargazerCount": 3 },
{ "name": "WebGL.js", "stargazerCount": 3 },
{ "name": "test-fork-workflow", "stargazerCount": 2 },
{ "name": "test-codesandbox-projects", "stargazerCount": 2 },
{ "name": "codesandbox-template-astro", "stargazerCount": 2 },
{ "name": "apollo", "stargazerCount": 2 },
{ "name": "api-docs", "stargazerCount": 2 },
{ "name": "Buddies-movie", "stargazerCount": 2 },
{ "name": "2k76", "stargazerCount": 2 },
{ "name": "gatsby-remark-embedder", "stargazerCount": 2 },
{ "name": "weird-hello-worlds", "stargazerCount": 2 },
{ "name": "rn-tabview-test-repro", "stargazerCount": 1 },
{ "name": "tailwindcss-jit", "stargazerCount": 1 },
{ "name": "DefinitelyTyped", "stargazerCount": 1 },
{ "name": "server-components-demo", "stargazerCount": 1 },
{ "name": "volant-vo", "stargazerCount": 1 },
{ "name": "react-spectrum", "stargazerCount": 1 },
{ "name": "webhooks-test", "stargazerCount": 1 },
{ "name": "react-showdown", "stargazerCount": 1 },
{ "name": "transcode", "stargazerCount": 1 },
{ "name": "awake-yet-action", "stargazerCount": 1 },
{ "name": "bradgarropy.com", "stargazerCount": 1 },
{ "name": "gatsby-plugin-social-banners", "stargazerCount": 1 },
{ "name": "Tour360", "stargazerCount": 1 },
{ "name": "blizard.js", "stargazerCount": 1 },
{ "name": "keep-a-changelog", "stargazerCount": 1 },
{ "name": "vulnerabilities", "stargazerCount": 1 },
{ "name": "harleydavidson", "stargazerCount": 1 },
{ "name": "awesome-webgl", "stargazerCount": 1 },
{ "name": "NewtonsInterpolation", "stargazerCount": 1 },
{ "name": "now-github-starter", "stargazerCount": 1 },
{ "name": "simplerockets", "stargazerCount": 1 }
],
"pageInfo": {
"hasNextPage": true,
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { CardType } from "./CardType";
import { cardUrl } from "./CardUrl";
describe("cardUrl", () => {
it("maps each card type to its path", () => {
expect(cardUrl(CardType.STATS).toString()).toBe("");
expect(cardUrl(CardType.TOP_LANGS).toString()).toBe("/top-langs");
expect(cardUrl(CardType.PIN).toString()).toBe("/pin");
expect(cardUrl(CardType.GIST).toString()).toBe("/gist");
expect(cardUrl(CardType.WAKATIME).toString()).toBe("/wakatime");
});
it("builds a suffix with params", () => {
expect(
cardUrl(CardType.TOP_LANGS).username("john").langsCount(4).toString(),
).toBe("/top-langs?username=john&langs_count=4");
});
it("derives a download filename per card", () => {
expect(cardUrl(CardType.STATS).username("john").filename()).toBe(
"john_card",
);
expect(cardUrl(CardType.TOP_LANGS).username("john").filename()).toBe(
"john_card",
);
expect(cardUrl(CardType.PIN).repo("owner/repo1").filename()).toBe(
"owner/repo1_card",
);
expect(cardUrl(CardType.GIST).gistId("abc").filename()).toBe("gist_card");
expect(cardUrl(CardType.WAKATIME).username("waka").filename()).toBe(
"waka_card",
);
});
it("is immutable: setters return new instances", () => {
const base = cardUrl(CardType.STATS).username("john");
const withIcons = base.showIcons();
expect(base.toString()).toBe("?username=john");
expect(withIcons.toString()).toBe("?username=john&show_icons=true");
});
it("sets values verbatim (callers decide whether to include a param)", () => {
// no dropping of empty/false — the value is set as given
expect(cardUrl(CardType.STATS).username("").toString()).toBe("?username=");
});
it("encodes special characters", () => {
// spaces as %20 (not +), commas as %2C
expect(
cardUrl(CardType.STATS)
.username("john")
.customTitle("My Stats")
.toString(),
).toBe("?username=john&custom_title=My%20Stats");
expect(
cardUrl(CardType.STATS).username("john").show("a,b,c").toString(),
).toBe("?username=john&show=a%2Cb%2Cc");
});
it("builds an absolute api url with the given host", () => {
expect(
cardUrl(CardType.STATS)
.username("john")
.client("wizard")
.toApiUrl("example.com"),
).toBe("https://example.com/api?username=john&client=wizard");
});
it("applies a theme via the universal setter", () => {
expect(
cardUrl(CardType.STATS).username("john").theme("github_dark").toString(),
).toBe("?username=john&theme=github_dark");
});
});
+282
View File
@@ -0,0 +1,282 @@
import { CardType } from "./CardType";
/**
* Immutable, per-card builders for the query-string suffix of a card URL.
*
* Each card type accepts a different set of query params, so there is one
* builder class per card exposing only the params that card supports. Setters
* return a new instance (immutable); {@link CardUrlBase.toString} produces the
* relative suffix (e.g. `/top-langs?username=foo&langs_count=4`).
*
* Which card accepts which param:
*
* | param | STATS | TOP_LANGS | PIN | GIST | WAKATIME |
* | ----------------------- | :---: | :-------: | :-: | :--: | :------: |
* | username | ✓ | ✓ | ✓ | | ✓ |
* | hide_title | ✓ | ✓ | | | ✓ |
* | custom_title | ✓ | | | | ✓ |
* | rank_icon | ✓ | | | | |
* | hide_rank | ✓ | | | | |
* | show | ✓ | | | | |
* | show_icons | ✓ | | | | |
* | include_all_commits | ✓ | | | | |
* | hide_values | | ✓ | | | |
* | layout | | ✓ | | | ✓ |
* | hide_progress | | ✓ | | | ✓ |
* | langs_count | | ✓ | | | ✓ |
* | repo | | | ✓ | | |
* | description_lines_count | | | ✓ | | |
* | show_owner | | | ✓ | ✓ | |
* | id (gist) | | | | ✓ | |
* | display_format | | | | | ✓ |
* | card_width | | | | | ✓ |
* | theme | ✓ | ✓ | ✓ | ✓ | ✓ |
* | client | ✓ | ✓ | ✓ | ✓ | ✓ |
* | disable_animations | ✓ | ✓ | ✓ | ✓ | ✓ |
*/
abstract class CardUrlBase<S extends CardUrlBase<S>> {
protected constructor(
protected readonly params: ReadonlyMap<string, string>,
) {}
/** Build a new instance of the concrete card builder. */
protected abstract create(params: ReadonlyMap<string, string>): S;
/** Path segment appended after `/api` (empty for the stats card). */
protected abstract readonly path: string;
/**
* The single place that produces a new instance. Sets the param verbatim;
* callers decide whether a param should be included (see `buildCardUrl`).
*/
protected with(key: string, value: string | number | boolean): S {
const next = new Map(this.params);
next.set(key, String(value));
return this.create(next);
}
// ---- universal setters (valid on every card) ----
theme(v: string): S {
return this.with("theme", v);
}
client(v: string): S {
return this.with("client", v);
}
disableAnimations(v = true): S {
return this.with("disable_animations", v);
}
/** Suggested download filename (without extension) for this card. */
abstract filename(): string;
/** Relative suffix, e.g. `/top-langs?username=foo&langs_count=4`. */
toString(): string {
const search = new URLSearchParams();
for (const [k, v] of this.params) {
search.set(k, v);
}
// URLSearchParams encodes spaces as `+`; normalize to `%20` for parity with
// encodeURIComponent and universal query parsing.
const qs = search.toString().replace(/\+/g, "%20");
return `${this.path}${qs ? `?${qs}` : ""}`;
}
/**
* Absolute URL the `<img>`/SvgInline loads. `host` is passed in so this
* module stays free of the `window`-dependent constants.
*/
toApiUrl(host: string): string {
return `https://${host}/api${this.toString()}`;
}
}
class StatsCardUrl extends CardUrlBase<StatsCardUrl> {
protected readonly path = "";
protected create(p: ReadonlyMap<string, string>) {
return new StatsCardUrl(p);
}
static create() {
return new StatsCardUrl(new Map());
}
filename() {
return `${this.params.get("username") ?? ""}_card`;
}
username(v: string) {
return this.with("username", v);
}
rankIcon(v: string) {
return this.with("rank_icon", v);
}
hideRank(v = true) {
return this.with("hide_rank", v);
}
hideTitle(v = true) {
return this.with("hide_title", v);
}
customTitle(v: string) {
return this.with("custom_title", v);
}
show(v: string) {
return this.with("show", v);
}
showIcons(v = true) {
return this.with("show_icons", v);
}
includeAllCommits(v = true) {
return this.with("include_all_commits", v);
}
}
class TopLangsCardUrl extends CardUrlBase<TopLangsCardUrl> {
protected readonly path = "/top-langs";
protected create(p: ReadonlyMap<string, string>) {
return new TopLangsCardUrl(p);
}
static create() {
return new TopLangsCardUrl(new Map());
}
filename() {
return `${this.params.get("username") ?? ""}_card`;
}
username(v: string) {
return this.with("username", v);
}
layout(v: string) {
return this.with("layout", v);
}
hideProgress(v = true) {
return this.with("hide_progress", v);
}
hideTitle(v = true) {
return this.with("hide_title", v);
}
langsCount(v: number) {
return this.with("langs_count", v);
}
hideValues(v = true) {
return this.with("hide_values", v);
}
}
class PinCardUrl extends CardUrlBase<PinCardUrl> {
protected readonly path = "/pin";
protected create(p: ReadonlyMap<string, string>) {
return new PinCardUrl(p);
}
static create() {
return new PinCardUrl(new Map());
}
filename() {
return `${this.params.get("repo") ?? ""}_card`;
}
username(v: string) {
return this.with("username", v);
}
repo(v: string) {
return this.with("repo", v);
}
showOwner(v = true) {
return this.with("show_owner", v);
}
descriptionLines(v: number) {
return this.with("description_lines_count", v);
}
}
class GistCardUrl extends CardUrlBase<GistCardUrl> {
protected readonly path = "/gist";
protected create(p: ReadonlyMap<string, string>) {
return new GistCardUrl(p);
}
static create() {
return new GistCardUrl(new Map());
}
filename() {
return "gist_card";
}
gistId(v: string) {
return this.with("id", v);
}
showOwner(v = true) {
return this.with("show_owner", v);
}
}
class WakatimeCardUrl extends CardUrlBase<WakatimeCardUrl> {
protected readonly path = "/wakatime";
protected create(p: ReadonlyMap<string, string>) {
return new WakatimeCardUrl(p);
}
static create() {
return new WakatimeCardUrl(new Map());
}
filename() {
return `${this.params.get("username") ?? ""}_card`;
}
username(v: string) {
return this.with("username", v);
}
layout(v: string) {
return this.with("layout", v);
}
hideProgress(v = true) {
return this.with("hide_progress", v);
}
hideTitle(v = true) {
return this.with("hide_title", v);
}
customTitle(v: string) {
return this.with("custom_title", v);
}
langsCount(v: number) {
return this.with("langs_count", v);
}
displayFormat(v: string) {
return this.with("display_format", v);
}
cardWidth(v: number) {
return this.with("card_width", v);
}
}
/** Any card-URL builder, regardless of card type (all share the universal API). */
export type CardUrlBuilder =
StatsCardUrl | TopLangsCardUrl | PinCardUrl | GistCardUrl | WakatimeCardUrl;
/**
* Create a card-URL builder typed to the given card, so only the params that
* card supports are in scope (e.g. `cardUrl(CardType.GIST)` has no
* `langsCount`).
*/
export function cardUrl(c: typeof CardType.STATS): StatsCardUrl;
export function cardUrl(c: typeof CardType.TOP_LANGS): TopLangsCardUrl;
export function cardUrl(c: typeof CardType.PIN): PinCardUrl;
export function cardUrl(c: typeof CardType.GIST): GistCardUrl;
export function cardUrl(c: typeof CardType.WAKATIME): WakatimeCardUrl;
export function cardUrl(c: CardType) {
switch (c) {
case CardType.STATS:
return StatsCardUrl.create();
case CardType.TOP_LANGS:
return TopLangsCardUrl.create();
case CardType.PIN:
return PinCardUrl.create();
case CardType.GIST:
return GistCardUrl.create();
case CardType.WAKATIME:
return WakatimeCardUrl.create();
default:
c satisfies never;
throw new Error(`unknown card type: ${c as string}`);
}
}
+11 -1
View File
@@ -2,24 +2,34 @@ export const STAGE_LABELS = [
{
title: "Login",
shortTitle: "Login",
description: "",
},
{
title: "Select a Card",
shortTitle: "Select Card",
description: "You will be able to customize your card in future steps.",
},
{
title: "Modify Card Parameters",
shortTitle: "Modify Parameters",
description: "",
},
{
title: "Choose a Theme",
shortTitle: "Select Theme",
description: "",
},
{
title: "Display your Card",
shortTitle: "Display Card",
description:
"Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!",
},
] as const satisfies Array<{ title: string; shortTitle: string }>;
] as const satisfies Array<{
title: string;
shortTitle: string;
description: string;
}>;
export type StageIndex = {
[K in keyof typeof STAGE_LABELS]: K extends `${infer N extends number}`
+15 -17
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { ToastContainer, toast } from "react-toastify";
import { getUserMetadata } from "../../api/user";
import { clearAxiosCache } from "../../axios-override";
import type { StageIndex } from "../../models/Stage";
import { useTheme } from "../../redux/selectors/themeSelectors";
import {
useIsAuthenticated,
useUserKey,
@@ -56,6 +57,7 @@ export function AppTrends() {
const userKey = useUserKey();
const userToken = useUserToken();
const isAuthenticated = useIsAuthenticated();
const { isDark } = useTheme();
const [stage, setStage] = useState<StageIndex>(isAuthenticated ? 1 : 0);
@@ -79,24 +81,20 @@ export function AppTrends() {
clearAxiosCache();
}, [userToken]);
/*
* Advance to step 1 only when `isAuthenticated` transitions, so a logged-in user can still navigate back to step 0.
* Tracked during render (comparing the previous value) rather than in an effect:
* https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
*/
{
/*
* This effect must only be executed when `isAuthenticated` changes,
* otherwise logged-in user are unable to go back to step 1.
*/
const prevIsAuthenticated = useRef(isAuthenticated);
useEffect(() => {
if (prevIsAuthenticated.current === isAuthenticated) {
return;
}
prevIsAuthenticated.current = isAuthenticated;
const [prevIsAuthenticated, setPrevIsAuthenticated] =
useState(isAuthenticated);
if (isAuthenticated !== prevIsAuthenticated) {
setPrevIsAuthenticated(isAuthenticated);
if (isAuthenticated && stage === 0) {
setStage(1);
}
}, [isAuthenticated, stage]);
}
}
useEffect(() => {
@@ -116,9 +114,9 @@ export function AppTrends() {
return (
<div className="min-h-screen flex flex-col">
<Header currStageIndex={stage} onStageIndexChange={setStage} />
<section className="bg-white text-gray-700 flex-grow">
<section className="bg-base-100 text-base-content flex-grow">
<HomeScreen stage={stage} setStage={setStage} />
<ToastContainer />
<ToastContainer theme={isDark ? "dark" : "light"} />
</section>
</div>
);
+15 -11
View File
@@ -2,6 +2,7 @@ import type { JSX } from "react";
import { FaGithub as GithubIcon } from "react-icons/fa";
import appIcon from "../../assets/appLogo64.png";
import { ThemePicker } from "../../components/Generic/ThemePicker";
import { ProgressBar } from "../../components/Home/Progress";
import { STAGE_LABELS } from "../../models/Stage";
import type { StageIndex } from "../../models/Stage";
@@ -19,31 +20,34 @@ export function Header({
}: HeaderProps): JSX.Element {
return (
<>
<div className="text-gray-100 bg-gray-800 shadow-md z-50">
<div
className="text-neutral-content shadow-md z-50"
style={{
backgroundColor:
"color-mix(in oklab, var(--color-primary), #000 50%)",
}}
>
<div className="px-5 py-2 flex flex-wrap">
{/* Logo */}
<a
href="/"
className="flex items-center title-font font-medium text-gray-50 mb-0 md:mr-8"
className="flex items-center title-font font-medium text-neutral-content mb-0 md:mr-8"
>
<img src={appIcon} alt="logo" className="w-6 h-6" />
<span className="ml-2 text-xl">GitHub Stats Extended</span>
</a>
{/* Star on GitHub */}
<div className="flex ml-auto items-center text-base justify-center">
{/* Star on GitHub + theme toggle */}
<div className="flex ml-auto items-center gap-2 text-base justify-center">
<a
href="https://github.com/stats-organization/github-stats-extended"
target="_blank"
rel="noopener noreferrer"
className="rounded-[0.25rem] shadow bg-neutral-content hover:opacity-90 text-neutral px-3 py-1 flex items-center"
>
<button
type="button"
className="rounded-[0.25rem] shadow bg-gray-200 hover:bg-gray-300 text-black px-3 py-1 flex items-center"
>
Star on
<GithubIcon className="ml-1.5 w-5 h-5" />
</button>
Star on
<GithubIcon className="ml-1.5 w-5 h-5" />
</a>
<ThemePicker />
</div>
</div>
</div>
+100 -219
View File
@@ -1,5 +1,4 @@
import axios from "axios";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { JSX } from "react";
import { useDispatch } from "react-redux";
import { BounceLoader } from "react-spinners";
@@ -7,18 +6,12 @@ import { v4 as uuidv4 } from "uuid";
import { authenticate } from "../../api/user";
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
import {
DEMO_GIST,
DEMO_REPO,
DEMO_USER,
DEMO_WAKATIME_USER,
HOST,
} from "../../constants";
import { DEMO_USER } from "../../constants";
import { CardType } from "../../models/CardType";
import { STAGE_LABELS } from "../../models/Stage";
import type { StageIndex } from "../../models/Stage";
import { useTheme } from "../../redux/selectors/themeSelectors";
import {
useIsAuthenticated,
usePrivateAccess,
@@ -26,12 +19,15 @@ import {
} from "../../redux/selectors/userSelectors";
import { login } from "../../redux/slices/user";
import { getFullSuffix } from "./getFullSuffix";
import { buildCardUrl } from "./buildCardUrl";
import { getDefaultCardOptions } from "./cardOptions";
import type { CardOptions } from "./cardOptions";
import { CustomizeStage } from "./stages/Customize";
import { DisplayStage } from "./stages/Display";
import { LoginStage } from "./stages/Login/Login";
import { SelectCardStage } from "./stages/SelectCard";
import { ThemeStage } from "./stages/Theme";
import { useCardDescriptor } from "./useCardDescriptor";
interface HomeScreenProps {
stage: StageIndex;
@@ -47,51 +43,44 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
const dispatch = useDispatch();
// for stage two
const [selectedUserId, setSelectedUserId] = useState(userId);
const [repo, setRepo] = useState(DEMO_REPO);
const [gist, setGist] = useState(DEMO_GIST);
const [wakatimeUser, setWakatimeUser] = useState(DEMO_WAKATIME_USER);
const [selectedCard, setSelectedCard] = useState<CardType>(CardType.STATS);
useEffect(() => {
setSelectedUserId(userId);
}, [userId]);
// for stage three
const [selectedStatsRank, setSelectedStatsRank] =
useState(STATS_DEFAULT_RANK);
const [selectedLanguagesLayout, setSelectedLanguagesLayout] = useState(
LANGUAGES_DEFAULT_LAYOUT,
);
const [selectedWakatimeLayout, setSelectedWakatimeLayout] = useState(
WAKATIME_DEFAULT_LAYOUT,
// for stages two and three
const [cardOptions, setCardOptions] = useState(() =>
getDefaultCardOptions(userId),
);
const [showTitle, setShowTitle] = useState(true);
const [showOwner, setShowOwner] = useState(false);
const [descriptionLines, setDescriptionLines] = useState<
number | undefined
>();
const [customTitle, setCustomTitle] = useState("");
const [langsCount, setLangsCount] = useState<number | undefined>();
const [hideValues, setHideValues] = useState(false);
const [showAllStats, setShowAllStats] = useState(false);
const [showIcons, setShowIcons] = useState(false);
const [includeAllCommits, setIncludeAllCommits] = useState(true);
const [enableAnimations, setEnableAnimations] = useState(true);
const [usePercent, setUsePercent] = useState(false);
const setCardOption = useCallback<
<K extends keyof CardOptions>(key: K, value: CardOptions[K]) => void
>((key, value) => {
setCardOptions((prev) => ({ ...prev, [key]: value }));
}, []);
const [theme, setTheme] = useState("default");
// Reset the selected user to the resolved account id when it changes,
// adjusting state during render rather than in an effect:
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevUserId, setPrevUserId] = useState(userId);
if (userId !== prevUserId) {
setPrevUserId(userId);
setCardOptions((prev) => ({ ...prev, selectedUserId: userId }));
}
const { isDark } = useTheme();
const [theme, setTheme] = useState(isDark ? "dark" : "default");
const handleCardTypeChange = (cardType: CardType) => {
if (cardType === CardType.TOP_LANGS) {
setLangsCount(4);
setSelectedWakatimeLayout(WAKATIME_DEFAULT_LAYOUT);
setCardOptions((prev) => ({
...prev,
langsCount: 4,
selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT,
}));
} else if (cardType === CardType.WAKATIME) {
setLangsCount(6);
setSelectedLanguagesLayout(LANGUAGES_DEFAULT_LAYOUT);
setCardOptions((prev) => ({
...prev,
langsCount: 6,
selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT,
}));
}
if (theme === "default" || theme === "default_repocard") {
@@ -107,77 +96,41 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
setStage(2);
};
const fullSuffix = getFullSuffix({
userId,
selectedCard,
selectedUserId,
repo,
gist,
wakatimeUser,
selectedStatsRank,
selectedLanguagesLayout,
selectedWakatimeLayout,
showTitle,
showOwner,
descriptionLines,
customTitle,
langsCount,
hideValues,
showAllStats,
showIcons,
includeAllCommits,
enableAnimations,
usePercent,
});
// Memoized so the builder keeps a stable reference across renders when its
// inputs are unchanged. Card components consume it by value (they derive a URL
// string), but memoizing keeps it safe to pass as a prop if any of them are
// ever wrapped in React.memo.
const cardBuilder = useMemo(
() => buildCardUrl(userId, selectedCard, cardOptions),
[userId, selectedCard, cardOptions],
);
// Preview builder for the customize stage, dark-themed to match the surroundings.
const customizeCardBuilder = useMemo(
() => (isDark ? cardBuilder.theme("github_dark") : cardBuilder),
[cardBuilder, isDark],
);
// for stage four
let themeSuffix = fullSuffix;
const isRepoCard =
selectedCard === CardType.PIN || selectedCard === CardType.GIST;
const defaultTheme = isRepoCard ? "default_repocard" : "default";
const isDefaultTheme = theme === defaultTheme;
if (
!(
(theme === "default" &&
[CardType.STATS, CardType.TOP_LANGS, CardType.WAKATIME].includes(
selectedCard as never,
)) ||
(theme === "default_repocard" &&
[CardType.PIN, CardType.GIST].includes(selectedCard as never))
)
) {
themeSuffix += `&theme=${theme}`;
}
const themeBuilder = useMemo(
() => (isDefaultTheme ? cardBuilder : cardBuilder.theme(theme)),
[cardBuilder, isDefaultTheme, theme],
);
// for stage five
const [gistUrl, setGistUrl] = useState("");
const guestHint = useMemo(() => {
switch (selectedCard) {
case CardType.STATS:
case CardType.TOP_LANGS:
return `username "${DEMO_USER}"`;
case CardType.PIN:
return `repo "${DEMO_REPO}"`;
case CardType.GIST:
return `Gist ID "${DEMO_GIST}"`;
case CardType.WAKATIME:
return `WakaTime username "${DEMO_WAKATIME_USER}"`;
default:
selectedCard satisfies never;
return "";
}
}, [selectedCard]);
useEffect(() => {
async function fetchGistURL() {
try {
const fullUrl = `https://api.github.com/gists/${gist}`;
const result = await axios.get<{ html_url: string }>(fullUrl);
setGistUrl(result.data.html_url);
} catch (error) {
console.error(error);
}
}
void fetchGistURL();
}, [gist]);
const cardDescriptor = useCardDescriptor({
selectedCard,
themeBuilder,
repo: cardOptions.repo,
userId,
wakatimeUser: cardOptions.wakatimeUser,
gist: cardOptions.gist,
});
const contentSectionRef = useRef<HTMLDivElement | null>(null);
@@ -194,31 +147,31 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
}, [stage]);
useEffect(() => {
async function redirectCode() {
// After requesting GitHub access, GitHub redirects back to your app with a code parameter
const url = window.location.href;
// After requesting GitHub access, GitHub redirects back to the app with a
// code parameter appended to the redirect URI (which carries mode=private|public).
const url = new URL(window.location.href);
const code = url.searchParams.get("code");
if (code === null) {
return;
}
// If GitHub API returns the code parameter
if (url.includes("code=")) {
const tempPrivateAccess = url.includes("private");
const newUrl = url.split("code=", 2) as [string, string];
const redirect = `${url.split(HOST)[0] as string}${HOST}/frontend`;
window.history.pushState({}, "", redirect);
setIsLoading(true);
const tempPrivateAccess = url.searchParams.get("mode") === "private";
window.history.pushState({}, "", `${url.origin}/frontend`);
async function exchangeCode(code: string) {
setIsLoading(true);
try {
const userKey = uuidv4();
const newUserId = await authenticate(
newUrl[1],
tempPrivateAccess,
userKey,
);
const newUserId = await authenticate(code, tempPrivateAccess, userKey);
dispatch(login({ userId: newUserId, userKey }));
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
}
void redirectCode();
void exchangeCode(code);
}, [dispatch]);
if (isLoading) {
@@ -230,11 +183,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
}
return (
<div ref={contentSectionRef} className="h-full px-2 lg:px-8 text-gray-600">
<div
ref={contentSectionRef}
className="h-full px-2 lg:px-8 text-base-content/70"
>
<div className="flex flex-col">
<div className="m-4 rounded-sm">
<div className="lg:p-4">
<h1 className="text-2xl text-gray-600 font-semibold">
<h1 className="text-2xl text-base-content/70 font-semibold">
{STAGE_LABELS[stage].title}
</h1>
<div>
@@ -245,7 +201,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
<a
href={`https://github.com/${userId}`}
target="_blank"
className="text-blue-500 hover:underline font-semibold"
className="text-primary hover:underline font-semibold"
>
{userId}
</a>
@@ -266,13 +222,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
</p>
</div>
) : (
[
"",
"You will be able to customize your card in future steps.",
"",
"",
"Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!",
][stage]
STAGE_LABELS[stage].description
)}
</div>
</div>
@@ -292,49 +242,15 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
{stage === 2 && (
<CustomizeStage
selectedCard={selectedCard}
selectedStatsRank={selectedStatsRank}
setSelectedStatsRank={setSelectedStatsRank}
selectedLanguagesLayout={selectedLanguagesLayout}
setSelectedLanguagesLayout={setSelectedLanguagesLayout}
selectedWakatimeLayout={selectedWakatimeLayout}
setSelectedWakatimeLayout={setSelectedWakatimeLayout}
showTitle={showTitle}
setShowTitle={setShowTitle}
showOwner={showOwner}
setShowOwner={setShowOwner}
descriptionLines={descriptionLines}
setDescriptionLines={setDescriptionLines}
customTitle={customTitle}
setCustomTitle={setCustomTitle}
langsCount={langsCount}
setLangsCount={setLangsCount}
hideValues={hideValues}
setHideValues={setHideValues}
showAllStats={showAllStats}
setShowAllStats={setShowAllStats}
showIcons={showIcons}
setShowIcons={setShowIcons}
includeAllCommits={includeAllCommits}
setIncludeAllCommits={setIncludeAllCommits}
enableAnimations={enableAnimations}
setEnableAnimations={setEnableAnimations}
selectedUserId={selectedUserId}
setSelectedUserId={setSelectedUserId}
repo={repo}
setRepo={setRepo}
gist={gist}
setGist={setGist}
wakatimeUser={wakatimeUser}
setWakatimeUser={setWakatimeUser}
usePercent={usePercent}
setUsePercent={setUsePercent}
fullSuffix={fullSuffix}
options={cardOptions}
onOptionChange={setCardOption}
card={customizeCardBuilder}
setStage={setStage}
/>
)}
{stage === 3 && (
<ThemeStage
fullSuffix={fullSuffix}
card={cardBuilder}
theme={theme}
onThemeChange={(theme) => {
setTheme(theme);
@@ -344,49 +260,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
)}
{stage === 4 && (
<DisplayStage
filename={(() => {
switch (selectedCard) {
case CardType.STATS:
case CardType.TOP_LANGS:
return `${selectedUserId}_card`;
case CardType.PIN:
return `${repo}_card`;
case CardType.GIST:
return `gist_card`;
case CardType.WAKATIME:
return `${wakatimeUser}_card`;
default:
selectedCard satisfies never;
return "";
}
})()}
link={(() => {
switch (selectedCard) {
case CardType.STATS:
case CardType.TOP_LANGS:
return `https://${HOST}/api${themeSuffix}`;
case CardType.PIN: {
let myRepo = repo;
if (!myRepo.includes("/")) {
myRepo = `${userId}/${myRepo}`;
}
return `https://github.com/${myRepo}`;
}
case CardType.GIST:
return gistUrl;
case CardType.WAKATIME:
return `https://wakatime.com/@${wakatimeUser}`;
default:
selectedCard satisfies never;
return "";
}
})()}
themeSuffix={themeSuffix}
filename={cardBuilder.filename()}
link={cardDescriptor.link}
theme={theme}
card={themeBuilder}
guestHint={
isAuthenticated
? null
: `Replace the sample ${guestHint} with your own after copying your Markdown or URL!`
: `Replace the sample ${cardDescriptor.guestHint} with your own after copying your Markdown or URL!`
}
/>
)}
@@ -5,12 +5,15 @@ import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/Stat
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
import { CardType } from "../../models/CardType";
import { getFullSuffix } from "./getFullSuffix";
import { buildCardUrl } from "./buildCardUrl";
import type { CardOptions } from "./cardOptions";
const baseOptions = {
selectedCard: CardType.STATS,
const USER_ID = "john-github";
// Built inline instead of via `getDefaultCardOptions`, which imports
// `constants.ts` and needs a `window` global these node-based tests lack.
const baseOptions: CardOptions = {
selectedUserId: "john",
userId: "john-github",
repo: "repo1",
gist: "gist1",
wakatimeUser: "wakaUser",
@@ -30,15 +33,15 @@ const baseOptions = {
usePercent: false,
};
describe("getFullSuffix", () => {
describe("buildCardUrl", () => {
it("builds stats suffix with defaults", () => {
const result = getFullSuffix(baseOptions);
const result = buildCardUrl(USER_ID, CardType.STATS, baseOptions);
expect(result).toBe("?username=john");
expect(result.toString()).toBe("?username=john");
});
it("adds stats options", () => {
const result = getFullSuffix({
const result = buildCardUrl(USER_ID, CardType.STATS, {
...baseOptions,
showIcons: true,
includeAllCommits: true,
@@ -46,73 +49,69 @@ describe("getFullSuffix", () => {
showTitle: false,
});
expect(result).toBe(
expect(result.toString()).toBe(
"?username=john" +
"&hide_title=true" +
"&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented" +
// commas are percent-encoded (%2C) now that params go through URLSearchParams
"&show=reviews%2Cdiscussions_started%2Cdiscussions_answered%2Cprs_merged%2Cprs_merged_percentage%2Cprs_commented%2Cprs_reviewed%2Cissues_commented" +
"&show_icons=true" +
"&include_all_commits=true",
);
});
it("builds top-langs suffix", () => {
const result = getFullSuffix({
const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
...baseOptions,
selectedCard: CardType.TOP_LANGS,
langsCount: 5,
showTitle: false,
});
expect(result).toBe(
expect(result.toString()).toBe(
"/top-langs?username=john&hide_title=true&langs_count=5",
);
});
it("builds pin suffix using userId not selectedUserId", () => {
const result = getFullSuffix({
const result = buildCardUrl(USER_ID, CardType.PIN, {
...baseOptions,
selectedCard: CardType.PIN,
showOwner: true,
descriptionLines: 3,
});
expect(result).toBe(
expect(result.toString()).toBe(
"/pin?username=john-github&repo=repo1&show_owner=true&description_lines_count=3",
);
});
it("builds gist suffix", () => {
const result = getFullSuffix({
const result = buildCardUrl(USER_ID, CardType.GIST, {
...baseOptions,
selectedCard: CardType.GIST,
showOwner: true,
});
expect(result).toBe("/gist?id=gist1&show_owner=true");
expect(result.toString()).toBe("/gist?id=gist1&show_owner=true");
});
it("builds wakatime suffix with percent and custom title", () => {
const result = getFullSuffix({
const result = buildCardUrl(USER_ID, CardType.WAKATIME, {
...baseOptions,
selectedCard: CardType.WAKATIME,
wakatimeUser: "waka",
usePercent: true,
customTitle: "My Stats",
showTitle: false,
});
expect(result).toBe(
expect(result.toString()).toBe(
"/wakatime?username=waka&hide_title=true&custom_title=My%20Stats&display_format=percent",
);
});
it("adds non-default layouts", () => {
const result = getFullSuffix({
const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
...baseOptions,
selectedCard: CardType.TOP_LANGS,
selectedLanguagesLayout: { id: 2, value: "compact", label: "Compact" },
});
expect(result).toBe("/top-langs?username=john&layout=compact");
expect(result.toString()).toBe("/top-langs?username=john&layout=compact");
});
});
@@ -0,0 +1,175 @@
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
import { CardType } from "../../models/CardType";
import { cardUrl } from "../../models/CardUrl";
import type { CardUrlBuilder } from "../../models/CardUrl";
import type { CardOptions } from "./cardOptions";
export function buildCardUrl(
userId: string,
selectedCard: CardType,
options: CardOptions,
): CardUrlBuilder {
const {
selectedUserId,
repo,
gist,
wakatimeUser,
selectedStatsRank,
selectedLanguagesLayout,
selectedWakatimeLayout,
showTitle,
showOwner,
descriptionLines,
customTitle,
langsCount,
hideValues,
showAllStats,
showIcons,
includeAllCommits,
enableAnimations,
usePercent,
} = options;
switch (selectedCard) {
case CardType.STATS: {
let url = cardUrl(CardType.STATS);
if (selectedUserId) {
url = url.username(selectedUserId);
}
if (selectedStatsRank !== STATS_DEFAULT_RANK) {
if (selectedStatsRank.value === "hide_rank") {
url = url.hideRank();
} else {
url = url.rankIcon(selectedStatsRank.value);
}
}
if (!showTitle) {
url = url.hideTitle();
}
if (customTitle) {
url = url.customTitle(customTitle);
}
if (showAllStats) {
url = url.show(
"reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented",
);
}
if (showIcons) {
url = url.showIcons();
}
if (includeAllCommits) {
url = url.includeAllCommits();
}
if (!enableAnimations) {
url = url.disableAnimations();
}
return url;
}
case CardType.TOP_LANGS: {
let url = cardUrl(CardType.TOP_LANGS);
if (selectedUserId) {
url = url.username(selectedUserId);
}
if (selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT) {
if (selectedLanguagesLayout.value === "hide_progress") {
url = url.hideProgress();
} else {
url = url.layout(selectedLanguagesLayout.value);
}
}
if (!showTitle) {
url = url.hideTitle();
}
if (langsCount) {
url = url.langsCount(langsCount);
}
if (hideValues) {
url = url.hideValues();
}
if (!enableAnimations) {
url = url.disableAnimations();
}
return url;
}
case CardType.PIN: {
/**
* We should use the name of the logged-in user, not the value entered in the
* username field in step 3.
*
* This input is not shown when the PIN card type is selected,
* but it may still contain a different value if the user previously chose another card type.
*
* For example,
* 1. the user could select the STATS card
* 2. enter a username
* 3. then go back and switch to the PIN card, leaving the old value behind.
*
* @see https://github.com/stats-organization/github-stats-extended/pull/73#discussion_r2792177515
*/
let url = cardUrl(CardType.PIN);
if (userId) {
url = url.username(userId);
}
if (repo) {
url = url.repo(repo);
}
if (showOwner) {
url = url.showOwner();
}
if (descriptionLines) {
url = url.descriptionLines(descriptionLines);
}
return url;
}
case CardType.GIST: {
let url = cardUrl(CardType.GIST);
if (gist) {
url = url.gistId(gist);
}
if (showOwner) {
url = url.showOwner();
}
return url;
}
case CardType.WAKATIME: {
let url = cardUrl(CardType.WAKATIME);
if (wakatimeUser) {
url = url.username(wakatimeUser);
}
if (selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT) {
if (selectedWakatimeLayout.value === "hide_progress") {
url = url.hideProgress().cardWidth(315);
} else {
url = url.layout(selectedWakatimeLayout.value);
}
}
if (!showTitle) {
url = url.hideTitle();
}
if (customTitle) {
url = url.customTitle(customTitle);
}
if (langsCount) {
url = url.langsCount(langsCount);
}
if (!enableAnimations) {
url = url.disableAnimations();
}
if (usePercent) {
url = url.displayFormat("percent");
}
return url;
}
default:
selectedCard satisfies never;
throw new Error(`unknown card type: ${selectedCard as string}`);
}
}
@@ -0,0 +1,53 @@
import type { SelectOption } from "../../components/Generic/Select";
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
import { DEMO_GIST, DEMO_REPO, DEMO_WAKATIME_USER } from "../../constants";
/**
* All user-tunable card parameters collected during the customize stage.
* Held as a single state object in `HomeScreen` and updated one key at a time.
*/
export interface CardOptions {
selectedUserId: string;
repo: string;
gist: string;
wakatimeUser: string;
selectedStatsRank: SelectOption;
selectedLanguagesLayout: SelectOption;
selectedWakatimeLayout: SelectOption;
showTitle: boolean;
showOwner: boolean;
descriptionLines: number | undefined;
customTitle: string;
langsCount: number | undefined;
hideValues: boolean;
showAllStats: boolean;
showIcons: boolean;
includeAllCommits: boolean;
enableAnimations: boolean;
usePercent: boolean;
}
export function getDefaultCardOptions(userId: string): CardOptions {
return {
selectedUserId: userId,
repo: DEMO_REPO,
gist: DEMO_GIST,
wakatimeUser: DEMO_WAKATIME_USER,
selectedStatsRank: STATS_DEFAULT_RANK,
selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT,
selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT,
showTitle: true,
showOwner: false,
descriptionLines: undefined,
customTitle: "",
langsCount: undefined,
hideValues: false,
showAllStats: false,
showIcons: false,
includeAllCommits: true,
enableAnimations: true,
usePercent: false,
};
}
@@ -1,173 +0,0 @@
import type { SelectOption } from "../../components/Generic/Select";
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
import { CardType } from "../../models/CardType";
interface Options {
userId: string;
selectedUserId: string;
selectedCard: CardType;
repo: string;
gist: string;
wakatimeUser: string;
selectedStatsRank: SelectOption;
selectedLanguagesLayout: SelectOption;
selectedWakatimeLayout: SelectOption;
showTitle: boolean;
showOwner: boolean;
descriptionLines: number | undefined;
customTitle: string;
langsCount: number | undefined;
hideValues: boolean;
showAllStats: boolean;
showIcons: boolean;
includeAllCommits: boolean;
enableAnimations: boolean;
usePercent: boolean;
}
export function getFullSuffix({
userId,
selectedCard,
selectedUserId,
repo,
gist,
wakatimeUser,
selectedStatsRank,
selectedLanguagesLayout,
selectedWakatimeLayout,
showTitle,
showOwner,
descriptionLines,
customTitle,
langsCount,
hideValues,
showAllStats,
showIcons,
includeAllCommits,
enableAnimations,
usePercent,
}: Options): string {
let fullSuffix = `${selectedCard === CardType.STATS ? "" : "/" + selectedCard}?`;
switch (selectedCard) {
case CardType.STATS:
case CardType.TOP_LANGS:
fullSuffix += `username=${selectedUserId}`;
break;
case CardType.PIN:
/**
* We should use the name of the logged-in user, not the value entered in the
* username field in step 3.
*
* This input is not shown when the PIN card type is selected,
* but it may still contain a different value if the user previously chose another card type.
*
* For example,
* 1. the user could select the STATS card
* 2. enter a username
* 3. then go back and switch to the PIN card, leaving the old value behind.
*
* @see https://github.com/stats-organization/github-stats-extended/pull/73#discussion_r2792177515
*/
fullSuffix += `username=${userId}&repo=${repo}`;
break;
case CardType.GIST:
fullSuffix += `id=${gist}`;
break;
case CardType.WAKATIME:
fullSuffix += `username=${wakatimeUser}`;
break;
default:
selectedCard satisfies never;
}
if (
selectedStatsRank !== STATS_DEFAULT_RANK &&
selectedCard === CardType.STATS
) {
fullSuffix += `&rank_icon=${selectedStatsRank.value}`;
}
if (
selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT &&
selectedCard === CardType.TOP_LANGS
) {
fullSuffix += `&layout=${selectedLanguagesLayout.value}`;
}
if (
selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT &&
selectedCard === CardType.WAKATIME
) {
fullSuffix += `&layout=${selectedWakatimeLayout.value}`;
}
if (
!showTitle &&
(selectedCard === CardType.STATS ||
selectedCard === CardType.TOP_LANGS ||
selectedCard === CardType.WAKATIME)
) {
fullSuffix += "&hide_title=true";
}
if (
showOwner &&
(selectedCard === CardType.PIN || selectedCard === CardType.GIST)
) {
fullSuffix += "&show_owner=true";
}
if (descriptionLines && selectedCard === CardType.PIN) {
fullSuffix += `&description_lines_count=${descriptionLines}`;
}
if (
customTitle &&
(selectedCard === CardType.STATS || selectedCard === CardType.WAKATIME)
) {
const encodedTitle = encodeURIComponent(customTitle);
fullSuffix += `&custom_title=${encodedTitle}`;
}
if (
langsCount &&
(selectedCard === CardType.TOP_LANGS || selectedCard === CardType.WAKATIME)
) {
fullSuffix += `&langs_count=${langsCount}`;
}
if (hideValues && selectedCard === CardType.TOP_LANGS) {
fullSuffix += `&hide_values=true`;
}
if (showAllStats && selectedCard === CardType.STATS) {
fullSuffix += `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented`;
}
if (showIcons && selectedCard === CardType.STATS) {
fullSuffix += `&show_icons=true`;
}
if (includeAllCommits && selectedCard === CardType.STATS) {
fullSuffix += `&include_all_commits=true`;
}
if (
!enableAnimations &&
(selectedCard === CardType.STATS ||
selectedCard === CardType.TOP_LANGS ||
selectedCard === CardType.WAKATIME)
) {
fullSuffix += `&disable_animations=${!enableAnimations}`;
}
if (usePercent && selectedCard === CardType.WAKATIME) {
fullSuffix += `&display_format=percent`;
}
return fullSuffix;
}
+121 -130
View File
@@ -1,7 +1,6 @@
import type { JSX, default as React } from "react";
import type { JSX } from "react";
import { CardImage } from "../../../components/Card/CardImage";
import type { SelectOption } from "../../../components/Generic/Select";
import { CheckboxSection } from "../../../components/Home/CheckboxSection";
import { LanguagesLayoutSection } from "../../../components/Home/LanguagesLayoutSection";
import { NumericSection } from "../../../components/Home/NumericSection";
@@ -15,101 +14,72 @@ import {
DEMO_WAKATIME_USER,
} from "../../../constants";
import { CardType } from "../../../models/CardType";
import type { CardUrlBuilder } from "../../../models/CardUrl";
import type { StageIndex } from "../../../models/Stage";
import { useIsAuthenticated } from "../../../redux/selectors/userSelectors";
import type { CardOptions } from "../cardOptions";
type Updater<T> = React.Dispatch<React.SetStateAction<T>>;
/**
* Extract the trailing path segments from pasted text, so pasting a full
* GitHub URL yields just the username (1 segment), owner/repo (2), or Gist id (1).
*/
function pastedPathTail(text: string, segments: number): string {
let value = text;
if (value.endsWith("/")) {
value = value.slice(0, -1);
}
const parts = value.split("/");
if (parts.length > segments) {
value = parts.slice(-segments).join("/");
}
return value;
}
/** @todo todo consider using React context API to avoid prop drilling */
interface CustomizeStageProps {
selectedCard: CardType;
selectedStatsRank: SelectOption;
setSelectedStatsRank: Updater<SelectOption>;
selectedLanguagesLayout: SelectOption;
setSelectedLanguagesLayout: Updater<SelectOption>;
selectedWakatimeLayout: SelectOption;
setSelectedWakatimeLayout: Updater<SelectOption>;
selectedUserId: string;
setSelectedUserId: Updater<string>;
repo: string;
setRepo: Updater<string>;
gist: string;
setGist: Updater<string>;
wakatimeUser: string;
setWakatimeUser: Updater<string>;
showTitle: boolean;
setShowTitle: Updater<boolean>;
descriptionLines: number | undefined;
setDescriptionLines: Updater<number | undefined>;
showOwner: boolean;
setShowOwner: Updater<boolean>;
customTitle: string;
setCustomTitle: Updater<string>;
langsCount: number | undefined;
setLangsCount: Updater<number | undefined>;
hideValues: boolean;
setHideValues: Updater<boolean>;
showIcons: boolean;
setShowIcons: Updater<boolean>;
showAllStats: boolean;
setShowAllStats: Updater<boolean>;
includeAllCommits: boolean;
setIncludeAllCommits: Updater<boolean>;
enableAnimations: boolean;
setEnableAnimations: Updater<boolean>;
usePercent: boolean;
setUsePercent: Updater<boolean>;
fullSuffix: string;
options: CardOptions;
onOptionChange: <K extends keyof CardOptions>(
key: K,
value: CardOptions[K],
) => void;
card: CardUrlBuilder;
setStage: (stageIndex: StageIndex) => void;
}
export function CustomizeStage({
selectedCard,
selectedStatsRank,
setSelectedStatsRank,
selectedLanguagesLayout,
setSelectedLanguagesLayout,
selectedWakatimeLayout,
setSelectedWakatimeLayout,
selectedUserId,
setSelectedUserId,
repo,
setRepo,
gist,
setGist,
wakatimeUser,
setWakatimeUser,
showTitle,
setShowTitle,
showOwner,
setShowOwner,
descriptionLines,
setDescriptionLines,
customTitle,
setCustomTitle,
langsCount,
setLangsCount,
hideValues,
setHideValues,
showIcons,
setShowIcons,
showAllStats,
setShowAllStats,
includeAllCommits,
setIncludeAllCommits,
enableAnimations,
setEnableAnimations,
usePercent,
setUsePercent,
fullSuffix,
options,
onOptionChange,
card,
setStage,
}: CustomizeStageProps): JSX.Element {
const cardType = selectedCard;
const isAuthenticated = useIsAuthenticated();
const {
selectedUserId,
repo,
gist,
wakatimeUser,
selectedStatsRank,
selectedLanguagesLayout,
selectedWakatimeLayout,
showTitle,
showOwner,
descriptionLines,
customTitle,
langsCount,
hideValues,
showAllStats,
showIcons,
includeAllCommits,
enableAnimations,
usePercent,
} = options;
return (
<div className="w-full flex flex-wrap">
<div className="h-auto lg:w-2/5 md:w-1/2 p-10 rounded-sm bg-gray-200">
<div className="h-auto lg:w-2/5 md:w-1/2 p-10 rounded-sm bg-base-200">
{(cardType === CardType.STATS || cardType === CardType.TOP_LANGS) && (
<TextSection
title="Username"
@@ -126,7 +96,7 @@ export function CustomizeStage({
e.preventDefault();
setStage(0);
}}
className="underline text-blue-900"
className="underline text-primary"
>
log in
</a>{" "}
@@ -137,19 +107,16 @@ export function CustomizeStage({
}
placeholder={`e.g. "${DEMO_USER}"`}
value={selectedUserId}
onValueChange={setSelectedUserId}
onValueChange={(value) => {
onOptionChange("selectedUserId", value);
}}
onPaste={(e) => {
e.preventDefault();
let newValue = e.clipboardData.getData("text");
// if the user pasted a full GitHub URL, extract username
if (newValue.endsWith("/")) {
newValue = newValue.slice(0, -1);
}
const parts = newValue.split("/");
if (parts.length > 1) {
newValue = parts.slice(-1).join("/");
}
setSelectedUserId(newValue);
onOptionChange(
"selectedUserId",
pastedPathTail(e.clipboardData.getData("text"), 1),
);
}}
disabled={!isAuthenticated}
/>
@@ -170,7 +137,7 @@ export function CustomizeStage({
e.preventDefault();
setStage(0);
}}
className="underline text-blue-900"
className="underline text-primary"
>
log in
</a>{" "}
@@ -181,19 +148,16 @@ export function CustomizeStage({
}
placeholder={`e.g. "${DEMO_REPO}"`}
value={repo}
onValueChange={setRepo}
onValueChange={(value) => {
onOptionChange("repo", value);
}}
onPaste={(e) => {
e.preventDefault();
let newValue = e.clipboardData.getData("text");
// if the user pasted a full GitHub URL, extract owner/repo
if (newValue.endsWith("/")) {
newValue = newValue.slice(0, -1);
}
const parts = newValue.split("/");
if (parts.length > 2) {
newValue = parts.slice(-2).join("/");
}
setRepo(newValue);
onOptionChange(
"repo",
pastedPathTail(e.clipboardData.getData("text"), 2),
);
}}
disabled={!isAuthenticated}
/>
@@ -214,7 +178,7 @@ export function CustomizeStage({
e.preventDefault();
setStage(0);
}}
className="underline text-blue-900"
className="underline text-primary"
>
log in
</a>{" "}
@@ -225,19 +189,16 @@ export function CustomizeStage({
}
placeholder={`e.g. "${DEMO_GIST}"`}
value={gist}
onValueChange={setGist}
onValueChange={(value) => {
onOptionChange("gist", value);
}}
onPaste={(e) => {
e.preventDefault();
let newValue = e.clipboardData.getData("text");
// if the user pasted a full GitHub URL, extract Gist ID
if (newValue.endsWith("/")) {
newValue = newValue.slice(0, -1);
}
const parts = newValue.split("/");
if (parts.length > 1) {
newValue = parts.slice(-1).join("/");
}
setGist(newValue);
onOptionChange(
"gist",
pastedPathTail(e.clipboardData.getData("text"), 1),
);
}}
disabled={!isAuthenticated}
/>
@@ -251,7 +212,7 @@ export function CustomizeStage({
<a
href="https://wakatime.com/"
target="_blank"
className="underline text-blue-900"
className="underline text-primary"
>
WakaTime
</a>{" "}
@@ -260,7 +221,9 @@ export function CustomizeStage({
}
placeholder={`e.g. "${DEMO_WAKATIME_USER}"`}
value={wakatimeUser}
onValueChange={setWakatimeUser}
onValueChange={(value) => {
onOptionChange("wakatimeUser", value);
}}
/>
)}
{cardType === CardType.STATS && (
@@ -269,13 +232,17 @@ export function CustomizeStage({
text="Show all available statistics."
question="Show all stats?"
checked={showAllStats}
onCheckedChange={setShowAllStats}
onCheckedChange={(checked) => {
onOptionChange("showAllStats", checked);
}}
/>
)}
{cardType === CardType.STATS && (
<StatsRankSection
selectedOption={selectedStatsRank}
onOptionChange={setSelectedStatsRank}
onOptionChange={(option) => {
onOptionChange("selectedStatsRank", option);
}}
/>
)}
{cardType === CardType.STATS && (
@@ -284,7 +251,9 @@ export function CustomizeStage({
text="Show icons next to all stats."
question="Show icons?"
checked={showIcons}
onCheckedChange={setShowIcons}
onCheckedChange={(checked) => {
onOptionChange("showIcons", checked);
}}
/>
)}
{cardType === CardType.STATS && (
@@ -293,19 +262,25 @@ export function CustomizeStage({
text="Count total commits or just commits of the last 365 days."
question="Include all commits?"
checked={includeAllCommits}
onCheckedChange={setIncludeAllCommits}
onCheckedChange={(checked) => {
onOptionChange("includeAllCommits", checked);
}}
/>
)}
{cardType === CardType.TOP_LANGS && (
<LanguagesLayoutSection
selectedLanguageLayoutOption={selectedLanguagesLayout}
onLanguageLayoutOptionChange={setSelectedLanguagesLayout}
onLanguageLayoutOptionChange={(option) => {
onOptionChange("selectedLanguagesLayout", option);
}}
/>
)}
{cardType === CardType.WAKATIME && (
<WakatimeLayoutSection
selectedOption={selectedWakatimeLayout}
onOptionChange={setSelectedWakatimeLayout}
onOptionChange={(option) => {
onOptionChange("selectedWakatimeLayout", option);
}}
/>
)}
{(cardType === CardType.TOP_LANGS ||
@@ -320,7 +295,9 @@ export function CustomizeStage({
</>
}
value={langsCount}
onValueChange={setLangsCount}
onValueChange={(value) => {
onOptionChange("langsCount", value);
}}
min={1}
max={20}
/>
@@ -331,7 +308,9 @@ export function CustomizeStage({
text="Hide language percentages or bytes while keeping the selected layout visible."
question="Hide values?"
checked={hideValues}
onCheckedChange={setHideValues}
onCheckedChange={(checked) => {
onOptionChange("hideValues", checked);
}}
/>
)}
{cardType === CardType.WAKATIME && (
@@ -340,7 +319,9 @@ export function CustomizeStage({
text="Show time spent in hours or percentages."
question="Show percentages?"
checked={usePercent}
onCheckedChange={setUsePercent}
onCheckedChange={(checked) => {
onOptionChange("usePercent", checked);
}}
/>
)}
{(cardType === CardType.STATS ||
@@ -351,7 +332,9 @@ export function CustomizeStage({
text="Shows a title at the top of the card."
question="Show title?"
checked={showTitle}
onCheckedChange={setShowTitle}
onCheckedChange={(checked) => {
onOptionChange("showTitle", checked);
}}
/>
)}
{(cardType === CardType.STATS || cardType === CardType.WAKATIME) && (
@@ -366,7 +349,9 @@ export function CustomizeStage({
}
placeholder='e.g. "My GitHub Stats"'
value={customTitle}
onValueChange={setCustomTitle}
onValueChange={(value) => {
onOptionChange("customTitle", value);
}}
/>
)}
{(cardType === CardType.STATS ||
@@ -377,7 +362,9 @@ export function CustomizeStage({
// text="Enable Animations."
question="enable animations?"
checked={enableAnimations}
onCheckedChange={setEnableAnimations}
onCheckedChange={(checked) => {
onOptionChange("enableAnimations", checked);
}}
/>
)}
{(cardType === CardType.PIN || cardType === CardType.GIST) && (
@@ -386,7 +373,9 @@ export function CustomizeStage({
text="Shows the repo owner's name next to the repo name."
question="Show owner?"
checked={showOwner}
onCheckedChange={setShowOwner}
onCheckedChange={(checked) => {
onOptionChange("showOwner", checked);
}}
/>
)}
{cardType === CardType.PIN && (
@@ -401,7 +390,9 @@ export function CustomizeStage({
</>
}
value={descriptionLines}
onValueChange={setDescriptionLines}
onValueChange={(value) => {
onOptionChange("descriptionLines", value);
}}
min={1}
max={3}
/>
@@ -411,7 +402,7 @@ export function CustomizeStage({
<a
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md"
target="_blank"
className="underline text-blue-900"
className="underline text-primary"
>
customization documentation
</a>{" "}
@@ -420,7 +411,7 @@ export function CustomizeStage({
</div>
<div className="w-full lg:w-3/5 md:w-1/2 object-center pt-5 md:pt-0 pl-0 md:pl-5 lg:pl-0">
<div className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32">
<CardImage imageSrc={fullSuffix} stage={2} />
<CardImage card={card} stage={2} />
</div>
</div>
</div>
+20 -12
View File
@@ -1,25 +1,31 @@
import { clsx } from "clsx";
import type { JSX } from "react";
import { toast } from "react-toastify";
import { saveSvgAsPng } from "save-svg-as-png";
import { CardImage } from "../../../components/Card/CardImage";
import { getCardThemeBackdrop } from "../../../components/Card/themeBackdrop";
import { Button } from "../../../components/Generic/Button";
import { HOST } from "../../../constants";
import type { CardUrlBuilder } from "../../../models/CardUrl";
import { useTheme } from "../../../redux/selectors/themeSelectors";
interface DisplayStageProps {
filename: string;
link: string;
themeSuffix: string;
theme: string;
card: CardUrlBuilder;
guestHint: string | null;
}
export function DisplayStage({
filename,
link,
themeSuffix,
theme,
card,
guestHint,
}: DisplayStageProps): JSX.Element {
const { isDark } = useTheme();
const downloadPNG = () => {
saveSvgAsPng(
document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild
@@ -34,7 +40,7 @@ export function DisplayStage({
const copyMarkdown = () => {
void navigator.clipboard.writeText(
`[![GitHub Stats](https://${HOST}/api${themeSuffix})](${link})`,
`[![GitHub Stats](${card.toApiUrl(HOST)})](${link})`,
);
toast.info("Copied to Clipboard!", {
position: "bottom-right",
@@ -47,7 +53,7 @@ export function DisplayStage({
};
const copyUrl = () => {
void navigator.clipboard.writeText(`https://${HOST}/api${themeSuffix}`);
void navigator.clipboard.writeText(card.toApiUrl(HOST));
toast.info("Copied to Clipboard!", {
position: "bottom-right",
autoClose: 1500,
@@ -61,7 +67,7 @@ export function DisplayStage({
return (
<div className="w-full flex flex-wrap">
<div className="h-auto lg:w-2/5 md:w-1/2">
<div className="p-10 rounded-sm bg-gray-200">
<div className="p-10 rounded-sm bg-base-200">
<div className="flex flex-col items-center">
{[
{
@@ -82,10 +88,8 @@ export function DisplayStage({
].map((item) => (
<Button
key={item.title}
className={clsx("m-4 w-60 flex justify-center", {
"bg-blue-500 hover:bg-blue-600 text-white": item.highlight,
"bg-white hover:bg-gray-100 text-black": !item.highlight,
})}
variant={item.highlight ? "primary" : "soft"}
className="m-4 w-60 flex justify-center"
onClick={item.onClick}
>
{item.title}
@@ -96,10 +100,14 @@ export function DisplayStage({
</div>
</div>
<div className="w-full lg:w-3/5 md:w-1/2 object-center pt-5 md:pt-0 pl-0 md:pl-5 lg:pl-0">
<div className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32">
<div
className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32 rounded p-4"
style={{ background: getCardThemeBackdrop(theme, isDark) }}
>
<CardImage
imageSrc={`${themeSuffix}&disable_animations=true`}
card={card.disableAnimations()}
stage={4}
className="flex justify-center"
/>
</div>
</div>
@@ -45,7 +45,7 @@ export function LoginAccountDeleteModal(
<div className="fixed left-0 top-0 w-full h-full">
<div className="w-full h-full flex justify-center items-center">
<div
className="w-96 p-4 bg-white rounded-sm border-2 border-gray-200 text-gray-700"
className="w-96 p-4 bg-base-100 rounded-sm border-2 border-base-300 text-base-content"
ref={wrapperRef}
>
<p className="mb-1 text-2xl">Delete Account</p>
@@ -57,16 +57,10 @@ export function LoginAccountDeleteModal(
</p>
<br />
<div className="flex flex-wrap">
<Button
className="bg-blue-500 hover:bg-blue-600 text-white rounded-[0.25rem]"
onClick={onClose}
>
<Button variant="primary" onClick={onClose}>
Cancel
</Button>
<Button
className="bg-gray-200 hover:bg-gray-300 ml-auto rounded-[0.25rem] text-red-600 border-2"
onClick={onConfirm}
>
<Button variant="error" className="ml-auto" onClick={onConfirm}>
Delete Account
</Button>
</div>
@@ -56,14 +56,17 @@ export function LoginAccountManagement(): JSX.Element {
<a
href={`https://${HOST}/api/downgrade?user_key=${userKey as string}`}
>
<Button className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100">
<Button
variant="soft"
className="h-12 flex justify-center items-center w-[320px]"
>
<GithubIcon className="w-6 h-6" />
<span className="ml-2 xl:text-lg">
Downgrade to Public Access
</span>
</Button>
</a>
<p className="text-sm text-gray-600 flex-1">
<p className="text-sm text-base-content/70 flex-1">
Switch to public access if you prefer not to share private
contributions.
</p>
@@ -71,14 +74,17 @@ export function LoginAccountManagement(): JSX.Element {
) : (
<div className="flex items-center gap-4">
<a href={GITHUB_PRIVATE_AUTH_URL}>
<Button className="h-12 flex justify-center items-center w-[320px] text-white bg-blue-500 hover:bg-blue-600">
<Button
variant="primary"
className="h-12 flex justify-center items-center w-[320px]"
>
<GithubIcon className="w-6 h-6" />
<span className="ml-2 xl:text-lg">
Upgrade to Private Access
</span>
</Button>
</a>
<p className="text-sm text-gray-600 flex-1">
<p className="text-sm text-base-content/70 flex-1">
Upgrade to include contributions in private repositories for more
complete and accurate stats.
</p>
@@ -89,12 +95,15 @@ export function LoginAccountManagement(): JSX.Element {
{/* Delete Account Button */}
<div className="mt-6 flex items-center gap-4">
<Button
className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100"
variant="soft"
className="h-12 flex justify-center items-center w-[320px]"
onClick={openDeleteModal}
>
<span className="xl:text-lg text-red-600">Delete Account</span>
<span className="xl:text-lg text-error text-shadow-none">
Delete Account
</span>
</Button>
<p className="text-sm text-gray-600 flex-1">
<p className="text-sm text-base-content/70 flex-1">
This will delete your GitHub-Stats-Extended account and then redirect
you to a GitHub screen where you can revoke your access token.
</p>
@@ -103,12 +112,13 @@ export function LoginAccountManagement(): JSX.Element {
{/* Logout Button */}
<div className="mt-6 flex items-center gap-4">
<Button
className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100"
variant="soft"
className="h-12 flex justify-center items-center w-[320px]"
onClick={handleLogout}
>
<span className="xl:text-lg">Log Out</span>
</Button>
<p className="text-sm text-gray-600 flex-1">
<p className="text-sm text-base-content/70 flex-1">
Log out from GitHub-Stats-Extended.
</p>
</div>
@@ -16,7 +16,7 @@ export function LoginBox(props: LoginBoxProps): JSX.Element {
<div className="h-full flex flex-wrap">
<div className={clsx("md:flex", { "opacity-25": isOpaque })}>
<div className="lg:block lg:w-3/5 lg:p-8">
<div className="bg-gray-200 rounded-sm w-full h-full m-auto p-8 shadow lg:h-auto">
<div className="bg-base-300 rounded-sm w-full h-full m-auto p-8 shadow lg:h-auto">
{children}
</div>
</div>
@@ -7,23 +7,26 @@ import {
DEMO_USER,
DEMO_WAKATIME_USER,
} from "../../../../constants";
import { CardType } from "../../../../models/CardType";
import { cardUrl } from "../../../../models/CardUrl";
import { useTheme } from "../../../../redux/selectors/themeSelectors";
const cards: Array<{ demoImageSrc: string }> = [
{
demoImageSrc: `/pin?repo=${DEMO_REPO}&disable_animations=true`,
},
{
demoImageSrc: `/top-langs?username=${DEMO_USER}&langs_count=4&disable_animations=true`,
},
{
demoImageSrc: `?username=${DEMO_USER}&include_all_commits=true&disable_animations=true`,
},
{
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450&disable_animations=true`,
},
{
demoImageSrc: `/gist?id=${DEMO_GIST}&disable_animations=true`,
},
const cards = [
cardUrl(CardType.PIN).repo(DEMO_REPO).disableAnimations(),
cardUrl(CardType.TOP_LANGS)
.username(DEMO_USER)
.langsCount(4)
.disableAnimations(),
cardUrl(CardType.STATS)
.username(DEMO_USER)
.includeAllCommits()
.disableAnimations(),
cardUrl(CardType.WAKATIME)
.username(DEMO_WAKATIME_USER)
.langsCount(6)
.cardWidth(450)
.disableAnimations(),
cardUrl(CardType.GIST).gistId(DEMO_GIST).disableAnimations(),
];
function getCardXPosition(cardIndex: number): number {
@@ -39,22 +42,28 @@ function getCardXPosition(cardIndex: number): number {
}
export function LoginBoxDemoCards(): JSX.Element {
const { isDark } = useTheme();
return (
<div className="w-full h-full lg:w-2/5 flex lg:flex-col lg:p-8 relative overflow-hidden">
<div className="relative w-full h-full">
{cards.map((card, index) => (
<div
key={card.demoImageSrc}
style={{
left: `${getCardXPosition(index)}%`,
position: "relative",
zoom: "0.5",
marginBottom: "1%",
}}
>
<CardImage imageSrc={card.demoImageSrc} compact={false} stage={0} />
</div>
))}
{cards.map((card, index) => {
// Show dark-themed demo cards in dark mode so they fit the surroundings.
const demoCard = isDark ? card.theme("github_dark") : card;
return (
<div
key={demoCard.toString()}
style={{
left: `${getCardXPosition(index)}%`,
position: "relative",
zoom: "0.5",
marginBottom: "1%",
}}
>
<CardImage card={demoCard} compact={false} stage={0} />
</div>
);
})}
</div>
</div>
);
@@ -20,24 +20,30 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
<LoginBox>
<div className="flex items-center gap-4 mb-4">
<a href={GITHUB_PUBLIC_AUTH_URL}>
<Button className="h-12 flex justify-center items-center w-[260px] text-white bg-blue-500 hover:bg-blue-600">
<Button
variant="primary"
className="h-12 flex justify-center items-center w-[260px]"
>
<GithubIcon className="w-6 h-6" />
<span className="ml-2 xl:text-lg">GitHub Public Access</span>
GitHub Public Access
</Button>
</a>
<p className="text-sm text-gray-600 flex-1">
<p className="text-sm text-base-content/80 flex-1">
Generate stats based on your contributions in public repositories.
</p>
</div>
<div className="flex items-center gap-4 mb-4">
<a href={GITHUB_PRIVATE_AUTH_URL}>
<Button className="h-12 flex justify-center items-center w-[260px] text-black border border-black bg-white hover:bg-gray-100">
<Button
variant="soft"
className="h-12 flex justify-center items-center w-[260px]"
>
<GithubIcon className="w-6 h-6" />
<span className="ml-2 xl:text-lg">GitHub Private Access</span>
GitHub Private Access
</Button>
</a>
<p className="text-sm text-gray-600 flex-1">
<p className="text-sm text-base-content/80 flex-1">
Include contributions from private repositories for more complete and
accurate stats.
</p>
@@ -45,12 +51,13 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
<div className="flex items-center gap-4">
<Button
className="h-12 flex justify-center items-center w-[260px] text-black border border-black bg-white hover:bg-gray-100"
variant="soft"
className="h-12 flex justify-center items-center w-[260px]"
onClick={onContinueAsGuest}
>
<span className="ml-2 xl:text-lg">Continue as Guest</span>
Continue as Guest
</Button>
<p className="text-sm text-gray-600 flex-1">
<p className="text-sm text-base-content/80 flex-1">
Explore options using sample data. Insert your own username in the
last step.
</p>
@@ -9,6 +9,9 @@ import {
DEMO_WAKATIME_USER,
} from "../../../constants";
import { CardType } from "../../../models/CardType";
import { cardUrl } from "../../../models/CardUrl";
import type { CardUrlBuilder } from "../../../models/CardUrl";
import { useTheme } from "../../../redux/selectors/themeSelectors";
import { useUserId } from "../../../redux/selectors/userSelectors";
interface SelectCardStageProps {
@@ -21,12 +24,13 @@ export function SelectCardStage({
onCardTypeChange,
}: SelectCardStageProps): JSX.Element {
const userId = useUserId(DEMO_USER);
const { isDark } = useTheme();
const options = useMemo<
Array<{
title: string;
description: string;
demoImageSrc: string;
demoCard: CardUrlBuilder;
cardType: CardType;
}>
>(
@@ -34,33 +38,36 @@ export function SelectCardStage({
{
title: "GitHub Stats Card",
description: "your overall GitHub statistics",
demoImageSrc: `?username=${userId}&include_all_commits=true`,
demoCard: cardUrl(CardType.STATS).username(userId).includeAllCommits(),
cardType: CardType.STATS,
},
{
title: "Top Languages Card",
description: "your most frequently used languages",
demoImageSrc: `/top-langs?username=${userId}&langs_count=4`,
demoCard: cardUrl(CardType.TOP_LANGS).username(userId).langsCount(4),
cardType: CardType.TOP_LANGS,
},
{
title: "GitHub Extra Pin",
description:
"pin more than 6 repositories in your profile using a GitHub profile readme",
demoImageSrc: `/pin?repo=${DEMO_REPO}`,
demoCard: cardUrl(CardType.PIN).repo(DEMO_REPO),
cardType: CardType.PIN,
},
{
title: "GitHub Gist Pin",
description:
"pin gists in your GitHub profile using a GitHub profile readme",
demoImageSrc: `/gist?id=${DEMO_GIST}`,
demoCard: cardUrl(CardType.GIST).gistId(DEMO_GIST),
cardType: CardType.GIST,
},
{
title: "WakaTime Stats Card",
description: "your coding activity from WakaTime",
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450`,
demoCard: cardUrl(CardType.WAKATIME)
.username(DEMO_WAKATIME_USER)
.langsCount(6)
.cardWidth(450),
cardType: CardType.WAKATIME,
},
],
@@ -69,25 +76,31 @@ export function SelectCardStage({
return (
<div className="w-full flex flex-wrap">
{options.map((card) => (
<button
className="p-2 lg:p-4"
key={card.cardType}
type="button"
onClick={() => {
onCardTypeChange(card.cardType);
}}
>
<Card
title={card.title}
description={card.description}
imageSrc={card.demoImageSrc}
selected={selectedCardType === card.cardType}
fixedSize
stage={1}
/>
</button>
))}
{options.map((card) => {
// Show dark-themed demo cards in dark mode so they fit the surroundings.
const demoCard = isDark
? card.demoCard.theme("github_dark")
: card.demoCard;
return (
<button
className="p-2 lg:p-4"
key={card.cardType}
type="button"
onClick={() => {
onCardTypeChange(card.cardType);
}}
>
<Card
title={card.title}
description={card.description}
card={demoCard}
selected={selectedCardType === card.cardType}
fixedSize
stage={1}
/>
</button>
);
})}
</div>
);
}
+45 -27
View File
@@ -2,6 +2,12 @@ import { themes } from "@stats-organization/github-readme-stats-core";
import type { JSX } from "react";
import { Card } from "../../../components/Card/Card";
import {
getCardThemeBackdrop,
getThemeSortRank,
} from "../../../components/Card/themeBackdrop";
import type { CardUrlBuilder } from "../../../models/CardUrl";
import { useTheme } from "../../../redux/selectors/themeSelectors";
const excludedThemes = [
"merko",
@@ -12,53 +18,65 @@ const excludedThemes = [
"holi",
];
const themeList = Object.keys(themes).filter(
(myTheme) => !excludedThemes.includes(myTheme),
);
// Light themes first, adaptive themes in the middle, dark themes last.
const themeList = Object.keys(themes)
.filter((myTheme) => !excludedThemes.includes(myTheme))
.sort((a, b) => getThemeSortRank(a) - getThemeSortRank(b));
interface ThemeStageProps {
fullSuffix: string;
card: CardUrlBuilder;
theme: string;
onThemeChange: (theme: string) => void;
}
export function ThemeStage({
theme,
fullSuffix,
card,
onThemeChange,
}: ThemeStageProps): JSX.Element {
const { isDark } = useTheme();
return (
<>
<div className="flex flex-wrap">
{themeList.map((myTheme) => (
<button
className="p-2 lg:p-4"
key={myTheme}
type="button"
onClick={() => {
onThemeChange(myTheme);
}}
>
<Card
title={myTheme}
description=""
imageSrc={`${fullSuffix}&theme=${myTheme}`}
selected={theme === myTheme}
stage={3}
/>
</button>
))}
{themeList.map((myTheme) => {
const themeColors = themes[myTheme as keyof typeof themes];
return (
<button
className="p-2 lg:p-4"
key={myTheme}
type="button"
onClick={() => {
onThemeChange(myTheme);
}}
>
<Card
title={myTheme}
description=""
card={card.theme(myTheme)}
selected={theme === myTheme}
stage={3}
backgroundColor={getCardThemeBackdrop(myTheme, isDark)}
titleColor={`#${
myTheme === "ambient_gradient" && !isDark
? (themes["ambient_gradient"].bg_color.split(",")[1] ?? "")
: themeColors.title_color
}`}
/>
</button>
);
})}
</div>
<div className="pl-10 pr-10">
For more theme options check the{" "}
{"For more theme options check the "}
<a
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md#themes"
target="_blank"
className="underline text-blue-900"
className="underline text-primary"
>
customization documentation
</a>{" "}
after you copied your card URL in step 5.
</a>
{" after you copied your card URL in step 5."}
</div>
</>
);
@@ -0,0 +1,89 @@
import axios from "axios";
import { useEffect, useMemo, useState } from "react";
import {
DEMO_GIST,
DEMO_REPO,
DEMO_USER,
DEMO_WAKATIME_USER,
HOST,
} from "../../constants";
import { CardType } from "../../models/CardType";
import type { CardUrlBuilder } from "../../models/CardUrl";
/** Per-card metadata for the final display stage. */
export interface CardDescriptor {
/** Sample identity shown to guests (e.g. a sample username or repo). */
guestHint: string;
/** URL the card image links to when clicked. */
link: string;
}
interface UseCardDescriptorOptions {
selectedCard: CardType;
/** Themed builder used to derive the stats/top-langs card link. */
themeBuilder: CardUrlBuilder;
repo: string;
userId: string;
wakatimeUser: string;
gist: string;
}
/**
* Resolve the per-card metadata (guest hint + link) for the display stage,
* fetching the Gist's public URL as a side effect when needed.
*/
export function useCardDescriptor({
selectedCard,
themeBuilder,
repo,
userId,
wakatimeUser,
gist,
}: UseCardDescriptorOptions): CardDescriptor {
const [gistUrl, setGistUrl] = useState("");
useEffect(() => {
async function fetchGistURL() {
try {
const result = await axios.get<{ html_url: string }>(
`https://api.github.com/gists/${gist}`,
);
setGistUrl(result.data.html_url);
} catch (error) {
console.error(error);
}
}
void fetchGistURL();
}, [gist]);
return useMemo<CardDescriptor>(() => {
const pinRepo = repo.includes("/") ? repo : `${userId}/${repo}`;
const statsCardLink = themeBuilder.toApiUrl(HOST);
const descriptors: Record<CardType, CardDescriptor> = {
[CardType.STATS]: {
guestHint: `username "${DEMO_USER}"`,
link: statsCardLink,
},
[CardType.TOP_LANGS]: {
guestHint: `username "${DEMO_USER}"`,
link: statsCardLink,
},
[CardType.PIN]: {
guestHint: `repo "${DEMO_REPO}"`,
link: `https://github.com/${pinRepo}`,
},
[CardType.GIST]: {
guestHint: `Gist ID "${DEMO_GIST}"`,
link: gistUrl,
},
[CardType.WAKATIME]: {
guestHint: `WakaTime username "${DEMO_WAKATIME_USER}"`,
link: `https://wakatime.com/@${wakatimeUser}`,
},
};
return descriptors[selectedCard];
}, [selectedCard, themeBuilder, repo, userId, gistUrl, wakatimeUser]);
}
@@ -0,0 +1,27 @@
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { isDarkTheme, setTheme as setThemeAction } from "../slices/theme";
import type { StoreState } from "../store";
/**
* Reads the active DaisyUI theme from the Redux store and exposes a setter
* that persists the choice and updates the `data-theme` attribute.
*/
export function useTheme(): {
theme: string;
isDark: boolean;
setTheme: (theme: string) => void;
} {
const dispatch = useDispatch();
const theme = useSelector((state: StoreState) => state.theme.theme);
const setTheme = useCallback(
(next: string) => {
dispatch(setThemeAction(next));
},
[dispatch],
);
return { theme, isDark: isDarkTheme(theme), setTheme };
}
+69
View File
@@ -0,0 +1,69 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
interface ThemeOption {
/** DaisyUI theme name, used as the `data-theme` value. */
name: string;
label: string;
/** Whether the theme has a dark background (drives toast styling, etc.). */
isDark: boolean;
}
/**
* Themes exposed in the theme picker.
* Keep in sync with the `themes:` list in `index.css`.
*/
export const THEMES: ReadonlyArray<ThemeOption> = [
{ name: "light", label: "Light", isDark: false },
{ name: "dark", label: "Dark", isDark: true },
];
const DEFAULT_LIGHT_THEME = "light";
const DEFAULT_DARK_THEME = "dark";
function isValidTheme(name: string | null): name is string {
return !!name && THEMES.some((theme) => theme.name === name);
}
export function isDarkTheme(name: string): boolean {
return THEMES.find((theme) => theme.name === name)?.isDark ?? false;
}
/**
* @public
* Exported so the inferred store type can name it (see the note in `user.ts`).
*/
export interface ThemeState {
theme: string;
}
function getInitialTheme(): string {
const stored = localStorage.getItem("theme");
if (isValidTheme(stored)) {
return stored;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? DEFAULT_DARK_THEME
: DEFAULT_LIGHT_THEME;
}
const initialState: ThemeState = {
theme: getInitialTheme(),
};
const themeSlice = createSlice({
name: "theme",
initialState,
reducers: {
setTheme: (state, action: PayloadAction<string>) => {
const theme = action.payload;
localStorage.setItem("theme", theme);
document.documentElement.setAttribute("data-theme", theme);
state.theme = theme;
},
},
});
export const { setTheme } = themeSlice.actions;
export default themeSlice.reducer;
+2
View File
@@ -3,11 +3,13 @@ import { configureStore } from "@reduxjs/toolkit";
import { USE_LOGGER } from "../constants";
import { loggerMiddleware } from "./logger";
import theme from "./slices/theme";
import user from "./slices/user";
const store = configureStore({
reducer: {
user,
theme,
},
middleware: (getDefaultMiddleware) => {
const middleware = getDefaultMiddleware();
+46 -54
View File
@@ -15,7 +15,7 @@
> By default, the stats card only shows statistics like stars, commits, and pull requests from public repositories. To show private statistics on the stats card, [allow GitHub-Stats-Extended to access your private contributions](fork.md#private-contributions-support) or [deploy your own instance](deploy.md).
> [!NOTE]
> Available ranks are S (top 1%), A+ (12.5%), A (25%), A- (37.5%), B+ (50%), B (62.5%), B- (75%), C+ (87.5%) and C (everyone). This ranking scheme is based on the [Japanese academic grading](https://wikipedia.org/wiki/Academic_grading_in_Japan) system. The global percentile is calculated as a weighted sum of percentiles for each statistic (number of commits, pull requests, reviews, issues, stars, and followers), based on the cumulative distribution function of the [exponential](https://wikipedia.org/wiki/exponential_distribution) and the [log-normal](https://wikipedia.org/wiki/Log-normal_distribution) distributions. The implementation can be investigated at [src/calculateRank.js](https://github.com/stats-organization/github-stats-extended/blob/master/backend/src/calculateRank.js). The circle around the rank shows 100 minus the global percentile.
> Available ranks are S (top 1%), A+ (12.5%), A (25%), A- (37.5%), B+ (50%), B (62.5%), B- (75%), C+ (87.5%) and C (everyone). This ranking scheme is based on the [Japanese academic grading](https://wikipedia.org/wiki/Academic_grading_in_Japan) system. The global percentile is calculated as a weighted sum of percentiles for each statistic (number of commits, pull requests, reviews, issues, stars, and followers), based on the cumulative distribution function of the [exponential](https://wikipedia.org/wiki/exponential_distribution) and the [log-normal](https://wikipedia.org/wiki/Log-normal_distribution) distributions. The implementation can be investigated at [calculateRank.ts](https://github.com/stats-organization/github-stats-extended/blob/master/packages/core/src/calculateRank.ts). The circle around the rank shows 100 minus the global percentile.
### Hiding individual stats
@@ -76,7 +76,7 @@ GitHub Stats Extended comes with several built-in themes (e.g. `dark`, `radical`
<img src="https://res.cloudinary.com/anuraghazra/image/upload/v1595174536/grs-themes_l4ynja.png" alt="GitHub Stats Extended Themes" width="600px"/>
You can look at a preview for [all available themes](../packages/core/src/themes/README.md) or checkout the [theme config file](../packages/core/src/themes/index.js). Please note that we paused the addition of new themes to decrease maintenance efforts; all pull requests related to new themes will be closed.
You can look at a preview for [all available themes](../packages/core/src/themes/README.md) or checkout the [theme config file](../packages/core/src/themes/index.ts). Please note that we paused the addition of new themes to decrease maintenance efforts; all pull requests related to new themes will be closed.
#### Responsive Card Theme
@@ -85,6 +85,50 @@ You can look at a preview for [all available themes](../packages/core/src/themes
Since GitHub will re-upload the cards and serve them from their [CDN](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls), we can not infer the browser/GitHub theme on the server side. There are, however, four methods you can use to create dynamics themes on the client side.
##### Use GitHub's new media feature (recommended)
You can use [GitHub's new media feature](https://github.blog/changelog/2022-05-19-specify-theme-context-for-images-in-markdown-beta/) in HTML to specify whether to display images for light or dark themes. This is done using the HTML `<picture>` element in combination with the `prefers-color-scheme` media feature.
```html
<picture>
<source
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark"
media="(prefers-color-scheme: dark)"
/>
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true" /> <!-- light mode -->
</picture>
```
<details>
<summary>:eyes: Show example</summary>
<picture>
<source
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark"
media="(prefers-color-scheme: dark)"
/>
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true" /> <!-- light mode -->
</picture>
</details>
##### Use GitHub's theme context tag
You can use [GitHub's theme context](https://github.blog/changelog/2021-11-24-specify-theme-context-for-images-in-markdown/) tags to switch the theme based on the user GitHub theme automatically. This is done by appending `#gh-dark-mode-only` or `#gh-light-mode-only` to the end of an image URL. This tag will define whether the image specified in the markdown is only shown to viewers using a light or a dark GitHub theme:
```md
[![Anurag's GitHub stats-Dark](https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark#gh-dark-mode-only)](https://github.com/stats-organization/github-stats-extended#gh-dark-mode-only)
[![Anurag's GitHub stats-Light](https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=default#gh-light-mode-only)](https://github.com/stats-organization/github-stats-extended#gh-light-mode-only)
```
<details>
<summary>:eyes: Show example</summary>
[![Anurag's GitHub stats-Dark](https://github-stats-extended.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=dark#gh-dark-mode-only)](https://github.com/stats-organization/github-stats-extended#gh-dark-mode-only)
[![Anurag's GitHub stats-Light](https://github-stats-extended.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=default#gh-light-mode-only)](https://github.com/stats-organization/github-stats-extended#gh-light-mode-only)
</details>
##### Use the transparent theme
We have included a `transparent` theme that has a transparent background. This theme is optimized to look good on GitHub's dark and light default themes. You can enable this theme using the `&theme=transparent` parameter like so:
@@ -115,58 +159,6 @@ You can use the `bg_color` parameter to make any of [the available themes](../pa
</details>
##### Use GitHub's theme context tag
You can use [GitHub's theme context](https://github.blog/changelog/2021-11-24-specify-theme-context-for-images-in-markdown/) tags to switch the theme based on the user GitHub theme automatically. This is done by appending `#gh-dark-mode-only` or `#gh-light-mode-only` to the end of an image URL. This tag will define whether the image specified in the markdown is only shown to viewers using a light or a dark GitHub theme:
```md
[![Anurag's GitHub stats-Dark](https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark#gh-dark-mode-only)](https://github.com/stats-organization/github-stats-extended#gh-dark-mode-only)
[![Anurag's GitHub stats-Light](https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=default#gh-light-mode-only)](https://github.com/stats-organization/github-stats-extended#gh-light-mode-only)
```
<details>
<summary>:eyes: Show example</summary>
[![Anurag's GitHub stats-Dark](https://github-stats-extended.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=dark#gh-dark-mode-only)](https://github.com/stats-organization/github-stats-extended#gh-dark-mode-only)
[![Anurag's GitHub stats-Light](https://github-stats-extended.vercel.app/api?username=anuraghazra\&show_icons=true\&theme=default#gh-light-mode-only)](https://github.com/stats-organization/github-stats-extended#gh-light-mode-only)
</details>
##### Use GitHub's new media feature
You can use [GitHub's new media feature](https://github.blog/changelog/2022-05-19-specify-theme-context-for-images-in-markdown-beta/) in HTML to specify whether to display images for light or dark themes. This is done using the HTML `<picture>` element in combination with the `prefers-color-scheme` media feature.
```html
<picture>
<source
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark"
media="(prefers-color-scheme: dark)"
/>
<source
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true"
media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)"
/>
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true" />
</picture>
```
<details>
<summary>:eyes: Show example</summary>
<picture>
<source
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true&theme=dark"
media="(prefers-color-scheme: dark)"
/>
<source
srcset="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true"
media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)"
/>
<img src="https://github-stats-extended.vercel.app/api?username=anuraghazra&show_icons=true" />
</picture>
</details>
### Customization
You can customize the appearance of all your cards however you wish with URL parameters.
+19 -38
View File
@@ -1,12 +1,12 @@
# Deploy on your own
# Run It Yourself
Since the GitHub API only allows a limited number of requests per hour, my `https://github-stats-extended.vercel.app/api` could possibly hit the rate limiter. If you deploy it yourself via GitHub Actions or your own hosted instance, then you do not have to worry about anything. Also, if you don't want to give my GitHub-Stats-Extended instance access to your private contributions but still want to include these contributions in your stats, you can simply host your own instance.
We cache generated cards for a few hours to avoid potential rate-limiting in the GitHub API or on Vercel. If you want to set your own cache duration or you want to include private contributions in your stats without granting our hosted version of GitHub-Stats-Extended access to your private contributions, you can run GitHub-Stats-Extended on your own.
GitHub Actions is the simplest setup with static SVGs stored in your repo but less frequent updates, while self-hosting takes more work and can serve fresher stats (with caching).
GitHub Actions is the simplest setup with static SVGs stored in your repo but less frequent updates, while self-hosting GitHub-Stats-Extended on Vercel takes more work and can serve fresher stats (with caching).
## GitHub Actions
## GitHub Action
GitHub Actions generates static SVGs and avoids per-request API calls. By default it uses `GITHUB_TOKEN` (public stats only), for private stats, set a [PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) as a secret and pass it to the action instead.
With [github-readme-stats-action](https://github.com/stats-organization/github-readme-stats-action) you can generate static cards in your GitHub Actions workflow, commit them to your profile repository, and embed them directly from there. This avoids any per-request API calls.
Create `/.github/workflows/grs.yml` in your profile repo (`USERNAME/USERNAME`):
@@ -15,17 +15,21 @@ name: Update README cards
on:
schedule:
- cron: "0 3 * * *"
- cron: "0 0 * * *" # Runs once daily at midnight
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Generate stats card
uses: readme-tools/github-readme-stats-action@v1
uses: stats-organization/github-readme-stats-action@v2
with:
card: stats
options: username=${{ github.repository_owner }}&show_icons=true
@@ -34,14 +38,14 @@ jobs:
- name: Commit cards
run: |
git config user.name "github-actions"
git config user.email "github-actions@users.noreply.github.com"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add profile/*.svg
git commit -m "Update README cards" || exit 0
git push
```
Then embed from your profile README:
Then embed from your [profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme):
```md
![Stats](./profile/stats.svg)
@@ -49,7 +53,7 @@ Then embed from your profile README:
See more options and examples in the [GitHub Readme Stats Action README](https://github.com/stats-organization/github-readme-stats-action#readme).
## Self-hosted (Vercel/Other)
## Self-hosted on Vercel
Running your own instance avoids public rate limits and gives you full control over caching, tokens, and private stats.
@@ -88,14 +92,11 @@ Selecting the right scopes for your token is important in case you want to displ
### On Vercel
<b>:film\_projector: [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107)</b>
Click on the deploy button to get started!
[![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/stats-organization/github-stats-extended)
[![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/stats-organization/github-stats-extended&root-directory=apps/backend)
<details>
<summary><b>:hammer_and_wrench: Recommended: Step-by-step guide on setting up your own Vercel instance</b></summary>
<b>Recommended: Step-by-step guide on setting up your own Vercel instance</b>
1. Go to [vercel.com](https://vercel.com/).
2. Click on `Log in`.
@@ -122,27 +123,7 @@ Click on the deploy button to get started!
12. Click deploy, and you're good to go. See your domains to use the API!
13. optional: add an SQL database; by using e.g. the ["Nile" integration](https://vercel.com/marketplace/nile) or by manually setting the environment variable `POSTGRES_URL`
14. optional: [create your own OAuth App](https://github.com/settings/developers) and set environment variables `OAUTH_REDIRECT_URI`, `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` on Vercel accordingly
15. optional: in addition to the Vercel project based on the `apps/backend` folder, create a second project based on the `apps/frontend` folder. No environment variables needed.
16. optional: set the environment variable `TURBO_PLATFORM_ENV_DISABLED` to `true` to disable the build-time warning from [turbo](https://turborepo.dev/) about environment variables missing from "turbo.json" - This warning is not relevant in our project.
</details>
### On other platforms
> [!WARNING]
> This way of using GitHub-Stats-Extended is not officially supported and was added to cater to some particular use cases where Vercel could not be used (e.g. [#2341](https://github.com/anuraghazra/github-readme-stats/discussions/2341)). The support for this method, therefore, is limited.
<details>
<summary><b>:hammer_and_wrench: Step-by-step guide for deploying on other platforms</b></summary>
1. Fork or clone this repo as per your needs
2. Move `express` from the devDependencies to the dependencies section of `package.json`
<https://github.com/anuraghazra/github-readme-stats/blob/ba7c2f8b55eac8452e479c8bd38b044d204d0424/package.json#L54-L61>
3. Run `npm i` if needed (initial setup)
4. Run `node express.js` to start the server, or set the entry point to `express.js` in `package.json` if you're deploying on a managed service
<https://github.com/anuraghazra/github-readme-stats/blob/ba7c2f8b55eac8452e479c8bd38b044d204d0424/package.json#L11>
5. You're done 🎉
</details>
15. optional: set the environment variable `TURBO_PLATFORM_ENV_DISABLED` to `true` to disable the build-time warning from [turbo](https://turborepo.dev/) about environment variables missing from "turbo.json" - This warning is not relevant in our project.
### Available environment variables
+16 -7
View File
@@ -25,14 +25,14 @@ The resolution of this most requested feature in github-readme-stats was [origin
GitHub-Stats-Extended proactively precomputes and caches cards. This solves the problem where [cards wouldn't load on the first try](https://github.com/anuraghazra/github-readme-stats/issues/2603). It also gives GitHub-Stats-Extended more time while generating cards in the background, which allows it to fetch more repo data:
### Multi-page fetching for accurate star counts
GitHub-Stats-Extended fetches up to 1000 of your starred repositories to accurately compute your stars count. In github-readme-stats, this is limited to 100 repos because github-readme-stats doesn't have the above-mentioned performance improvements.
### Customization of top languages card
GitHub-Stats-Extended allows you to display your top languages without any numbers via the `hide_values` parameter. And the `prog_bar_bg_color` parameter allows you to customize the background color of the progress bars, e.g. by setting it to white:
![anuraghazra's top languages without numbers](https://github-stats-extended.vercel.app/api/top-langs?username=anuraghazra&langs_count=4&hide_values=true&prog_bar_bg_color=fff)
### Multi-page fetching for accurate star counts
GitHub-Stats-Extended fetches up to 1000 of your starred repositories to accurately compute your stars count. In github-readme-stats, this is limited to 100 repos because github-readme-stats doesn't have the above-mentioned performance improvements.
### Private contributions support
GitHub-Stats-Extended can include private contributions in your stats cards. You no longer have to deploy your own instance for that. Just log into the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) via the "GitHub Private Access" button (or click "Upgrade to Private Access" if already logged in). This will allow GitHub-Stats-Extended to see your private contributions.
@@ -72,12 +72,21 @@ One of the valued maintainers [wrote](https://github.com/anuraghazra/github-read
>
> The volume is overwhelming for the small team, especially taking into account that right now I'm alone online and working only sometimes when I have a free hours, so it took some time to get to your PR.
So I decided to implement some of the highly requested features and make the enhanced project available to everyone. Since the initial release of this fork I have been in contact with the maintainers of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) and we decided to join forces. github-stats-extended is now in the process of becoming the successor of github-readme-stats.
So [@martin-mfg](https://github.com/martin-mfg) decided to fork the project, implement some of the highly requested features and make the enhanced project available to everyone. Since the initial release of this fork @martin-mfg joined forces with the maintainers of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) and GitHub-Stats-Extended is now becoming the successor of github-readme-stats.
## Compatibility Notes
If there should be any changes in github-readme-stats in the future, they will be synced into this fork.
GitHub-Stats-Extended aims to be fully compatible with [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). Generally, additional functionality introduced in this fork has to be explicitly enabled via some parameter.
GitHub-Stats-Extended aims to be fully compatible with [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). Additional functionality introduced in this fork has to be explicitly enabled via some parameter.
So you can change an existing stats card url from [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) to GitHub-Stats-Extended simply by changing the domain from `github-readme-stats.vercel.app` to `github-stats-extended.vercel.app`. The card will look the same.
There is only one exception to this: GitHub-Stats-Extended improves line wrapping for multi-line gist and repository descriptions.
This should be an improvement for existing cards, but it still changes their appearance a bit.
Previously, line wrapping happened simply after 59 characters, with special handling for Chinese characters:
<img width="400" height="140" alt="character-based" src="https://github.com/user-attachments/assets/1cf7edba-7f6c-4a37-89d7-334cbe54f0f1" />
GitHub-Stats-Extended now takes the actual width of each character into account:
<img width="400" height="150" alt="new-server-side-calculation" src="https://github.com/user-attachments/assets/277b92df-b2a9-48be-bd2a-d28c1d6764c5" />
+11 -29
View File
@@ -2,12 +2,14 @@ import { fileURLToPath } from "node:url";
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import eslintReact from "@eslint-react/eslint-plugin";
import { defineConfig } from "eslint/config";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import {
createTypeScriptImportResolver,
defaultConditionNames,
} from "eslint-import-resolver-typescript";
import { importX } from "eslint-plugin-import-x";
import { default as jsdoc } from "eslint-plugin-jsdoc";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import { default as tseslint } from "typescript-eslint";
@@ -26,14 +28,7 @@ export default defineConfig(
/** Keep in sync with `tsconfig.base.json#customConditions` */
"@stats/source",
"types",
"import",
"require",
"node",
"node-addons",
"browser",
"default",
...defaultConditionNames,
],
}),
],
@@ -150,6 +145,10 @@ export default defineConfig(
},
],
// Keep `@param props.x` doc names in sync with the destructured
// parameters so they cannot silently drift on rename.
"jsdoc/check-param-names": "error",
// We don't need this we have typescript
"jsdoc/require-returns": "off",
"jsdoc/require-returns-description": "off",
@@ -172,23 +171,6 @@ export default defineConfig(
},
{
files: ["apps/frontend/**/*.{js,jsx,ts,tsx}"],
plugins: {
react,
"react-hooks": reactHooks,
},
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
"react/jsx-no-undef": "error",
"react/jsx-uses-vars": "error",
"react/no-array-index-key": "warn",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
},
...eslintReact.configs["recommended-typescript"],
},
);
+15 -16
View File
@@ -2,27 +2,26 @@
"name": "@stats-organization/root",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
"packageManager": "pnpm@10.34.1+sha512.b58fbde6dca66a929538021581f648b4570b6ca19b18e7cbd7f2c07a7b24454155388dacdf08f2af3678e88a6d1fe04f9d609df24bf51735a060ea041b374ab7",
"devDependencies": {
"@eslint/compat": "2.0.5",
"@eslint-react/eslint-plugin": "5.17.1",
"@eslint/compat": "2.1.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.59.1",
"@types/node": "24.12.2",
"@playwright/test": "1.61.1",
"@types/node": "24.13.3",
"@vitest/coverage-v8": "catalog:default",
"eslint": "10.2.1",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-jsdoc": "62.9.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0",
"eslint": "10.7.0",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jsdoc": "63.1.0",
"globals": "17.7.0",
"husky": "9.1.7",
"knip": "6.7.0",
"lint-staged": "16.4.0",
"prettier": "3.8.3",
"turbo": "2.9.6",
"knip": "6.27.0",
"lint-staged": "17.0.8",
"prettier": "3.9.5",
"turbo": "2.10.5",
"typescript": "6.0.3",
"typescript-eslint": "8.59.0",
"typescript-eslint": "8.64.0",
"vitest": "catalog:default"
},
"scripts": {
+1 -1
View File
@@ -1 +1 @@
Core library powering [GitHub Stats Extended](https://github.com/stats-organization/github-stats-extended) and [GitHub Readme Stats Action](https://github.com/readme-tools/github-readme-stats-action).
Core library powering [GitHub Stats Extended](https://github.com/stats-organization/github-stats-extended) and [GitHub Readme Stats Action](https://github.com/stats-organization/github-readme-stats-action).
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@stats-organization/github-readme-stats-core",
"version": "0.0.1",
"version": "2.1.5",
"type": "module",
"homepage": "https://github-stats-extended.vercel.app/frontend",
"bugs": {
@@ -83,12 +83,12 @@
"@testing-library/jest-dom": "6.9.1",
"@uppercod/css-to-object": "1.1.1",
"axios-mock-adapter": "2.1.0",
"js-yaml": "4.1.1",
"jsdom": "29.0.2",
"js-yaml": "5.2.1",
"jsdom": "catalog:default",
"vitest": "catalog:default"
},
"dependencies": {
"axios": "^1.15.2",
"axios": "catalog:default",
"emoji-name-map": "^2.0.3",
"github-username-regex": "^1.0.0"
}
+10
View File
@@ -0,0 +1,10 @@
/**
* `emoji-name-map` ships no type definitions and exposes no `exports` map, so it
* cannot be resolved under `nodenext`. Declare the minimal surface used here.
*/
declare module "emoji-name-map" {
const emojiNameMap: {
get(name: string): string | undefined;
};
export default emojiNameMap;
}
+36 -2
View File
@@ -1,6 +1,5 @@
// @ts-check
import { renderGistCard } from "../cards/gist.js";
import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -28,6 +27,23 @@ export default async (
},
pat = null,
) => {
const invalidColorInput = findInvalidColor({
title_color,
icon_color,
text_color,
bg_color,
border_color,
});
if (invalidColorInput) {
return {
status: "error - permanent",
content: renderError({
message: "Something went wrong",
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
}),
};
}
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
@@ -45,6 +61,24 @@ export default async (
};
}
const safePattern = /^[-\w/.,]+$/;
if (id && !safePattern.test(id)) {
return {
status: "error - permanent",
content: renderError({
message: "Something went wrong",
secondaryMessage: "Gist ID contains unsafe characters",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
};
}
try {
const gistData = await fetchGist(id, pat);
+19 -2
View File
@@ -1,6 +1,5 @@
// @ts-check
import { renderStatsCard } from "../cards/stats.js";
import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -46,6 +45,24 @@ export default async (
},
pat = null,
) => {
const invalidColorInput = findInvalidColor({
title_color,
ring_color,
icon_color,
text_color,
bg_color,
border_color,
});
if (invalidColorInput) {
return {
status: "error - permanent",
content: renderError({
message: "Something went wrong",
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
}),
};
}
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
+18 -2
View File
@@ -1,6 +1,5 @@
// @ts-check
import { renderRepoCard } from "../cards/repo.js";
import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -36,6 +35,23 @@ export default async (
},
pat = null,
) => {
const invalidColorInput = findInvalidColor({
title_color,
icon_color,
text_color,
bg_color,
border_color,
});
if (invalidColorInput) {
return {
status: "error - permanent",
content: renderError({
message: "Something went wrong",
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
}),
};
}
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
+38 -6
View File
@@ -1,6 +1,5 @@
// @ts-check
import { renderTopLanguages } from "../cards/top-languages.js";
import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -40,6 +39,23 @@ export default async (
},
pat = null,
) => {
const invalidColorInput = findInvalidColor({
title_color,
text_color,
bg_color,
prog_bar_bg_color,
border_color,
});
if (invalidColorInput) {
return {
status: "error - permanent",
content: renderError({
message: "Something went wrong",
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
}),
};
}
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
@@ -57,10 +73,27 @@ export default async (
};
}
const safePattern = /^[-\w/.,]+$/;
if (username && !safePattern.test(username)) {
return {
status: "error - permanent",
content: renderError({
message: "Something went wrong",
secondaryMessage: "Username contains unsafe characters",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
};
}
if (
layout !== undefined &&
(typeof layout !== "string" ||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout)
) {
return {
status: "error - permanent",
@@ -80,8 +113,7 @@ export default async (
if (
stats_format !== undefined &&
(typeof stats_format !== "string" ||
!["bytes", "percentages"].includes(stats_format))
!["bytes", "percentages"].includes(stats_format)
) {
return {
status: "error - permanent",
+36 -2
View File
@@ -1,6 +1,5 @@
// @ts-check
import { renderWakatimeCard } from "../cards/wakatime.js";
import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -34,6 +33,23 @@ export default async ({
display_format,
disable_animations,
}) => {
const invalidColorInput = findInvalidColor({
title_color,
icon_color,
text_color,
bg_color,
border_color,
});
if (invalidColorInput) {
return {
status: "error - permanent",
content: renderError({
message: "Something went wrong",
secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
}),
};
}
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
@@ -51,6 +67,24 @@ export default async ({
};
}
const safePattern = /^[-\w/.,]+$/;
if (username && !safePattern.test(username)) {
return {
status: "error - permanent",
content: renderError({
message: "Something went wrong",
secondaryMessage: "Username contains unsafe characters",
renderOptions: {
title_color,
text_color,
bg_color,
border_color,
theme,
},
}),
};
}
try {
const stats = await fetchWakatimeStats({ username, api_domain });
@@ -1,20 +1,20 @@
/**
* Calculates the exponential cdf.
*
* @param {number} x The value.
* @returns {number} The exponential cdf.
* @param x The value.
* @returns The exponential cdf.
*/
function exponential_cdf(x) {
function exponential_cdf(x: number): number {
return 1 - 2 ** -x;
}
/**
* Calculates the log normal cdf.
*
* @param {number} x The value.
* @returns {number} The log normal cdf.
* @param x The value.
* @returns The log normal cdf.
*/
function log_normal_cdf(x) {
function log_normal_cdf(x: number): number {
// approximation
return x / (1 + x);
}
@@ -22,16 +22,16 @@ function log_normal_cdf(x) {
/**
* Calculates the users rank.
*
* @param {object} params Parameters on which the user's rank depends.
* @param {boolean} params.all_commits Whether `include_all_commits` was used.
* @param {number} params.commits Number of commits.
* @param {number} params.prs The number of pull requests.
* @param {number} params.issues The number of issues.
* @param {number} params.reviews The number of reviews.
* @param {number} params.repos Total number of repos.
* @param {number} params.stars The number of stars.
* @param {number} params.followers The number of followers.
* @returns {{ level: string, percentile: number }} The users rank.
* @param params Parameters on which the user's rank depends.
* @param params.all_commits Whether `include_all_commits` was used.
* @param params.commits Number of commits.
* @param params.prs The number of pull requests.
* @param params.issues The number of issues.
* @param params.reviews The number of reviews.
* @param params.repos Total number of repos (accepted for compatibility, unused in the calculation).
* @param params.stars The number of stars.
* @param params.followers The number of followers.
* @returns The users rank.
*/
function calculateRank({
all_commits,
@@ -39,11 +39,18 @@ function calculateRank({
prs,
issues,
reviews,
// eslint-disable-next-line no-unused-vars
repos, // unused
stars,
followers,
}) {
}: {
all_commits: boolean;
commits: number;
prs: number;
issues: number;
reviews: number;
repos: number;
stars: number;
followers: number;
}): { level: string; percentile: number } {
const COMMITS_MEDIAN = all_commits ? 1000 : 250,
COMMITS_WEIGHT = 2;
const PRS_MEDIAN = 50,
@@ -79,6 +86,9 @@ function calculateRank({
TOTAL_WEIGHT;
const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)];
if (level === undefined) {
throw new Error("Unable to determine rank level");
}
return { level, percentile: rank * 100 };
}
-2
View File
@@ -1,5 +1,3 @@
// @ts-check
import { default as Card } from "../common/Card.js";
import { getCardColors } from "../common/color.js";
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
+30 -22
View File
@@ -1,8 +1,6 @@
// @ts-check
import { Card } from "../common/Card.js";
import { I18n } from "../common/I18n.js";
import { getCardColors } from "../common/color.js";
import { getCardColors, isPrefixedHexColor } from "../common/color.js";
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
import { encodeHTML } from "../common/html.js";
import { icons } from "../common/icons.js";
@@ -34,20 +32,29 @@ const DESCRIPTION_MAX_LINES = 3;
* @param {string} textColor The color of the text.
* @returns {string} Wrapped repo description SVG object.
*/
const getBadgeSVG = (label, textColor, xOffset = 0) => `
<g data-testid="badge" class="badge" transform="translate(${320 + xOffset}, -18)">
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
<text
x="23" y="-5"
alignment-baseline="central"
dominant-baseline="central"
text-anchor="middle"
fill="${textColor}"
>
${label}
</text>
</g>
`;
const getBadgeSVG = (label, textColor, xOffset = 0) => {
if (!isPrefixedHexColor(textColor)) {
throw new Error(`Invalid text color: "${textColor}"`);
}
if (!Number.isFinite(xOffset)) {
throw new Error(`Invalid xOffset: "${xOffset}"`);
}
return `
<g data-testid="badge" class="badge" transform="translate(${320 + xOffset}, -18)">
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
<text
x="23" y="-5"
alignment-baseline="central"
dominant-baseline="central"
text-anchor="middle"
fill="${textColor}"
>
${encodeHTML(label)}
</text>
</g>
`;
};
/**
* @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data.
@@ -112,6 +119,7 @@ const renderRepoCard = (repo, options = {}) => {
});
let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], []));
const encodedUsername = encodeURIComponent(username);
const STATS = {};
if (show.includes("prs_authored")) {
STATS.prs_authored = {
@@ -119,7 +127,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.prs-authored"),
value: totalPRsAuthored,
id: "prs_authored",
link: `https://github.com/search?q=${repoFilter}author%3A${username}&amp;type=pullrequests`,
link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&amp;type=pullrequests`,
};
}
if (show.includes("prs_commented")) {
@@ -128,7 +136,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.prs-commented"),
value: totalPRsCommented,
id: "prs_commented",
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&amp;type=pullrequests`,
link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=pullrequests`,
};
}
if (show.includes("prs_reviewed")) {
@@ -137,7 +145,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.prs-reviewed"),
value: totalPRsReviewed,
id: "prs_reviewed",
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&amp;type=pullrequests`,
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=pullrequests`,
};
}
if (show.includes("issues_authored")) {
@@ -146,7 +154,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.issues-authored"),
value: totalIssuesAuthored,
id: "issues_authored",
link: `https://github.com/search?q=${repoFilter}author%3A${username}&amp;type=issues`,
link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&amp;type=issues`,
};
}
if (show.includes("issues_commented")) {
@@ -155,7 +163,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.issues-commented"),
value: totalIssuesCommented,
id: "issues_commented",
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&amp;type=issues`,
link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=issues`,
};
}
+22 -10
View File
@@ -1,10 +1,9 @@
// @ts-check
import { Card } from "../common/Card.js";
import { I18n } from "../common/I18n.js";
import { getCardColors } from "../common/color.js";
import { CustomError } from "../common/error.js";
import { kFormatter } from "../common/fmt.js";
import { encodeHTML } from "../common/html.js";
import { icons, rankIcon } from "../common/icons.js";
import { buildSearchFilter, clampValue } from "../common/ops.js";
import { flexLayout, measureText } from "../common/render.js";
@@ -54,8 +53,10 @@ const LONG_LOCALES = [
/**
* Create a stats card text item.
*
* The caller must ensure that the passed `icon` and `link` are properly sanitized!
*
* @param {object} params Object that contains the createTextNode parameters.
* @param {string} params.icon The icon to display.
* @param {string} params.icon The sanitized icon to display.
* @param {string} params.label The label to display.
* @param {number} params.value The value to display.
* @param {string} params.id The id of the stat.
@@ -66,7 +67,7 @@ const LONG_LOCALES = [
* @param {boolean} params.bold Whether to bold the label.
* @param {string} params.numberFormat The format of numbers on card.
* @param {number=} params.numberPrecision The precision of numbers on card.
* @param {string} params.link Url to link to.
* @param {string} params.link Sanitized url to link to.
* @param {number} params.labelXOffset horizontal offset for label.
* @returns {string} The stats card text item SVG object.
*/
@@ -85,6 +86,16 @@ const createTextNode = ({
link,
labelXOffset = 25,
}) => {
if (!Number.isFinite(labelXOffset)) {
throw new Error(`Invalid labelXOffset: "${labelXOffset}"`);
}
if (!Number.isFinite(shiftValuePos)) {
throw new Error(`Invalid shiftValuePos: "${shiftValuePos}"`);
}
if (!Number.isFinite(index)) {
throw new Error(`Invalid index: "${index}"`);
}
const precision =
typeof numberPrecision === "number" && !isNaN(numberPrecision)
? clampValue(numberPrecision, 0, 2)
@@ -111,7 +122,7 @@ const createTextNode = ({
${iconSvg}
<text class="stat ${
bold ? " bold" : "not_bold"
}" ${labelOffset} y="12.5">${label}:</text>
}" ${labelOffset} y="12.5">${encodeHTML(label)}:</text>
<text
class="stat ${bold ? " bold" : "not_bold"}"
x="${(showIcons ? 140 : 120) + (bold ? 5 : 0) + shiftValuePos}"
@@ -424,13 +435,14 @@ const renderStatsCard = (
}
let repoFilter = encodeURIComponent(buildSearchFilter(repo, owner));
const encodedUsername = encodeURIComponent(username);
if (show.includes("prs_authored")) {
STATS.prs_authored = {
icon: icons.prs,
label: i18n.t("statcard.prs-authored"),
value: totalPRsAuthored,
id: "prs_authored",
link: `https://github.com/search?q=${repoFilter}author%3A${username}&amp;type=pullrequests`,
link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&amp;type=pullrequests`,
};
}
if (show.includes("prs_commented")) {
@@ -439,7 +451,7 @@ const renderStatsCard = (
label: i18n.t("statcard.prs-commented"),
value: totalPRsCommented,
id: "prs_commented",
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&amp;type=pullrequests`,
link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=pullrequests`,
};
}
if (show.includes("prs_reviewed")) {
@@ -448,7 +460,7 @@ const renderStatsCard = (
label: i18n.t("statcard.prs-reviewed"),
value: totalPRsReviewed,
id: "prs_reviewed",
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&amp;type=pullrequests`,
link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=pullrequests`,
};
}
if (show.includes("issues_authored")) {
@@ -457,7 +469,7 @@ const renderStatsCard = (
label: i18n.t("statcard.issues-authored"),
value: totalIssuesAuthored,
id: "issues_authored",
link: `https://github.com/search?q=${repoFilter}author%3A${username}&amp;type=issues`,
link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&amp;type=issues`,
};
}
if (show.includes("issues_commented")) {
@@ -466,7 +478,7 @@ const renderStatsCard = (
label: i18n.t("statcard.issues-commented"),
value: totalIssuesCommented,
id: "issues_commented",
link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&amp;type=issues`,
link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&amp;type=issues`,
};
}
+49 -13
View File
@@ -1,9 +1,12 @@
// @ts-check
import { Card } from "../common/Card.js";
import { I18n } from "../common/I18n.js";
import { fallbackColor, getCardColors } from "../common/color.js";
import {
fallbackColor,
getCardColors,
isPrefixedHexColor,
} from "../common/color.js";
import { formatBytes } from "../common/fmt.js";
import { encodeHTML } from "../common/html.js";
import { chunkArray, clampValue, lowercaseTrim } from "../common/ops.js";
import {
createProgressNode,
@@ -246,8 +249,8 @@ const createProgressTextNode = ({
return `
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
<text data-testid="lang-name" x="2" y="15" class="lang-name">${name}</text>
${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${displayValue}</text>`}
<text data-testid="lang-name" x="2" y="15" class="lang-name">${encodeHTML(name)}</text>
${hideValues ? "" : `<text x="${progressTextX}" y="34" class="lang-name">${encodeHTML(displayValue)}</text>`}
${createProgressNode({
x: 0,
y: 25,
@@ -287,11 +290,15 @@ const createCompactLangNode = ({
const staggerDelay = (index + 3) * 150;
const color = lang.color || "#858585";
if (!isPrefixedHexColor(color)) {
throw new Error(`Invalid language color: "${color}"`);
}
return `
<g class="stagger" style="animation-delay: ${staggerDelay}ms">
<circle cx="5" cy="6" r="5" fill="${color}" />
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
${lang.name} ${hideProgress || hideValues ? "" : displayValue}
${encodeHTML(lang.name)} ${hideProgress || hideValues ? "" : encodeHTML(displayValue)}
</text>
</g>
`;
@@ -441,6 +448,11 @@ const renderCompactLayout = (
let progressOffset = 0;
const compactProgressBar = langs
.map((lang) => {
const langColor = lang.color || DEFAULT_LANG_COLOR;
if (!isPrefixedHexColor(langColor)) {
throw new Error(`Invalid language color: "${langColor}"`);
}
const percentage = parseFloat(
((lang.size / totalLanguageSize) * offsetWidth).toFixed(2),
);
@@ -455,7 +467,7 @@ const renderCompactLayout = (
y="0"
width="${progress}"
height="8"
fill="${lang.color || "#858585"}"
fill="${langColor}"
/>
`;
progressOffset += percentage;
@@ -516,18 +528,23 @@ const renderDonutVerticalLayout = (
// Generate each donut vertical chart part
for (const lang of langs) {
const langColor = lang.color || DEFAULT_LANG_COLOR;
if (!isPrefixedHexColor(langColor)) {
throw new Error(`Invalid language color: "${langColor}"`);
}
const percentage = (lang.size / totalLanguageSize) * 100;
const circleLength = totalCircleLength * (percentage / 100);
const delay = startDelayCoefficient * 100;
circles.push(`
<g class="stagger" style="animation-delay: ${delay}ms">
<circle
<circle
cx="150"
cy="100"
r="${radius}"
fill="transparent"
stroke="${lang.color}"
stroke="${langColor}"
stroke-width="25"
stroke-dasharray="${totalCircleLength}"
stroke-dashoffset="${indent}"
@@ -591,6 +608,11 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
// Generate each pie chart part
for (const lang of langs) {
const langColor = lang.color || DEFAULT_LANG_COLOR;
if (!isPrefixedHexColor(langColor)) {
throw new Error(`Invalid language color: "${langColor}"`);
}
if (langs.length === 1) {
paths.push(`
<circle
@@ -598,7 +620,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
cy="${centerY}"
r="${radius}"
stroke="none"
fill="${lang.color}"
fill="${langColor}"
data-testid="lang-pie"
size="100"
/>
@@ -631,7 +653,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
data-testid="lang-pie"
size="${percentage}"
d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
fill="${lang.color}"
fill="${langColor}"
/>
</g>
`);
@@ -718,12 +740,22 @@ const renderDonutLayout = (
statsFormat,
hideValues,
) => {
if (!Number.isFinite(width)) {
throw new Error(`Invalid width: "${width}"`);
}
const centerX = width / 3;
const centerY = width / 3;
const radius = centerX - 60;
const strokeWidth = 12;
const colors = langs.map((lang) => lang.color);
const colors = langs.map((lang) => {
const langColor = lang.color || DEFAULT_LANG_COLOR;
if (!isPrefixedHexColor(langColor)) {
throw new Error(`Invalid language color: "${langColor}"`);
}
return langColor;
});
const langsPercents = langs.map((lang) =>
parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
);
@@ -785,10 +817,14 @@ const renderDonutLayout = (
* @returns {string} No languages data SVG node string.
*/
const noLanguagesDataNode = ({ color, text, layout }) => {
if (!isPrefixedHexColor(color)) {
throw new Error(`Invalid text color: "${color}"`);
}
return `
<text x="${
layout === "pie" || layout === "donut-vertical" ? CARD_PADDING : 0
}" y="11" class="stat bold" fill="${color}">${text}</text>
}" y="11" class="stat bold" fill="${color}">${encodeHTML(text)}</text>
`;
};
+33 -10
View File
@@ -1,8 +1,7 @@
// @ts-check
import { Card } from "../common/Card.js";
import { I18n } from "../common/I18n.js";
import { getCardColors } from "../common/color.js";
import { getCardColors, isPrefixedHexColor } from "../common/color.js";
import { encodeHTML } from "../common/html.js";
import languageColors from "../common/languageColors.json" with { type: "json" };
import { clampValue, lowercaseTrim } from "../common/ops.js";
import { createProgressNode, flexLayout } from "../common/render.js";
@@ -26,8 +25,12 @@ const TOTAL_TEXT_WIDTH = 275;
* @returns {string} No coding activity SVG node string.
*/
const noCodingActivityNode = ({ color, text }) => {
if (!isPrefixedHexColor(color)) {
throw new Error(`Invalid text color: "${color}"`);
}
return `
<text x="25" y="11" class="stat bold" fill="${color}">${text}</text>
<text x="25" y="11" class="stat bold" fill="${color}">${encodeHTML(text)}</text>
`;
};
@@ -60,6 +63,13 @@ const formatLanguageValue = ({ display_format, lang }) => {
* @returns {string} The compact layout language SVG node.
*/
const createCompactLangNode = ({ lang, x, y, display_format }) => {
if (!Number.isFinite(x)) {
throw new Error(`Invalid x: "${x}"`);
}
if (!Number.isFinite(y)) {
throw new Error(`Invalid y: "${y}"`);
}
// @ts-ignore
const color = languageColors[lang.name] || "#858585";
const value = formatLanguageValue({ display_format, lang });
@@ -68,7 +78,7 @@ const createCompactLangNode = ({ lang, x, y, display_format }) => {
<g transform="translate(${x}, ${y})">
<circle cx="5" cy="6" r="5" fill="${color}" />
<text data-testid="lang-name" x="15" y="10" class='lang-name'>
${lang.name} - ${value}
${encodeHTML(lang.name)} - ${encodeHTML(value)}
</text>
</g>
`;
@@ -127,6 +137,13 @@ const createTextNode = ({
progressBarBackgroundColor,
progressBarWidth,
}) => {
if (!Number.isFinite(index)) {
throw new Error(`Invalid index: "${index}"`);
}
if (!Number.isFinite(progressBarWidth)) {
throw new Error(`Invalid progressBarWidth: "${progressBarWidth}"`);
}
const staggerDelay = (index + 3) * 150;
const cardProgress = hideProgress
? null
@@ -144,12 +161,12 @@ const createTextNode = ({
return `
<g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">
<text class="stat bold" y="12.5" data-testid="${id}">${label}:</text>
<text class="stat bold" y="12.5" data-testid="${encodeHTML(id)}">${encodeHTML(label)}:</text>
<text
class="stat"
x="${hideProgress ? HIDDEN_PROGRESSBAR_PADDING : PROGRESSBAR_PADDING + progressBarWidth}"
y="12.5"
>${value}</text>
>${encodeHTML(value)}</text>
${cardProgress}
</g>
`;
@@ -181,11 +198,15 @@ const recalculatePercentages = (languages) => {
* @param {string} colors.textColor The text color.
* @returns {string} Card CSS styles.
*/
const getStyles = ({
const getStyles = function ({
// eslint-disable-next-line no-unused-vars
titleColor,
textColor,
}) => {
}) {
if (!isPrefixedHexColor(textColor)) {
throw new Error(`Invalid text color: "${textColor}"`);
}
return `
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
@@ -379,7 +400,9 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
progressBarColor: titleColor,
// @ts-ignore
progressBarBackgroundColor:
textColor === titleColor ? bgColor : textColor,
textColor === titleColor
? "#fff0" // transparent
: textColor,
hideProgress: hide_progress,
progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH,
});
-275
View File
@@ -1,275 +0,0 @@
// @ts-check
import { encodeHTML } from "./html.js";
import { flexLayout } from "./render.js";
class Card {
/**
* Creates a new card instance.
*
* @param {object} args Card arguments.
* @param {number=} args.width Card width.
* @param {number=} args.height Card height.
* @param {number=} args.border_radius Card border radius.
* @param {string=} args.customTitle Card custom title.
* @param {string=} args.defaultTitle Card default title.
* @param {string=} args.titlePrefixIcon Card title prefix icon.
* @param {object} [args.colors={}] Card colors arguments.
* @param {string=} args.colors.titleColor Card title color.
* @param {string=} args.colors.textColor Card text color.
* @param {string=} args.colors.iconColor Card icon color.
* @param {string|string[]=} args.colors.bgColor Card background color.
* @param {string=} args.colors.borderColor Card border color.
*/
constructor({
width = 100,
height = 100,
border_radius = 4.5,
colors = {},
customTitle,
defaultTitle = "",
titlePrefixIcon,
}) {
this.width = width;
this.height = height;
this.hideBorder = false;
this.hideTitle = false;
this.border_radius = border_radius;
// returns theme based colors with proper overrides and defaults
this.colors = colors;
this.title =
customTitle === undefined
? encodeHTML(defaultTitle)
: encodeHTML(customTitle);
this.css = "";
this.paddingX = 25;
this.paddingY = 35;
this.titlePrefixIcon = titlePrefixIcon;
this.animations = true;
this.a11yTitle = "";
this.a11yDesc = "";
}
/**
* @returns {void}
*/
disableAnimations() {
this.animations = false;
}
/**
* @param {Object} props The props object.
* @param {string} props.title Accessibility title.
* @param {string} props.desc Accessibility description.
* @returns {void}
*/
setAccessibilityLabel({ title, desc }) {
this.a11yTitle = title;
this.a11yDesc = desc;
}
/**
* @param {string} value The CSS to add to the card.
* @returns {void}
*/
setCSS(value) {
this.css = value;
}
/**
* @param {boolean} value Whether to hide the border or not.
* @returns {void}
*/
setHideBorder(value) {
this.hideBorder = value;
}
/**
* @param {boolean} value Whether to hide the title or not.
* @returns {void}
*/
setHideTitle(value) {
this.hideTitle = value;
if (value) {
this.height -= 30;
}
}
/**
* @param {string} text The title to set.
* @returns {void}
*/
setTitle(text) {
this.title = text;
}
/**
* @returns {string} The rendered card title.
*/
renderTitle() {
const titleText = `
<text
x="0"
y="0"
class="header"
data-testid="header"
>${this.title}</text>
`;
const prefixIcon = `
<svg
class="icon"
x="0"
y="-13"
viewBox="0 0 16 16"
version="1.1"
width="16"
height="16"
>
${this.titlePrefixIcon}
</svg>
`;
return `
<g
data-testid="card-title"
transform="translate(${this.paddingX}, ${this.paddingY})"
>
${flexLayout({
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
gap: 25,
}).join("")}
</g>
`;
}
/**
* @returns {string} The rendered card gradient.
*/
renderGradient() {
if (typeof this.colors.bgColor !== "object") {
return "";
}
const gradients = this.colors.bgColor.slice(1);
return typeof this.colors.bgColor === "object"
? `
<defs>
<linearGradient
id="gradient"
gradientTransform="rotate(${this.colors.bgColor[0]})"
gradientUnits="userSpaceOnUse"
>
${gradients.map((grad, index) => {
let offset = (index * 100) / (gradients.length - 1);
return `<stop offset="${offset}%" stop-color="#${grad}" />`;
})}
</linearGradient>
</defs>
`
: "";
}
/**
* Retrieves css animations for a card.
*
* @returns {string} Animation css.
*/
getAnimations = () => {
return `
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
};
/**
* @param {string} body The inner body of the card.
* @returns {string} The rendered card.
*/
render(body) {
return `
<svg
width="${this.width}"
height="${this.height}"
viewBox="0 0 ${this.width} ${this.height}"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="descId"
>
<title id="titleId">${this.a11yTitle}</title>
<desc id="descId">${this.a11yDesc}</desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: ${this.colors.titleColor};
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
${this.css}
${this.getAnimations()}
${
this.animations === false
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
: ""
}
</style>
${this.renderGradient()}
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="${this.border_radius}"
height="99%"
stroke="${this.colors.borderColor}"
width="${this.width - 1}"
fill="${
typeof this.colors.bgColor === "object"
? "url(#gradient)"
: this.colors.bgColor
}"
stroke-opacity="${this.hideBorder ? 0 : 1}"
/>
${this.hideTitle ? "" : this.renderTitle()}
<g
data-testid="main-card-body"
transform="translate(0, ${
this.hideTitle ? this.paddingX : this.paddingY + 20
})"
>
${body}
</g>
</svg>
`;
}
}
export { Card };
export default Card;
+338
View File
@@ -0,0 +1,338 @@
import { isPrefixedHexColor, isValidGradient } from "./color.js";
import { encodeHTML } from "./html.js";
import { flexLayout } from "./render.js";
interface CardColors {
/** Card title color. */
titleColor?: string;
/** Card text color. */
textColor?: string;
/** Card icon color. */
iconColor?: string;
/** Card background color. */
bgColor?: string | Array<string>;
/** Card border color. */
borderColor?: string;
}
class Card {
width: number;
height: number;
hideBorder: boolean;
hideTitle: boolean;
border_radius: number;
colors: CardColors;
title: string;
css: string;
paddingX: number;
paddingY: number;
titlePrefixIcon: string | undefined;
animations: boolean;
a11yTitle: string;
a11yDesc: string;
/**
* Creates a new card instance.
*
* The caller must ensure that the passed `titlePrefixIcon` is properly sanitized!
*
* @param props Card arguments.
* @param props.width Card width.
* @param props.height Card height.
* @param props.border_radius Card border radius.
* @param props.colors Card colors arguments.
* @param props.customTitle Card custom title.
* @param props.defaultTitle Card default title.
* @param props.titlePrefixIcon Sanitized card title prefix icon.
*/
constructor({
width = 100,
height = 100,
border_radius = 4.5,
colors = {},
customTitle,
defaultTitle = "",
titlePrefixIcon,
}: {
width?: number;
height?: number;
border_radius?: number;
colors?: CardColors;
customTitle?: string;
defaultTitle?: string;
titlePrefixIcon?: string;
}) {
this.width = width;
this.height = height;
this.hideBorder = false;
this.hideTitle = false;
this.border_radius = parseFloat(String(border_radius));
// returns theme based colors with proper overrides and defaults
this.colors = colors;
this.title = customTitle === undefined ? defaultTitle : customTitle;
this.css = "";
this.paddingX = 25;
this.paddingY = 35;
this.titlePrefixIcon = titlePrefixIcon;
this.animations = true;
this.a11yTitle = "";
this.a11yDesc = "";
}
disableAnimations(): void {
this.animations = false;
}
/**
* @param props The props object.
* @param props.title Accessibility title.
* @param props.desc Accessibility description.
*/
setAccessibilityLabel({
title,
desc,
}: {
title: string;
desc: string;
}): void {
this.a11yTitle = title;
this.a11yDesc = desc;
}
/**
* The caller must ensure that the passed `css` string is properly sanitized!
*
* @param value The sanitized CSS to add to the card.
*/
setCSS(value: string): void {
this.css = value;
}
/**
* @param value Whether to hide the border or not.
*/
setHideBorder(value: boolean): void {
this.hideBorder = value;
}
/**
* @param value Whether to hide the title or not.
*/
setHideTitle(value: boolean): void {
if (value && !this.hideTitle) {
this.height -= 30;
}
if (!value && this.hideTitle) {
this.height += 30;
}
this.hideTitle = value;
}
/**
* @param text The title to set.
*/
setTitle(text: string): void {
this.title = text;
}
/**
* @returns The rendered card title.
*/
renderTitle(): string {
const titleText = `
<text
x="0"
y="0"
class="header"
data-testid="header"
>${encodeHTML(this.title)}</text>
`;
const prefixIcon = `
<svg
class="icon"
x="0"
y="-13"
viewBox="0 0 16 16"
version="1.1"
width="16"
height="16"
>
${String(this.titlePrefixIcon)}
</svg>
`;
return `
<g
data-testid="card-title"
transform="translate(${this.paddingX}, ${this.paddingY})"
>
${flexLayout({
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
gap: 25,
}).join("")}
</g>
`;
}
/**
* @returns The rendered card gradient.
*/
renderGradient(): string {
if (typeof this.colors.bgColor !== "object") {
return "";
}
if (!isValidGradient(this.colors.bgColor)) {
throw new Error(`Invalid gradient: ${this.colors.bgColor.join(",")}`);
}
const gradients = this.colors.bgColor.slice(1);
return `
<defs>
<linearGradient
id="gradient"
gradientTransform="rotate(${String(this.colors.bgColor[0])})"
gradientUnits="userSpaceOnUse"
>
${gradients
.map((grad, index) => {
const offset = (index * 100) / (gradients.length - 1);
return `<stop offset="${offset}%" stop-color="#${grad}" />`;
})
.join(",")}
</linearGradient>
</defs>
`;
}
/**
* Retrieves css animations for a card.
*
* @returns Animation css.
*/
getAnimations = (): string => {
return `
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
};
/**
* The caller must ensure that the passed `body` string is properly sanitized!
*
* @param body The sanitized inner body of the card.
* @returns The rendered card.
*/
render(body: string): string {
if (!Number.isFinite(this.border_radius)) {
throw new Error(`Invalid border radius: "${this.border_radius}"`);
}
if (
this.colors.titleColor !== undefined &&
!isPrefixedHexColor(this.colors.titleColor)
) {
throw new Error(`Invalid title color: "${this.colors.titleColor}"`);
}
if (
this.colors.borderColor !== undefined &&
!isPrefixedHexColor(this.colors.borderColor)
) {
throw new Error(`Invalid border color: "${this.colors.borderColor}"`);
}
if (
this.colors.bgColor !== undefined &&
!(typeof this.colors.bgColor === "object"
? isValidGradient(this.colors.bgColor)
: isPrefixedHexColor(this.colors.bgColor))
) {
throw new Error(
`Invalid background color: ${String(this.colors.bgColor)}`,
);
}
return `
<svg
width="${this.width}"
height="${this.height}"
viewBox="0 0 ${this.width} ${this.height}"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="descId"
>
<title id="titleId">${encodeHTML(this.a11yTitle)}</title>
<desc id="descId">${encodeHTML(this.a11yDesc)}</desc>
<style>
.header {
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
fill: ${String(this.colors.titleColor)};
animation: fadeInAnimation 0.8s ease-in-out forwards;
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.header { font-size: 15.5px; }
}
${this.css}
${this.getAnimations()}
${
this.animations
? ""
: `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
}
</style>
${this.renderGradient()}
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="${this.border_radius}"
height="99%"
stroke="${String(this.colors.borderColor)}"
width="${this.width - 1}"
fill="${
typeof this.colors.bgColor === "object"
? "url(#gradient)"
: String(this.colors.bgColor)
}"
stroke-opacity="${this.hideBorder ? 0 : 1}"
/>
${this.hideTitle ? "" : this.renderTitle()}
<g
data-testid="main-card-body"
transform="translate(0, ${
this.hideTitle ? this.paddingX : this.paddingY + 20
})"
>
${body}
</g>
</svg>
`;
}
}
export { Card };
export default Card;
-42
View File
@@ -1,42 +0,0 @@
// @ts-check
const FALLBACK_LOCALE = "en";
/**
* I18n translation class.
*/
class I18n {
/**
* Constructor.
*
* @param {Object} options Options.
* @param {string=} options.locale Locale.
* @param {any} options.translations Translations.
*/
constructor({ locale, translations }) {
this.locale = locale || FALLBACK_LOCALE;
this.translations = translations;
}
/**
* Get translation.
*
* @param {string} str String to translate.
* @returns {string} Translated string.
*/
t(str) {
if (!this.translations[str]) {
throw new Error(`${str} Translation string not found`);
}
if (!this.translations[str][this.locale]) {
throw new Error(
`'${str}' translation not found for locale '${this.locale}'`,
);
}
return this.translations[str][this.locale];
}
}
export { I18n };
+49
View File
@@ -0,0 +1,49 @@
const FALLBACK_LOCALE = "en";
/**
* I18n translation class.
*/
class I18n<Translations extends Record<string, Record<string, string>>> {
locale: string;
translations: Translations;
/**
* @param props Constructor arguments.
* @param props.locale Locale.
* @param props.translations Translations.
*/
constructor({
locale,
translations,
}: {
locale?: string;
translations: Translations;
}) {
this.locale = locale || FALLBACK_LOCALE;
this.translations = translations;
}
/**
* Get translation.
*
* @param str String to translate.
* @returns Translated string.
*/
t(str: keyof Translations & string): string {
const translation = this.translations[str];
if (!translation) {
throw new Error(`${str} Translation string not found`);
}
const localized = translation[this.locale];
if (!localized) {
throw new Error(
`'${str}' translation not found for locale '${this.locale}'`,
);
}
return localized;
}
}
export { I18n };
-145
View File
@@ -1,145 +0,0 @@
// @ts-check
import { themes } from "../themes/index.js";
/**
* Checks if a string is a valid hex color.
*
* @param {string} hexColor String to check.
* @returns {boolean} True if the given string is a valid hex color.
*/
const isValidHexColor = (hexColor) => {
return new RegExp(
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/,
).test(hexColor);
};
/**
* Check if the given string is a valid gradient.
*
* @param {string[]} colors Array of colors.
* @returns {boolean} True if the given string is a valid gradient.
*/
const isValidGradient = (colors) => {
return (
colors.length > 2 &&
colors.slice(1).every((color) => isValidHexColor(color))
);
};
/**
* Retrieves a gradient if color has more than one valid hex codes else a single color.
*
* @param {string} color The color to parse.
* @param {string | string[]} fallbackColor The fallback color.
* @returns {string | string[]} The gradient or color.
*/
const fallbackColor = (color, fallbackColor) => {
let gradient = null;
let colors = color ? color.split(",") : [];
if (colors.length > 1 && isValidGradient(colors)) {
gradient = colors;
}
return (
(gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
fallbackColor
);
};
/**
* Object containing card colors.
* @typedef {{
* titleColor: string;
* iconColor: string;
* textColor: string;
* bgColor: string | string[];
* borderColor: string;
* ringColor: string;
* }} CardColors
*/
/**
* Returns theme based colors with proper overrides and defaults.
*
* @param {Object} args Function arguments.
* @param {string=} args.title_color Card title color.
* @param {string=} args.text_color Card text color.
* @param {string=} args.icon_color Card icon color.
* @param {string=} args.bg_color Card background color.
* @param {string=} args.border_color Card border color.
* @param {string=} args.ring_color Card ring color.
* @param {string=} args.theme Card theme.
* @returns {CardColors} Card colors.
*/
const getCardColors = ({
title_color,
text_color,
icon_color,
bg_color,
border_color,
ring_color,
theme,
}) => {
const defaultTheme = themes["default"];
const isThemeProvided =
theme !== null && theme !== undefined && theme in themes;
// @ts-ignore
const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme;
const defaultBorderColor =
"border_color" in selectedTheme
? selectedTheme.border_color
: // @ts-ignore
defaultTheme.border_color;
// get the color provided by the user else the theme color
// finally if both colors are invalid fallback to default theme
const titleColor = fallbackColor(
title_color || selectedTheme.title_color,
"#" + defaultTheme.title_color,
);
// get the color provided by the user else the theme color
// finally if both colors are invalid we use the titleColor
const ringColor = fallbackColor(
// @ts-ignore
ring_color || selectedTheme.ring_color,
titleColor,
);
const iconColor = fallbackColor(
icon_color || selectedTheme.icon_color,
"#" + defaultTheme.icon_color,
);
const textColor = fallbackColor(
text_color || selectedTheme.text_color,
"#" + defaultTheme.text_color,
);
const bgColor = fallbackColor(
bg_color || selectedTheme.bg_color,
"#" + defaultTheme.bg_color,
);
const borderColor = fallbackColor(
border_color || defaultBorderColor,
"#" + defaultBorderColor,
);
if (
typeof titleColor !== "string" ||
typeof textColor !== "string" ||
typeof ringColor !== "string" ||
typeof iconColor !== "string" ||
typeof borderColor !== "string"
) {
throw new Error(
"Unexpected behavior, all colors except background should be string.",
);
}
return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
};
export { fallbackColor, getCardColors };
+212
View File
@@ -0,0 +1,212 @@
import { themes } from "../themes/index.js";
/** Matches a 3-, 4-, 6-, or 8-digit hex color with no leading `#`. */
const HEX_COLOR =
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{3})$/;
/**
* Checks if a value is a bare hex color, i.e. hex digits with no `#` prefix
* (`"f00"`, `"ffffff"`). This is the form user-supplied color params and
* gradient stops arrive in.
*
* @param value Value to check.
* @returns True if the value is a bare hex color.
*/
const isBareHexColor = (value: unknown): boolean => {
return typeof value === "string" && HEX_COLOR.test(value);
};
/**
* Checks if a value is a `#`-prefixed hex color (`"#f00"`, `"#ffffff"`). This
* is the form colors take once resolved by {@link getCardColors}, i.e. right
* before they are written into the SVG.
*
* @param value Value to check.
* @returns True if the value is a `#`-prefixed hex color.
*/
const isPrefixedHexColor = (value: unknown): boolean => {
return (
typeof value === "string" &&
value.startsWith("#") &&
HEX_COLOR.test(value.slice(1))
);
};
/**
* Checks if the given parts form a valid gradient: a finite numeric angle
* followed by at least two bare-hex color stops, e.g. `["90", "f00", "0f0"]`.
* The angle is written into the SVG `gradientTransform="rotate(...)"`.
*
* @param parts Gradient parts: `[angle, ...stops]`.
* @returns True if the parts form a valid gradient.
*/
const isValidGradient = (parts: Array<string>): boolean => {
const [angle, ...stops] = parts;
return (
stops.length >= 2 &&
angle !== undefined &&
angle.trim() !== "" &&
Number.isFinite(Number(angle)) &&
stops.every(isBareHexColor)
);
};
/**
* Checks if a string is a valid input for a color or gradient.
*
* @param color String to check, may be null or undefined.
* @returns True if the given string is a valid input.
*/
const isValidColorInput = (color: string | null | undefined): boolean => {
if (color === null || color === undefined) {
return true;
}
return isValidGradient(color.split(",")) || isBareHexColor(color);
};
/**
* Iterates over a collection of colors inputs and verifies that each is a valid color or gradient.
*
* @param colors Object whose values are checked as valid color inputs.
* @return The first key where the associated input value is not valid. null if all inputs are valid.
*/
const findInvalidColor = (
colors: Record<string, string | null | undefined>,
): string | null => {
for (const [key, value] of Object.entries(colors)) {
if (!isValidColorInput(value)) {
return key;
}
}
return null;
};
/**
* Retrieves a gradient if color has more than one valid hex codes else a single color.
*
* @param color The color to parse.
* @param fallbackColor The fallback color.
* @returns The gradient or color.
*/
const fallbackColor = (
color: string | undefined,
fallbackColor: string | Array<string>,
): string | Array<string> => {
const colors = color ? color.split(",") : [];
if (colors.length > 1 && isValidGradient(colors)) {
return colors;
}
if (color !== undefined && isBareHexColor(color)) {
return `#${color}`;
}
return fallbackColor;
};
/**
* Object containing card colors.
*/
interface CardColors {
titleColor: string;
iconColor: string;
textColor: string;
bgColor: string | Array<string>;
borderColor: string;
ringColor: string;
}
/**
* Returns theme based colors with proper overrides and defaults.
*
* @param props Function arguments.
* @param props.title_color Card title color.
* @param props.text_color Card text color.
* @param props.icon_color Card icon color.
* @param props.bg_color Card background color.
* @param props.border_color Card border color.
* @param props.ring_color Card ring color.
* @param props.theme Card theme.
* @returns Card colors.
*/
const getCardColors = ({
title_color,
text_color,
icon_color,
bg_color,
border_color,
ring_color,
theme,
}: {
title_color?: string | undefined;
text_color?: string | undefined;
icon_color?: string | undefined;
bg_color?: string | undefined;
border_color?: string | undefined;
ring_color?: string | undefined;
theme?: string | undefined;
}): CardColors => {
const defaultTheme = themes.default;
const isThemeProvided = theme !== undefined && theme in themes;
const selectedTheme = isThemeProvided
? themes[theme as keyof typeof themes]
: defaultTheme;
const defaultBorderColor =
"border_color" in selectedTheme
? selectedTheme.border_color
: defaultTheme.border_color;
// get the color provided by the user else the theme color
// finally if both colors are invalid fallback to default theme
const titleColor = fallbackColor(
title_color || selectedTheme.title_color,
"#" + defaultTheme.title_color,
);
// get the color provided by the user else the theme color
// finally if both colors are invalid we use the titleColor
// NOTE: no built-in theme defines `ring_color`, so it falls back to the title color.
const ringColor = fallbackColor(ring_color, titleColor);
const iconColor = fallbackColor(
icon_color || selectedTheme.icon_color,
"#" + defaultTheme.icon_color,
);
const textColor = fallbackColor(
text_color || selectedTheme.text_color,
"#" + defaultTheme.text_color,
);
const bgColor = fallbackColor(
bg_color || selectedTheme.bg_color,
"#" + defaultTheme.bg_color,
);
const borderColor = fallbackColor(
border_color || defaultBorderColor,
"#" + defaultBorderColor,
);
if (
typeof titleColor !== "string" ||
typeof textColor !== "string" ||
typeof ringColor !== "string" ||
typeof iconColor !== "string" ||
typeof borderColor !== "string"
) {
throw new Error(
"Unexpected behavior, all colors except background should be string.",
);
}
return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor };
};
export {
fallbackColor,
getCardColors,
findInvalidColor,
isValidGradient,
isBareHexColor,
isPrefixedHexColor,
};
-78
View File
@@ -1,78 +0,0 @@
// @ts-check
/**
* @param {string | undefined} value Comma-separated string.
* @returns {string[] | undefined} Parsed string values.
*/
const parseCsv = (value) => {
if (!value) {
return undefined;
}
return value.split(",");
};
/**
* @param {Record<string, string | undefined>} env Environment variables to inspect.
* @returns {{name: string, value: string}[]} Personal access tokens found in the environment.
*/
const parsePATsFromEnv = (env) => {
return Object.keys(env)
.filter((key) => /PAT_\d*$/.exec(key))
.map((name) => ({
name,
value: env[name],
}));
};
/**
* @returns {Record<string, string | undefined>} `process.env` if available, otherwise `{}`.
*/
const getDefaultEnv = () => {
if (typeof process !== "undefined" && process?.env) {
return process.env;
}
return {};
};
/**
* @param {Partial<ReturnType<typeof loadConfigFromEnv>>} config (Partial) config values to normalize.
* @returns {{
* whitelist: string[] | undefined,
* gistWhitelist: string[] | undefined,
* excludeRepositories: string[],
* fetchMultiPageStars: string | undefined,
* pats: {name: string, value: string}[],
* }} Normalized config object with defaults applied.
*/
const normalizeConfig = (config = {}) => {
return {
whitelist: config.whitelist,
gistWhitelist: config.gistWhitelist,
excludeRepositories: config.excludeRepositories || [],
fetchMultiPageStars: config.fetchMultiPageStars,
pats: config.pats || [],
};
};
let currentConfig;
/**
* @param {Record<string, string | undefined>} env Environment variables used to build the runtime config.
*/
export const loadConfigFromEnv = (env = getDefaultEnv()) => {
const whitelist = parseCsv(env.WHITELIST);
const gistWhitelist = parseCsv(env.GIST_WHITELIST);
const excludeRepositories = parseCsv(env.EXCLUDE_REPO) || [];
currentConfig = normalizeConfig({
whitelist,
gistWhitelist,
excludeRepositories,
fetchMultiPageStars: env.FETCH_MULTI_PAGE_STARS,
pats: parsePATsFromEnv(env),
});
};
loadConfigFromEnv();
export const getConfig = () => currentConfig;
+83
View File
@@ -0,0 +1,83 @@
type Env = Record<string, string | undefined>;
interface PersonalAccessToken {
name: string;
value: string;
}
interface Config {
whitelist: Array<string> | undefined;
gistWhitelist: Array<string> | undefined;
excludeRepositories: Array<string>;
fetchMultiPageStars: string | undefined;
pats: Array<PersonalAccessToken>;
}
/**
* @param value Comma-separated string.
* @returns Parsed string values.
*/
const parseCsv = (value: string | undefined): Array<string> | undefined => {
if (!value) {
return undefined;
}
return value.split(",");
};
/**
* @param env Environment variables to inspect.
* @returns Personal access tokens found in the environment.
*/
const parsePATsFromEnv = (env: Env): Array<PersonalAccessToken> => {
return Object.keys(env)
.filter((key) => /PAT_\d*$/.exec(key))
.map((name) => ({
name,
value: env[name] ?? "",
}));
};
/**
* @returns `process.env` if available, otherwise `{}`.
*/
const getDefaultEnv = (): Env => {
const processEnv = (globalThis as { process?: { env?: Env } }).process?.env;
return processEnv ?? {};
};
/**
* @param config (Partial) config values to normalize.
* @returns Normalized config object with defaults applied.
*/
const normalizeConfig = (config: Partial<Config> = {}): Config => {
return {
whitelist: config.whitelist,
gistWhitelist: config.gistWhitelist,
excludeRepositories: config.excludeRepositories ?? [],
fetchMultiPageStars: config.fetchMultiPageStars,
pats: config.pats ?? [],
};
};
let currentConfig: Config;
/**
* @param env Environment variables used to build the runtime config.
*/
export const loadConfigFromEnv = (env: Env = getDefaultEnv()): void => {
const whitelist = parseCsv(env["WHITELIST"]);
const gistWhitelist = parseCsv(env["GIST_WHITELIST"]);
const excludeRepositories = parseCsv(env["EXCLUDE_REPO"]) ?? [];
currentConfig = normalizeConfig({
whitelist,
gistWhitelist,
excludeRepositories,
fetchMultiPageStars: env["FETCH_MULTI_PAGE_STARS"],
pats: parsePATsFromEnv(env),
});
};
loadConfigFromEnv();
export const getConfig = (): Config => currentConfig;
-9
View File
@@ -1,9 +0,0 @@
// @ts-check
/**
* Valid owner affiliations for GitHub API queries.
* @type {Array<string>}
*/
const OWNER_AFFILIATIONS = ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"];
export { OWNER_AFFILIATIONS };
+10
View File
@@ -0,0 +1,10 @@
/**
* Valid owner affiliations for GitHub API queries.
*/
const OWNER_AFFILIATIONS: Array<string> = [
"OWNER",
"COLLABORATOR",
"ORGANIZATION_MEMBER",
];
export { OWNER_AFFILIATIONS };
@@ -1,14 +1,12 @@
// @ts-check
import { OWNER_AFFILIATIONS } from "./constants.js";
/**
* @type {string} A general message to ask user to try again later.
* A general message to ask user to try again later.
*/
const TRY_AGAIN_LATER = "Please try again later";
/**
* @type {Object<string, string>} A map of error types to secondary error messages.
* A map of error types to secondary error messages.
*/
const SECONDARY_ERROR_MESSAGES = {
MAX_RETRY:
@@ -28,16 +26,21 @@ const SECONDARY_ERROR_MESSAGES = {
* Custom error class to handle custom GRS errors.
*/
class CustomError extends Error {
type: string;
secondaryMessage: string;
/**
* Custom error constructor.
*
* @param {string} message Error message.
* @param {string} type Error type.
* @param message Error message.
* @param type Error type.
*/
constructor(message, type) {
constructor(message: string, type: string) {
super(message);
this.type = type;
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type;
type PartialRecord = Partial<Record<string, string>>;
this.secondaryMessage =
(SECONDARY_ERROR_MESSAGES as PartialRecord)[type] ?? type;
}
static MAX_RETRY = "MAX_RETRY";
@@ -53,13 +56,16 @@ class CustomError extends Error {
* Missing query parameter class.
*/
class MissingParamError extends Error {
missedParams: Array<string>;
secondaryMessage: string | undefined;
/**
* Missing query parameter error constructor.
*
* @param {string[]} missedParams An array of missing parameters names.
* @param {string=} secondaryMessage Optional secondary message to display.
* @param missedParams An array of missing parameters names.
* @param secondaryMessage Optional secondary message to display.
*/
constructor(missedParams, secondaryMessage) {
constructor(missedParams: Array<string>, secondaryMessage?: string) {
const msg = `Missing params ${missedParams
.map((p) => `"${p}"`)
.join(", ")} make sure you pass the parameters in URL`;
@@ -72,10 +78,10 @@ class MissingParamError extends Error {
/**
* Retrieve secondary message from an error object.
*
* @param {Error} err The error object.
* @returns {string|undefined} The secondary message if available, otherwise undefined.
* @param err The error object.
* @returns The secondary message if available, otherwise undefined.
*/
const retrieveSecondaryMessage = (err) => {
const retrieveSecondaryMessage = (err: Error): string | undefined => {
return "secondaryMessage" in err && typeof err.secondaryMessage === "string"
? err.secondaryMessage
: undefined;
@@ -1,16 +1,14 @@
// @ts-check
import { encodeHTML } from "./html.js";
import { splitWrappedText } from "./render.js";
/**
* Retrieves num with suffix k(thousands) precise to given decimal places.
*
* @param {number} num The number to format.
* @param {number=} precision The number of decimal places to include.
* @returns {string|number} The formatted number.
* @param num The number to format.
* @param precision The number of decimal places to include.
* @returns The formatted number.
*/
const kFormatter = (num, precision) => {
const kFormatter = (num: number, precision?: number): string | number => {
const abs = Math.abs(num);
const sign = Math.sign(num);
@@ -22,17 +20,17 @@ const kFormatter = (num, precision) => {
return sign * abs;
}
return sign * parseFloat((abs / 1000).toFixed(1)) + "k";
return `${sign * parseFloat((abs / 1000).toFixed(1))}k`;
};
/**
* Convert bytes to a human-readable string representation.
*
* @param {number} bytes The number of bytes to convert.
* @returns {string} The human-readable representation of bytes.
* @param bytes The number of bytes to convert.
* @returns The human-readable representation of bytes.
* @throws {Error} If bytes is negative or too large.
*/
const formatBytes = (bytes) => {
const formatBytes = (bytes: number): string => {
if (bytes < 0) {
throw new Error("Bytes must be a non-negative number");
}
@@ -45,23 +43,29 @@ const formatBytes = (bytes) => {
const base = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(base));
if (i >= sizes.length) {
const unit = sizes[i];
if (unit === undefined) {
throw new Error("Bytes is too large to convert to a human-readable string");
}
return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`;
return `${(bytes / Math.pow(base, i)).toFixed(1)} ${unit}`;
};
/**
* Split text over multiple lines based on the card width.
*
* @param {string} text Text to split.
* @param {number} width Available wrap width in px.
* @param {number} fontSize Font size in px.
* @param {number} maxLines Maximum number of lines.
* @returns {string[]} Array of lines.
* @param text Text to split.
* @param width Available wrap width in px.
* @param fontSize Font size in px.
* @param maxLines Maximum number of lines.
* @returns Array of lines.
*/
const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => {
const wrapTextMultiline = (
text: string,
width: number,
fontSize: number,
maxLines = 3,
): Array<string> => {
const wrapped = splitWrappedText(text, fontSize, width);
const lines = wrapped
.map((line) => encodeHTML(line.trim()))
@@ -69,7 +73,11 @@ const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => {
// Add "..." to the last line if the text exceeds maxLines
if (wrapped.length > maxLines) {
lines[maxLines - 1] += "...";
const lastIndex = maxLines - 1;
const lastLine = lines[lastIndex];
if (lastLine !== undefined) {
lines[lastIndex] = `${lastLine}...`;
}
}
// Remove empty lines if text fits in less than maxLines lines

Some files were not shown because too many files have changed in this diff Show More