├── .eslintrc.json
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── codeql-analysis.yml
│ └── scheduled-builds.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode
└── settings.json
├── .yarn
└── install-state.gz
├── .yarnrc.yml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING
├── LICENSE
├── README.md
├── archive
├── [legal].tsx
└── privacy-policy.md.bkp
├── cache
├── appcache.ts
└── plugins-cache.ts
├── changelog.md
├── components.json
├── components
├── AllPluginsMultiView.test.tsx
├── AllPluginsMultiView.tsx
├── AuthorAndDescription.test.tsx
├── AuthorAndDescription.tsx
├── BetaEntryCard.test.tsx
├── BetaEntryCard.tsx
├── CardAnnotations.test.tsx
├── CardAnnotations.tsx
├── Category.test.tsx
├── Category.tsx
├── Comments.tsx
├── Divider.test.tsx
├── Divider.tsx
├── EthicalAd.tsx
├── Faq.test.tsx
├── Faq.tsx
├── FavPluginUpdates.test.tsx
├── FavPluginUpdates.tsx
├── Favorites.test.tsx
├── Favorites.tsx
├── Footer.test.tsx
├── Footer.tsx
├── GivePluginRating.tsx
├── GrowmeScript.tsx
├── Header.test.tsx
├── Header.tsx
├── InfoBar.test.tsx
├── InfoBar.tsx
├── LinkButton.test.tsx
├── LinkButton.tsx
├── Navbar.test.tsx
├── Navbar.tsx
├── PluginCard.test.tsx
├── PluginCard.tsx
├── PluginEcosystemStats.test.tsx
├── PluginEcosystemStats.tsx
├── PluginsBanner.tsx
├── PluginsComparisionTable.test.tsx
├── PluginsComparisionTable.tsx
├── PluginsListView.test.tsx
├── PluginsListView.tsx
├── PluginsMultiView.test.tsx
├── PluginsMultiView.tsx
├── PluginsTableView.test.tsx
├── PluginsTableView.tsx
├── Score.test.tsx
├── Score.tsx
├── Sidebar.tsx
├── Sponsorship.tsx
├── StarRating.tsx
├── StarRatingInput.tsx
├── background
│ └── wave-background.tsx
├── home
│ ├── Highlights.test.tsx
│ ├── Highlights.tsx
│ ├── MostDownloaded.test.tsx
│ ├── MostDownloaded.tsx
│ ├── SubstackNewsletter.test.tsx
│ └── SubstackNewsletter.tsx
├── plugins
│ ├── PluginSection.tsx
│ ├── dataview.tsx
│ ├── obsidian-tasks-plugin.tsx
│ └── theme.ts
├── post
│ ├── LatestPosts.test.tsx
│ ├── LatestPosts.tsx
│ ├── PostIcon.test.tsx
│ └── PostIcon.tsx
└── ui
│ ├── button.tsx
│ ├── card.tsx
│ ├── carousel.tsx
│ ├── checkbox.tsx
│ ├── dialog.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── navigation-menu.tsx
│ ├── progress.tsx
│ ├── spinner.tsx
│ └── textarea.tsx
├── constants.ts
├── data
├── indexnow.json
├── siteData.test.ts
├── siteData.ts
└── wrapped-2024.json
├── docs-readme.md
├── domain
├── plugins
│ └── models
│ │ ├── Plugin.ts
│ │ └── PluginRatingInfo.ts
├── remark
│ ├── ad-hander.ts
│ ├── plugin-hander.ts
│ └── plugin-image-handler.ts
├── scorer
│ ├── ScorerUtils.ts
│ └── models
│ │ └── PluginMetrics.model.ts
└── suggestions
│ ├── index.ts
│ └── models
│ └── index.ts
├── eslint.config.mjs
├── hooks
├── useCustomScore.ts
├── useCustomScoreWithScoreUpdater.ts
├── useIsLessThanLarge.ts
├── useScoreUpdater.ts
└── useUser.ts
├── jest.config.ts
├── jsdoc.config.json
├── lib
├── abstractions.ts
├── analytics
│ ├── analytics.tsx
│ ├── strategies
│ │ └── plausible.strategy.tsx
│ └── types
│ │ └── analytics.ts
├── customThemes.test.ts
├── customThemes.ts
├── environment.ts
├── feature-flag
│ ├── feature-flags.tsx
│ └── types
│ │ └── flags.ts
├── flags.ts
├── jsonLdSchema.test.ts
├── jsonLdSchema.ts
├── legalDocs.ts
├── plugins.ts
├── posts.test.ts
├── posts.ts
├── scorer.ts
├── supabase-server.ts
├── supabase.ts
└── utils.ts
├── load-dot-env.ps1
├── next-env.d.ts
├── next-sitemap.config.js
├── next.config.js
├── package.json
├── pages
├── 404.tsx
├── _app.tsx
├── _document.tsx
├── _responsive-layout.tsx
├── about.tsx
├── auth
│ └── callback.tsx
├── bases-support.tsx
├── beta
│ ├── index.tsx
│ ├── plugins.tsx
│ └── themes.tsx
├── categories
│ ├── [slug].tsx
│ └── index.tsx
├── cookie-policy.tsx
├── favorites.tsx
├── index.tsx
├── info.tsx
├── migrate.tsx
├── most-downloaded.tsx
├── new.tsx
├── plugins
│ ├── [slug].tsx
│ └── index.tsx
├── posts
│ ├── 2024-12-07-wrapped-2024.tsx
│ ├── [slug].tsx
│ ├── index.tsx
│ └── workflows.tsx
├── privacy-policy.tsx
├── profile.tsx
├── scorer
│ ├── build.tsx
│ └── index.tsx
├── share.tsx
├── tags
│ ├── [slug].tsx
│ └── index.tsx
├── test.tsx
├── timeline.tsx
├── tools
│ └── dataview-query-wizard.tsx
├── trending.tsx
└── updates.tsx
├── postcss.config.js
├── posts
├── 2024-11-10-weekly-plugin-updates.md
├── 2024-11-17-weekly-plugin-updates.md
├── 2024-11-24-weekly-plugin-updates.md
├── 2024-12-01-weekly-plugin-updates.md
├── 2024-12-07-wrapped-2024.md
├── 2024-12-08-weekly-plugin-updates.md
├── 2024-12-11-top-7-obsidian-plugins-for-daily-journaling.md
├── 2024-12-15-weekly-plugin-updates.md
├── 2024-12-18-writing-stats-essential-plugins.md
├── 2024-12-22-weekly-plugin-updates.md
├── 2024-12-28-obsidian-plugin-scoring-system.md
├── 2025-01-05-weekly-plugin-updates.md
├── 2025-01-12-weekly-plugin-updates.md
├── 2025-01-18-building-a-custom-score-function.md
├── 2025-01-19-weekly-plugin-updates.md
├── 2025-01-26-weekly-plugin-updates.md
├── 2025-02-03-weekly-plugin-updates.md
├── 2025-02-16-weekly-plugin-updates.md
├── 2025-02-24-weekly-plugin-updates.md
├── 2025-03-09-weekly-plugin-updates.md
├── 2025-03-10-weekly-plugin-updates.md
├── 2025-03-18-weekly-plugin-updates.md
├── 2025-03-23-weekly-plugin-updates.md
├── 2025-03-30-weekly-plugin-updates.md
├── 2025-04-08-weekly-plugin-updates.md
├── 2025-04-10-obsidian-dataview-query-wizard.md
├── 2025-04-13-weekly-plugin-updates.md
├── 2025-04-16-publish-plugins.md
├── 2025-04-20-weekly-plugin-updates.md
├── 2025-04-22-recipes-plugins.md
├── 2025-04-27-weekly-plugin-updates.md
├── 2025-05-01-spaced-repetition-plugins.md
├── 2025-05-04-weekly-plugin-updates.md
├── 2025-05-11-weekly-plugin-updates.md
├── 2025-05-14-music-notation-plugins.md
├── 2025-05-18-weekly-plugin-updates.md
├── 2025-05-25-weekly-plugin-updates.md
├── 2025-05-30-zotero-plugins.md
├── 2025-06-01-weekly-plugin-updates.md
├── 2025-06-08-weekly-updates.md
├── 2025-06-10-custom-callouts-in-obsidian.md
├── 2025-06-13-callout-plugins.md
├── 2025-06-15-weekly-updates.md
├── 2025-06-23-weekly-updates.md
├── 2025-06-29-weekly-updates.md
├── 2025-07-06-weekly-updates.md
├── 2025-07-10-chart-plugins.md
├── 2025-07-13-weekly-updates.md
├── 2025-07-18-pomodoro-plugins.md
├── 2025-07-20-weekly-updates.md
├── 2025-07-27-weekly-updates.md
├── 2025-08-03-weekly-updates.md
├── 2025-08-10-weekly-updates.md
├── 2025-08-24-weekly-updates.md
├── 2025-08-31-weekly-updates.md
├── 2025-09-10-weekly-updates.md
├── 2025-09-14-weekly-updates.md
├── 2025-09-24-weekly-updates.md
└── 2025-10-08-weekly-updates.md
├── prisma
└── schema.prisma
├── public
├── a944fdca7e16402a80e17aead9645552.txt
├── bcee69848e584efbac6b1dcbadaa8c64.txt
├── data
│ └── plugins-history.json
├── docs
│ ├── ScorerUtils.html
│ ├── ScorerUtils.ts.html
│ ├── global.html
│ ├── index.html
│ ├── models_PluginMetrics.model.ts.html
│ ├── scripts
│ │ ├── app.min.js
│ │ ├── linenumber.js
│ │ └── search.js
│ └── styles
│ │ ├── app.min.css
│ │ ├── iframe.css
│ │ ├── prettify-jsdoc.css
│ │ ├── prettify-tomorrow.css
│ │ └── reset.css
├── favicon-64.png
├── favicon.ico
├── images
│ ├── 2024-12-22-weekly-plugin-updates-1.webp
│ ├── 2025-01-05-weekly-plugin-updates-1.webp
│ ├── 2025-01-12-weekly-plugin-updates.webp
│ ├── 2025-01-19-weekly-plugin-updates.webp
│ ├── 2025-01-25-weekly-plugin-updates.webp
│ ├── 2025-02-01-weekly-plugin-updates.webp
│ ├── 2025-02-24-weekly-plugin-updates.webp
│ ├── 2025-03-10-weekly-plugin-updates-1.webp
│ ├── 2025-03-16-weekly-plugin-updates.webp
│ ├── 2025-03-18-weekly-plugin-updates-1.webp
│ ├── 2025-03-30-weekly-plugin-updates.webp
│ ├── 2025-04-08-weekly-plugin-updates.webp
│ ├── 2025-04-13-weekly-plugin-updates.webp
│ ├── 2025-04-16-publish-workflow-og.png
│ ├── 2025-04-16-publish-workflow.webp
│ ├── 2025-04-20-weekly-plugin-updates.webp
│ ├── 2025-04-22-recipes-workflow-og.webp
│ ├── 2025-04-22-recipes-workflow.webp
│ ├── 2025-04-27-weekly-plugin-updates.webp
│ ├── 2025-05-01-spaced-repetition-workflow-og.png
│ ├── 2025-05-01-spaced-repetition-workflow.webp
│ ├── 2025-05-04-weekly-plugin-updates.webp
│ ├── 2025-05-11-weekly-plugin-updates.webp
│ ├── 2025-05-14-sheet-music-workflow-og.webp
│ ├── 2025-05-14-sheet-music-workflow.webp
│ ├── 2025-05-30-zotero-plugins.webp
│ ├── 2025-07-10-charts-plugins.webp
│ ├── 2025-07-18-pomodoro-plugins.webp
│ ├── calendar-128.png
│ ├── calendar.png
│ ├── chibi-obsidian-wizard.png
│ ├── custom-callouts-in-obsidian-with-css-snippets.webp
│ ├── custom-callouts-in-obsidian.png
│ ├── custom-scorer-example-1.png
│ ├── daily-journaling.webp
│ ├── default-callout-in-obsidian.png
│ ├── empty-icon.svg
│ ├── empty.svg
│ ├── favicon.png
│ ├── feature-score-intro.gif
│ ├── gk-coding-1.webp
│ ├── gk-coding.webp
│ ├── how-to-favorite-step-1.png
│ ├── how-to-favorite-step-2.png
│ ├── how-to-favorite-step-3.png
│ ├── introducing-categories-and-tags.png
│ ├── new-og.webp
│ ├── obsidian-stats-ogImage.png
│ ├── obsidian-weekly-updates.webp
│ ├── plugin-score.png
│ ├── plugin-updates-banner.png
│ ├── sample-scores.gif
│ ├── score-example-1.png
│ ├── score-example.png
│ ├── scorer
│ │ ├── all-plugins-with-score-original.png
│ │ ├── all-plugins-with-score.png
│ │ ├── empty-builder-original.png
│ │ ├── empty-builder.png
│ │ ├── normalized-download-count-scorer-original.png
│ │ ├── normalized-download-count-scorer.png
│ │ ├── save-and-use-original.png
│ │ ├── save-and-use.png
│ │ ├── scorer-list-original.png
│ │ ├── scorer-list-with-scorers-original.png
│ │ ├── scorer-list-with-scorers.png
│ │ └── scorer-list.png
│ ├── scoring-plugins-banner.webp
│ ├── trending-plugins.bkp-2.webp
│ ├── trending-plugins.bkp.webp
│ ├── trending-plugins.webp
│ └── undraw
│ │ ├── empty.svg
│ │ ├── moving_2cfm.png
│ │ ├── undraw_awards_fieb.svg
│ │ ├── undraw_celebration_re_kc9k.svg
│ │ ├── undraw_community_re_cyrm.svg
│ │ ├── undraw_lightbulb_moment_re_ulyo.svg
│ │ ├── undraw_programmer_re_owql.svg
│ │ └── undraw_with-love.svg
├── logo-512-removebg-preview.png
├── logo-512.png
├── logo-64.png
├── logo-apple-touch-icon-180x180.png
├── plugin-scores-2024-12-28.json
├── robots.txt
├── sitemap-1.xml
├── vercel.svg
├── yandex_27ef3a93625b3d8e.html
└── yandex_57114eb967a05d89.html
├── store
└── scorer-store.ts
├── styles
├── Home.module.css
└── globals.css
├── tailwind.config.js
├── tsconfig.json
├── utils
├── datetime.test.ts
├── datetime.ts
├── favorites.test.ts
├── favorites.ts
├── generate-rss.js
├── indexnow.mjs
├── plugins.test.ts
└── plugins.ts
├── vercel.json
└── yarn.lock
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "settings": {
4 | "react": {
5 | "version": "detect"
6 | }
7 | },
8 | "rules": {
9 | "prettier/prettier": "off",
10 | "@typescript-eslint/no-unused-vars": "warn",
11 | "react/react-in-jsx-scope": "off",
12 | "@typescript-eslint/no-explicit-any": "warn",
13 | "@typescript-eslint/no-unused-expressions": "warn",
14 | "react/no-unescaped-entities": "warn",
15 | "react/prop-types": "off",
16 | "react/jsx-key": "warn",
17 | "react/jsx-no-target-blank": "warn",
18 | "@typescript-eslint/no-require-imports": "warn",
19 | "react/display-name": "off",
20 | "@typescript-eslint/no-empty-object-type": "warn",
21 | "prefer-const": "warn",
22 | "no-extra-boolean-cast": "warn",
23 | "no-unexpected-multiline": "warn",
24 | "no-control-regex": "warn",
25 | "no-undef": "off",
26 | "no-redeclare": "warn"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: ganesshkumar
2 | buy_me_a_coffee: ganeshkumar
3 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: '[BUG]'
5 | labels: ''
6 | assignees: ganesshkumar
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 |
28 | - OS: [e.g. iOS]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 |
33 | - Device: [e.g. iPhone6]
34 | - OS: [e.g. iOS8.1]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: '[Feature Request]'
5 | labels: ''
6 | assignees: ganesshkumar
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe. **
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **Additional context**
19 | Add any other context or screenshots about the feature request here.
20 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: 'CodeQL'
13 |
14 | on:
15 | push:
16 | branches: [master]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [master]
20 | schedule:
21 | - cron: '28 14 * * 3'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: ['javascript']
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support
38 |
39 | steps:
40 | - name: Checkout repository
41 | uses: actions/checkout@v2
42 |
43 | # Initializes the CodeQL tools for scanning.
44 | - name: Initialize CodeQL
45 | uses: github/codeql-action/init@v1
46 | with:
47 | languages: ${{ matrix.language }}
48 | # If you wish to specify custom queries, you can do so here or in a config file.
49 | # By default, queries listed here will override any specified in a config file.
50 | # Prefix the list here with "+" to use these queries and those in the config file.
51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
52 |
53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
54 | # If this step fails, then you should remove it and run the build manually (see below)
55 | - name: Autobuild
56 | uses: github/codeql-action/autobuild@v1
57 |
58 | # ℹ️ Command-line programs to run using the OS shell.
59 | # 📚 https://git.io/JvXDl
60 |
61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
62 | # and modify them (or add more) to build your code if your project
63 | # uses a compiled language
64 |
65 | #- run: |
66 | # make bootstrap
67 | # make release
68 |
69 | - name: Perform CodeQL Analysis
70 | uses: github/codeql-action/analyze@v1
71 |
--------------------------------------------------------------------------------
/.github/workflows/scheduled-builds.yml:
--------------------------------------------------------------------------------
1 | # .github/workflows/scheduled-builds.yml
2 | name: Production Deployment
3 |
4 | on:
5 | schedule:
6 | # Runs "At minute 0 past every 2nd hour" (see https://crontab.guru)
7 | - cron: '0 */2 * * *'
8 | workflow_dispatch:
9 |
10 | jobs:
11 | build:
12 | name: Trigger Site Rebuild
13 | runs-on: ubuntu-latest
14 | environment: production
15 | steps:
16 | - name: Deploy master branch
17 | run: curl ${{ secrets.deploy_master_branch }}
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | public/rss.xml
38 | public/sitemap.xml
39 | public/sitemap-0.xml
40 | public/weekly-plugin-updates-rss.xml
41 |
42 | get-assets
43 | *.dot
44 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .next
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "es5",
5 | "tabWidth": 2
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.insertSpaces": true,
3 | "editor.tabSize": 2
4 | }
5 |
--------------------------------------------------------------------------------
/.yarn/install-state.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/.yarn/install-state.gz
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/CONTRIBUTING:
--------------------------------------------------------------------------------
1 | When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change.
2 |
3 | Please note we have a [code of conduct](./CODE_OF_CONDUCT.md), please follow it in all your interactions with the project.
4 |
5 | ## Pull Requests
6 |
7 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a
8 | build.
9 | 2. Update the README.md file with any relevant information.
10 | 3. Increase the version numbers in package.json and manifest.json to the new version that this
11 | Pull Request would represent. You can run `yarn version` or `npm run version` to update to the
12 | next version automatically.
13 | 4. You may merge the Pull Request in once you have the sign-off by any maintainers or if you
14 | do not have permission to do that, you may request a maintainer to merge it for you.
15 |
16 |
17 | ## Bug Report/Feature Requests
18 |
19 | 1. Please use issues to report a bug or request a feature.
20 | 2. Use the templates available to create a [bug report](.github/ISSUE_TEMPLATE/bug_report.md) or [feature request](.github/ISSUE_TEMPLATE/feature_request.md).
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022-2025 Ganessh Kumar R P
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Obsidian Stats
2 |
3 | Stay informed about the Obsidian plugin ecosystem with Obsidian Stats! Whether you're tracking trending plugins, new releases, or your personal favorites, we've got you covered.
4 |
5 | 
6 | 
7 | 
8 | 
9 |
10 | [](https://github.com/ganesshkumar/obsidian-plugins-stats-ui)
11 | [](LICENSE)
12 |
13 | ## Features
14 |
15 | Explore [Obsidian](https://obsidian.md) plugins with these powerful features:
16 |
17 | - **[New Plugins](https://www.obsidianstats.com/new)** – Discover plugins released in the past 10 days.
18 | - **[Latest Updates](https://www.obsidianstats.com/updates)** – Stay up to date with the latest releases and changelogs.
19 | - **[Trending Plugins](https://www.obsidianstats.com/trending)** – Check out the top 10 trending plugins based on downloads and engagement.
20 | - **[Most Downloaded](https://obsidianstats.com/most-downloaded)** – View the most downloaded plugins over last week, last month and overall timeline.
21 | - **[Favorites](https://www.obsidianstats.com/favorites)** – Track your favorite plugins and get notified of new updates.
22 | - **[Plugin Timeline](https://www.obsidianstats.com/timeline)** – Track additions, removals, and updates over time.
23 | - **[Plugin Sharing](https://obsidianstats.com/share)** – Compare and share plugin lists with others.
24 | - **[Migration Guide](https://www.obsidianstats.com/migrate)** – Export and import your favorite plugin lists across devices.
25 | - **[Scoring System](https://www.obsidianstats.com/scorer)** – Build custom scoring functions to rank plugins based on your criteria.
26 | - **[Tags](https://www.obsidianstats.com/tags)** – Browse plugins by tags to find exactly what you need.
27 | - **[Timeline](https://www.obsidianstats.com/timeline)** – Visualize the history of plugin updates and releases.
28 | - **[Share](https://www.obsidianstats.com/share)** – Share your favorite plugin lists with the community.
29 |
30 | ## Screenshots
31 |
32 | ## License
33 |
34 | [MIT](LICENSE)
35 |
36 | ---
37 |
38 | If you find this tool useful, consider supporting me with a coffee! ☕
39 |
40 |
--------------------------------------------------------------------------------
/archive/[legal].tsx:
--------------------------------------------------------------------------------
1 | import { getAllLegalDocIds, getLegalDocData } from '../lib/legalDocs';
2 | import { GetStaticPaths, GetStaticProps } from 'next';
3 | import { remark } from 'remark';
4 | import html from 'remark-html';
5 | import Header, { IHeaderProps } from '../components/Header';
6 | import Navbar from '../components/Navbar';
7 | import { Footer } from '../components/Footer';
8 |
9 | export const getStaticPaths: GetStaticPaths = async () => {
10 | const paths = getAllLegalDocIds();
11 | return {
12 | paths,
13 | fallback: false,
14 | };
15 | };
16 |
17 | export const getStaticProps: GetStaticProps = async ({ params }) => {
18 | const legalDocData = getLegalDocData(params?.legal as string);
19 | const processedContent = await remark()
20 | .use(html)
21 | .process(legalDocData.content);
22 | const contentHtml = processedContent.toString();
23 |
24 | const title = 'Privacy Policy';
25 | const description = 'Obsidian Stats Privacy Policy';
26 | const canonical = `https://www.obsidianstats.com/privacy-policy`;
27 | const image = `/images/obsidian-stats-ogImage.png`;
28 | const jsonLdSchema = null;
29 |
30 | return {
31 | props: {
32 | title,
33 | description,
34 | canonical,
35 | image,
36 | jsonLdSchema,
37 | legalDocData: {
38 | ...legalDocData,
39 | contentHtml,
40 | },
41 | },
42 | };
43 | };
44 |
45 | interface ILegalDocData {
46 | id: string;
47 | title: string;
48 | content: string;
49 | contentHtml: string;
50 | }
51 |
52 | interface ILegalDocProps extends IHeaderProps {
53 | legalDocData: ILegalDocData;
54 | }
55 |
56 | const Legal = (props: ILegalDocProps) => {
57 | const { legalDocData } = props;
58 | return (
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | {legalDocData.title}
67 |
68 |
71 |
72 |
73 |
74 |
75 |
76 | );
77 | };
78 |
79 | export default Legal;
80 |
--------------------------------------------------------------------------------
/archive/privacy-policy.md.bkp:
--------------------------------------------------------------------------------
1 | ---
2 | title: Privacy Policy
3 | ---
4 |
5 | ## Introduction
6 | Your privacy is important to us. This Privacy Policy explains how we collect, use, and protect your information when you use our website. By accessing or using our website, you agree to the terms of this Privacy Policy.
7 |
8 | ## Information We Collect
9 |
10 | ### 1. **Local Storage Data**
11 | We store certain information in your browser's local storage to enhance your experience, including:
12 | - A list of your favorite plugin IDs.
13 | - Your custom scorer functions.
14 | - The ID of the currently active scorer function.
15 |
16 | This data is stored locally on your browser and is not shared with third parties. It is used solely to provide a personalized experience, such as remembering your preferences between visits.
17 |
18 | ### 2. **Cookies**
19 | We use cookies for the following purposes:
20 | - **Feature Flags and Experiments**: We use Amplitude cookies to manage feature flags and experiments that are essential for the functioning of the website.
21 | - **Analytics**: We use Google Analytics to collect anonymized data about website usage, such as pages visited, session duration, and interactions. This helps us improve website performance and user experience.
22 |
23 | ## How We Use Your Information
24 | The information we collect is used to:
25 | - Improve website functionality and user experience.
26 | - Analyze website performance and usage patterns.
27 | - Personalize your interactions with the site.
28 |
29 | ## Sharing Your Information
30 | We do not share your information with third parties except for:
31 | - **Amplitude**: For managing feature flags and experiments essential to website functionality.
32 | - **Google Analytics**: For analyzing anonymized website usage data.
33 |
34 | Both providers comply with applicable data protection laws and process anonymized data on our behalf.
35 |
36 | ## Your Choices
37 | By using this website, you consent to the collection and use of your information as described in this Privacy Policy. If you do not agree with these terms, you must discontinue use of the website.
38 |
39 | ## Data Security
40 | We take reasonable measures to protect your information from unauthorized access, use, or disclosure. However, no method of transmission or storage is 100% secure, and we cannot guarantee absolute security.
41 |
42 | ## Changes to This Privacy Policy
43 | We may update this Privacy Policy from time to time. Any changes will be posted on this page with an updated "Last Updated" date.
44 |
45 | ## Contact Us
46 | If you have any questions about this Privacy Policy, please open an issue in our [Github page](https://github.com/ganesshkumar/obsidian-plugins-stats-ui/issues).
47 |
48 | _Last Updated: 2025-01-13_
49 |
50 |
--------------------------------------------------------------------------------
/cache/appcache.ts:
--------------------------------------------------------------------------------
1 | const HALF_AN_HOUR = 30 * 60 * 1000;
2 |
3 | class AppCache {
4 | static data = {};
5 |
6 | static get(key) {
7 | console.log(`cache ${AppCache.data[key] ? 'hit' : 'miss'}: ${key}`);
8 | return AppCache.data[key];
9 | }
10 |
11 | static set(key, value, timeout = HALF_AN_HOUR) {
12 | if (AppCache.data[key]) {
13 | console.log('no-op: tried to set cache but cache is already present');
14 | return;
15 | }
16 |
17 | AppCache.data = Object.assign({}, AppCache.data, { [key]: value });
18 | console.log(`cache set ${key}`);
19 |
20 | setTimeout(() => {
21 | delete AppCache.data[key];
22 | console.log(`cache invalidated ${key}`);
23 | }, HALF_AN_HOUR);
24 | }
25 | }
26 |
27 | export default AppCache;
28 |
--------------------------------------------------------------------------------
/changelog.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | - #### 2025-01-15
4 | - Added `/timeline` page.
5 | - Added develop.obsidianstats.com to publish `develop` branch as beta version of the webapp.
6 | - Added support
7 | - to build curtom scorer via pages/scorer/build and
8 | - to manage scorer functions via pages/scorer/index
9 | - to patch custom scores into plugins in
10 | - components/PluginsMultiView
11 | - pages/plugins/index
12 | - Fixed SEO issue where canonical names for some pages point to invalid URL.
13 | - Fixed issues with tags where redirects to pages/tags/[slug] were inconsistently sanitized.
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "styles/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/components/AuthorAndDescription.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import AuthorAndDescription from './AuthorAndDescription';
5 |
6 | describe('AuthorAndDescription', () => {
7 | test('renders author and description', () => {
8 | const author = 'John Doe';
9 | const description = 'This is a test description.';
10 | render( );
11 |
12 | const authorElement = screen.getByText(`${author}`);
13 | const descriptionElement = screen.getByText(description);
14 |
15 | expect(authorElement).toBeInTheDocument();
16 | expect(descriptionElement).toBeInTheDocument();
17 | });
18 |
19 | test('renders author with correct class', () => {
20 | const author = 'Jane Doe';
21 | render( );
22 |
23 | const authorElement = screen.getByText(`${author}`);
24 | expect(authorElement).toBeInTheDocument();
25 | expect(authorElement).toHaveClass('group-hover:text-violet-500');
26 | });
27 |
28 | test('renders description with correct class', () => {
29 | const description = 'Another test description.';
30 | render( );
31 |
32 | const descriptionElement = screen.getByText(description);
33 | expect(descriptionElement).toBeInTheDocument();
34 | expect(descriptionElement).toHaveClass('mr-5');
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/components/AuthorAndDescription.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const AuthorAndDescription = ({ author, description }) => {
4 | return (
5 | <>
6 |
7 | by {author}
8 |
9 | {description}
10 | >
11 | );
12 | };
13 |
14 | export default AuthorAndDescription;
15 |
--------------------------------------------------------------------------------
/components/BetaEntryCard.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { BetaEntryCard } from './BetaEntryCard';
5 | import moment from 'moment';
6 | import { PullRequestEntry } from '@prisma/client';
7 |
8 | // Minimal mock for PullRequestEntry fields used
9 | const baseEntry: PullRequestEntry = {
10 | id: '1',
11 | prNumber: 1234,
12 | repo: 'user/repo',
13 | name: 'Amazing Beta Plugin',
14 | author: 'GreatDev',
15 | description: 'Provides amazing features for testing highlight logic.',
16 | type: 'plugin',
17 | createdAt: moment().subtract(2, 'days').valueOf() as any,
18 | lastUpdatedAt: moment().valueOf() as any,
19 | prStatus: 'open',
20 | status: 'in-review',
21 | prLabels: null,
22 | operation: 'add',
23 | pluginId: 'amazing-beta-plugin',
24 | version: '0.0.1',
25 | lastClassifiedAt: null,
26 | lastEnrichedAt: null,
27 | needManualReview: false,
28 | manualReviewReason: null,
29 | };
30 |
31 | describe('BetaEntryCard', () => {
32 | it('highlights matching tokens in name, author, and description', () => {
33 | render( );
34 |
35 | // Name highlight
36 | const nameMarks = screen.getAllByText(/Amazing/i);
37 | expect(nameMarks.length).toBeGreaterThan(0);
38 |
39 | // Author highlight
40 | const authorMarks = screen.getAllByText(/GreatDev/i);
41 | expect(authorMarks.length).toBeGreaterThan(0);
42 |
43 | // Description highlight
44 | const featMarks = screen.getAllByText(/features/i);
45 | expect(featMarks.length).toBeGreaterThan(0);
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/components/CardAnnotations.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import CardAnnotations from './CardAnnotations';
5 |
6 | describe('CardAnnotations', () => {
7 | test('renders favorite annotation', () => {
8 | render(
9 |
14 | );
15 | const favoriteAnnotation = screen.getByText('Favorite');
16 | expect(favoriteAnnotation).toBeInTheDocument();
17 | expect(favoriteAnnotation).toHaveAttribute('title', 'Favorite plugin');
18 | });
19 |
20 | test('renders new annotation', () => {
21 | render(
22 |
28 | );
29 | const newAnnotation = screen.getByText('New Plugin');
30 | expect(newAnnotation).toBeInTheDocument();
31 | expect(newAnnotation).toHaveAttribute('title', 'Less than a day old');
32 | });
33 |
34 | test('renders trending annotation', () => {
35 | render(
36 |
41 | );
42 | const trendingAnnotation = screen.getByText('Trending');
43 | expect(trendingAnnotation).toBeInTheDocument();
44 | expect(trendingAnnotation).toHaveAttribute('title', 'Trending plugin');
45 | });
46 |
47 | test('renders multiple annotations', () => {
48 | render(
49 |
55 | );
56 | const favoriteAnnotation = screen.getByText('Favorite');
57 | const newAnnotation = screen.getByText('New Plugin');
58 | const trendingAnnotation = screen.getByText('Trending');
59 |
60 | expect(favoriteAnnotation).toBeInTheDocument();
61 | expect(newAnnotation).toBeInTheDocument();
62 | expect(trendingAnnotation).toBeInTheDocument();
63 | });
64 |
65 | test('renders no annotations when all props are false', () => {
66 | render(
67 |
72 | );
73 | expect(screen.queryByText('Favorite')).not.toBeInTheDocument();
74 | expect(screen.queryByText('New')).not.toBeInTheDocument();
75 | expect(screen.queryByText('Trending')).not.toBeInTheDocument();
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/components/CardAnnotations.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const CardAnnotations = ({
4 | isFavorite,
5 | isNotADayOld,
6 | isTrending,
7 | category = '',
8 | }) => {
9 | return (
10 |
11 | {isFavorite && (
12 |
16 | Favorite
17 |
18 | )}
19 | {isNotADayOld && (
20 |
24 | New {category}
25 |
26 | )}
27 | {isTrending && (
28 |
32 | Trending
33 |
34 | )}
35 |
36 | );
37 | };
38 |
39 | export default CardAnnotations;
40 |
--------------------------------------------------------------------------------
/components/Category.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { CategoryIcon } from './Category';
5 |
6 | describe('CategoryIcon', () => {
7 | const categories = [
8 | { name: 'Task Management', testId: 'task-management-icon' },
9 | { name: 'File Management', testId: 'file-management-icon' },
10 | { name: 'Note Enhancements', testId: 'note-enhancements-icon' },
11 | { name: 'Data Visualization', testId: 'data-visualization-icon' },
12 | { name: '3rd Party Integrations', testId: 'third-party-integrations-icon' },
13 | { name: 'Productivity Tools', testId: 'productivity-tools-icon' },
14 | { name: 'Coding & Technical Tools', testId: 'coding-technical-tools-icon' },
15 | { name: 'Creative & Writing Tools', testId: 'creative-writing-tools-icon' },
16 | { name: 'Privacy & Security', testId: 'privacy-security-icon' },
17 | { name: 'Customization & UI', testId: 'customization-ui-icon' },
18 | { name: 'Collaboration & Sharing', testId: 'collaboration-sharing-icon' },
19 | {
20 | name: 'Learning & Knowledge Management',
21 | testId: 'learning-knowledge-management-icon',
22 | },
23 | { name: 'Miscellaneous', testId: 'miscellaneous-icon' },
24 | { name: 'Uncategorized', testId: 'uncategorized-icon' },
25 | { name: 'Unknown', testId: 'default-icon' },
26 | ];
27 |
28 | categories.forEach(({ name, testId }) => {
29 | test(`renders correct icon for ${name}`, () => {
30 | render( );
31 | const icon = screen.queryByTestId(testId);
32 | expect(icon).toBeInTheDocument();
33 | expect(icon).toHaveAttribute('width', '48');
34 | expect(icon).toHaveAttribute('height', '48');
35 | });
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/components/Comments.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useRef, useEffect } from 'react';
3 |
4 | const Comments = () => {
5 | const commentBox = useRef(null);
6 |
7 | useEffect(() => {
8 | const script = document.createElement('script');
9 | script.src = 'https://utteranc.es/client.js';
10 | script.async = true;
11 | script.crossOrigin = 'anonymous';
12 | script.setAttribute('repo', 'ganesshkumar/comments');
13 | script.setAttribute('issue-term', 'url');
14 | script.setAttribute('theme', 'github-light');
15 | script.setAttribute('label', 'obsidian-dataview-query-wizard');
16 | commentBox.current.appendChild(script);
17 | }, []);
18 |
19 | return
;
20 | };
21 |
22 | export default Comments;
23 |
--------------------------------------------------------------------------------
/components/Divider.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import Divider from './Divider';
5 |
6 | describe('Divider Component', () => {
7 | it('renders the Divider component', () => {
8 | render( );
9 |
10 | // Check if the HR.Trimmed component is rendered
11 | expect(screen.getByRole('separator')).toBeInTheDocument();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/components/Divider.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { HR } from 'flowbite-react';
3 |
4 | const Divider = () => (
5 |
6 |
7 |
8 | );
9 |
10 | export default Divider;
11 |
--------------------------------------------------------------------------------
/components/Faq.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import Faqs from './Faq';
5 |
6 | const mockFaqs = [
7 | {
8 | question: 'What is your return policy?',
9 | answer: 'You can return any item within 30 days of purchase.',
10 | },
11 | {
12 | question: 'How do I track my order?',
13 | answer:
14 | 'You can track your order using the tracking number provided in your order confirmation email.',
15 | },
16 | ];
17 |
18 | describe('Faqs Component', () => {
19 | it('renders the Faqs component', () => {
20 | render( );
21 |
22 | // Check if the FAQ questions and answers are rendered
23 | mockFaqs.forEach((faq) => {
24 | expect(screen.getByText(faq.question)).toBeInTheDocument();
25 | expect(screen.getByText(faq.answer)).toBeInTheDocument();
26 | });
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/components/Faq.tsx:
--------------------------------------------------------------------------------
1 | import { Accordion } from 'flowbite-react';
2 | import React from 'react';
3 | import { Faq } from '../lib/abstractions';
4 |
5 | interface IFaqProps {
6 | faqs: Faq[];
7 | }
8 |
9 | const Faqs = ({ faqs }: IFaqProps) => {
10 | return (
11 |
12 |
13 | {faqs &&
14 | faqs.map((faq, idx) => (
15 |
16 |
17 |
18 | {faq.question}
19 |
20 |
21 |
22 |
23 | {faq.answer}
24 |
25 |
26 |
27 | ))}
28 |
29 |
30 | );
31 | };
32 |
33 | export default Faqs;
34 |
--------------------------------------------------------------------------------
/components/FavPluginUpdates.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import FavPluginUpdates, { NoFavPlugins } from './FavPluginUpdates';
5 | import { setupFavorites } from '../utils/favorites';
6 |
7 | jest.mock('../utils/favorites', () => ({
8 | setupFavorites: jest.fn(),
9 | }));
10 |
11 | describe('FavPluginUpdates', () => {
12 | it('renders NoFavPlugins component when there are no favorites', () => {
13 | setupFavorites.mockImplementation((setFavorites) => setFavorites([]));
14 | render( );
15 | expect(
16 | screen.getByText('Updates from your favorite plugins')
17 | ).toBeInTheDocument();
18 | });
19 |
20 | it('does not render NoFavPlugins component when there are favorites', () => {
21 | setupFavorites.mockImplementation((setFavorites) =>
22 | setFavorites(['plugin1'])
23 | );
24 | render( );
25 | expect(
26 | screen.queryByText('Updates from your favorite plugins')
27 | ).not.toBeInTheDocument();
28 | });
29 | });
30 |
31 | describe('NoFavPlugins', () => {
32 | it('renders step instructions correctly', () => {
33 | render( );
34 | expect(
35 | screen.getByText("Find and open your favorite plugin's page.")
36 | ).toBeInTheDocument();
37 | expect(
38 | screen.getByText(
39 | 'Click the favorite button to mark the plugin as your favorite.'
40 | )
41 | ).toBeInTheDocument();
42 | expect(
43 | screen.getByText(
44 | 'Any plugin updates for your favorite plugins will appear here.'
45 | )
46 | ).toBeInTheDocument();
47 | });
48 |
49 | it('changes step on click', () => {
50 | render( );
51 | const stepElement = screen.getByText(
52 | "Find and open your favorite plugin's page."
53 | );
54 | stepElement.click();
55 | expect(
56 | screen.getByText(
57 | 'Click the favorite button to mark the plugin as your favorite.'
58 | )
59 | ).toBeInTheDocument();
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/components/Favorites.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useCallback, useEffect, useState } from 'react';
4 | import { setFavorite, unsetFavorite } from '../utils/favorites';
5 |
6 | import { Plus, Minus, Share2 } from 'react-feather';
7 | import { useAnalytics } from '../lib/analytics/analytics';
8 |
9 | const Favorites = ({ isFavorite, plugin, setFavorites }) => {
10 | const [shareText, setShareText] = useState('share');
11 | const { trackEvent } = useAnalytics();
12 |
13 | const hostname =
14 | process.env.hostname ||
15 | process.env.VERCEL_URL ||
16 | process.env.NODE_ENV === 'production'
17 | ? 'https://www.obsidianstats.com'
18 | : 'http://localhost:5000';
19 |
20 | const shareClicked = () => {
21 | if (navigator) {
22 | navigator.clipboard.writeText(`${hostname}/plugins/${plugin.pluginId}`);
23 | setShareText('copied link to clipboard');
24 | }
25 | };
26 |
27 | useEffect(() => {
28 | if (shareText === 'copied link to clipboard') {
29 | setTimeout(() => setShareText('share'), 5000);
30 | }
31 | }, [shareText]);
32 |
33 | const handleSetFavoriteClicked = useCallback(() => {
34 | trackEvent('Favorite Button Click');
35 | setFavorite(plugin.pluginId, setFavorites);
36 | }, [plugin.pluginId, setFavorites]);
37 |
38 | const handleUnsetFavoriteClicked = useCallback(() => {
39 | trackEvent('Unfavorite Button Click');
40 | unsetFavorite(plugin.pluginId, setFavorites);
41 | }, [plugin.pluginId, setFavorites]);
42 |
43 | return (
44 |
45 | {isFavorite ? (
46 |
47 |
48 |
53 | unfavorite
54 |
55 |
56 | ) : (
57 |
58 |
59 |
64 | favorite
65 |
66 |
67 | )}
68 |
69 |
70 |
75 | {shareText}
76 |
77 |
78 |
79 | );
80 | };
81 |
82 | export default Favorites;
83 |
--------------------------------------------------------------------------------
/components/Footer.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import { Footer } from './Footer';
4 | import Constants from '../constants';
5 |
6 | describe('AppFooter', () => {
7 | test('renders footer brand', () => {
8 | render();
9 | const brandLink = screen.getByRole('link', {
10 | name: Constants.AppName,
11 | });
12 | expect(brandLink).toBeInTheDocument();
13 | expect(brandLink).toHaveAttribute('href', '/');
14 | });
15 |
16 | test('renders plugin links', () => {
17 | render();
18 | const pluginLinks = [
19 | { name: 'Beta (All)', href: '/beta' },
20 | { name: 'Beta Plugins', href: '/beta/plugins' },
21 | { name: 'Beta Themes', href: '/beta/themes' },
22 | { name: 'All Plugins', href: '/plugins' },
23 | { name: 'New Plugins', href: '/new' },
24 | { name: 'Latest Updates', href: '/updates' },
25 | { name: 'Favorite Plugins', href: '/favorites' },
26 | { name: 'Most Downloaded', href: '/most-downloaded' },
27 | { name: 'Trending Plugins', href: '/trending' },
28 | { name: 'Plugin Tags', href: '/tags' },
29 | ];
30 |
31 | pluginLinks.forEach(({ name, href }) => {
32 | const link = screen.getByRole('link', { name });
33 | expect(link).toBeInTheDocument();
34 | expect(link).toHaveAttribute('href', href);
35 | });
36 | });
37 |
38 | test('renders post links', () => {
39 | render();
40 | const postLink = screen.getByRole('link', { name: 'All Posts' });
41 | expect(postLink).toBeInTheDocument();
42 | expect(postLink).toHaveAttribute('href', '/posts');
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/components/GrowmeScript.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Script from 'next/script';
3 |
4 | declare global {
5 | interface Window {
6 | growMe: ((event: any) => void) & { _: any[] };
7 | }
8 | }
9 |
10 | export default function GrowMeScript() {
11 | return (
12 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 |
3 | export interface IHeaderProps {
4 | title: string;
5 | description: string;
6 | canonical: string;
7 | image: string;
8 | current?: string;
9 | jsonLdSchema?: any;
10 | }
11 |
12 | const Header = ({
13 | title,
14 | description,
15 | canonical,
16 | image,
17 | current,
18 | jsonLdSchema,
19 | }: IHeaderProps) => {
20 | return (
21 |
22 |
23 | {title}
24 |
25 |
26 |
27 |
28 |
29 | {/* Open Graph Tags */}
30 |
31 |
36 |
37 |
38 |
39 |
40 | {/* Twitter Tags */}
41 |
46 |
47 |
52 |
53 |
54 | {/* Yandex Verification */}
55 |
60 | {/* JSON-LD Schema */}
61 | {jsonLdSchema && (
62 |
67 | )}
68 |
69 | );
70 | };
71 |
72 | export default Header;
73 |
--------------------------------------------------------------------------------
/components/InfoBar.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import InfoBar from './InfoBar';
5 |
6 | describe('InfoBar', () => {
7 | const title = 'Test Title';
8 |
9 | const headingLevels = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
10 |
11 | headingLevels.forEach((level) => {
12 | test(`renders ${level} with correct title`, () => {
13 | render( );
14 | const heading = screen.getByRole('heading', {
15 | level: parseInt(level[1]),
16 | });
17 | expect(heading).toBeInTheDocument();
18 | expect(heading).toHaveTextContent(title);
19 | });
20 | });
21 |
22 | test('renders h1 by default', () => {
23 | render( );
24 | const heading = screen.getByRole('heading', { level: 1 });
25 | expect(heading).toBeInTheDocument();
26 | expect(heading).toHaveTextContent(title);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/components/InfoBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | interface IInfoBarProps {
3 | title: string;
4 | as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
5 | }
6 |
7 | const Content = ({ title }) => (
8 | <>
9 |
17 | {title}
18 |
19 |
20 |
25 | #
26 |
27 | >
28 | );
29 |
30 | const className =
31 | 'group relative scroll-mt-20 text-3xl font-bold text-gray-800 dark:text-white capitalize my-4';
32 | const id = 'default-table';
33 |
34 | const InfoBar = ({ title, as = 'h1' }: IInfoBarProps) => {
35 | switch (as) {
36 | case 'h6':
37 | return (
38 |
39 |
40 |
41 | );
42 | case 'h5':
43 | return (
44 |
45 |
46 |
47 | );
48 | case 'h4':
49 | return (
50 |
51 |
52 |
53 | );
54 | case 'h3':
55 | return (
56 |
57 |
58 |
59 | );
60 | case 'h2':
61 | return (
62 |
63 |
64 |
65 | );
66 | case 'h1':
67 | default:
68 | return (
69 |
70 |
71 |
72 | );
73 | }
74 | };
75 |
76 | export default InfoBar;
77 |
--------------------------------------------------------------------------------
/components/LinkButton.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import { LinkButton } from './LinkButton';
4 |
5 | describe('LinkButton', () => {
6 | test('renders LinkButton with default size', () => {
7 | render( );
8 | const linkButton = screen.getByRole('link', { name: 'Test Button' });
9 | expect(linkButton).toBeInTheDocument();
10 | expect(linkButton).toHaveAttribute('href', '/test');
11 | expect(linkButton).toHaveClass(
12 | 'font-medium w-fit border bg-gray-600 hover:bg-gray-700 text-slate-100 px-2 py-1 rounded text-center'
13 | );
14 | });
15 |
16 | test('renders LinkButton with small size', () => {
17 | render( );
18 | const linkButton = screen.getByRole('link', { name: 'Test Button' });
19 | expect(linkButton).toBeInTheDocument();
20 | expect(linkButton).toHaveAttribute('href', '/test');
21 | expect(linkButton).toHaveClass(
22 | 'font-medium w-fit border bg-gray-600 hover:bg-gray-700 text-slate-100 px-2 py-1 rounded text-center text-sm'
23 | );
24 | });
25 |
26 | test('passes additional props to LinkButton', () => {
27 | render( );
28 | const linkButton = screen.getByRole('link', { name: 'Test Button' });
29 | expect(linkButton).toBeInTheDocument();
30 | expect(linkButton).toHaveAttribute('href', '/test');
31 | expect(linkButton).toHaveAttribute('target', '_blank');
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/components/LinkButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useRouter } from 'next/router';
3 | import { ComponentProps } from 'react';
4 | import { useAnalytics } from '../lib/analytics/analytics';
5 |
6 | interface LinkButtonProps extends ComponentProps<'a'> {
7 | href: string;
8 | content: string;
9 | size?: string;
10 | }
11 |
12 | const capitalizeFirstLetter = (val) => {
13 | return String(val).charAt(0).toUpperCase() + String(val).slice(1);
14 | };
15 |
16 | export const LinkButton = (props: LinkButtonProps) => {
17 | const { content, href } = props;
18 | const router = useRouter();
19 | const { trackEvent } = useAnalytics();
20 |
21 | const handleClick = () => {
22 | let eventName = '';
23 | if (href === '/new') {
24 | eventName = 'See All New Plugins Button Click';
25 | } else if (href === '/updates') {
26 | eventName = 'See All Updated Plugins Button Click';
27 | } else if (href === '/most-downloaded') {
28 | eventName = 'See All Most Downloaded Plugins Button Click';
29 | } else if (href === '/trending') {
30 | eventName = 'See All Trending Plugins Button Click';
31 | } else {
32 | eventName = `See All ${capitalizeFirstLetter(href.split('/')[1] || '')} Plugins Button Click`;
33 | }
34 |
35 | trackEvent(eventName);
36 | router.push(href);
37 | };
38 |
39 | if (props.size === 'small') {
40 | return (
41 |
45 | {content}
46 |
47 | );
48 | }
49 | return (
50 |
54 | {content}
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/components/Navbar.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import Navbar from './Navbar';
4 | import Constants from '../constants';
5 |
6 | describe('Navbar', () => {
7 | test('renders navbar brand', () => {
8 | render( );
9 | const brandImage = screen.getByAltText(`${Constants.AppName} logo`);
10 | expect(brandImage).toBeInTheDocument();
11 | const src = brandImage.getAttribute('src');
12 | expect(src.includes('favicon-64.png')).toBe(true);
13 | const brandText = screen.getByText(Constants.AppName);
14 | expect(brandText).toBeInTheDocument();
15 | });
16 |
17 | test('renders navbar links', () => {
18 | render( );
19 | const links = [
20 | { name: 'New Plugins', href: '/new' },
21 | { name: 'Beta (PRs)', href: '/beta' },
22 | { name: 'Posts', href: '/posts' },
23 | { name: 'Favorites', href: '/favorites' },
24 | { name: 'All Plugins', href: '/plugins' },
25 | ];
26 |
27 | links.forEach(({ name, href }) => {
28 | const link = screen.getByRole('link', { name });
29 | expect(link).toBeInTheDocument();
30 | expect(link).toHaveAttribute('href', href);
31 | });
32 | });
33 |
34 | test('renders dropdown links', () => {
35 | render( );
36 | const dropdownLinks = [
37 | { name: 'Latest Updates', href: '/updates' },
38 | { name: 'Most Downloaded', href: '/most-downloaded' },
39 | ];
40 |
41 | // Simulate clicking the dropdown toggle
42 | const dropdownToggle = screen.getByText('More');
43 | fireEvent.click(dropdownToggle);
44 |
45 | dropdownLinks.forEach(({ name, href }) => {
46 | const link = screen.getByRole('link', { name });
47 | expect(link).toBeInTheDocument();
48 | expect(link).toHaveAttribute('href', href);
49 | });
50 | });
51 |
52 | test('passes correct props to Navbar component', () => {
53 | const children = Test Children
;
54 | render({children} );
55 | expect(screen.getByText('Test Children')).toBeInTheDocument();
56 | });
57 |
58 | test('highlights the correct current page link', () => {
59 | render( );
60 | const currentPageLink = screen.getByRole('link', { name: 'New Plugins' });
61 | expect(currentPageLink).toHaveClass('md:text-purple-700');
62 | });
63 | });
64 |
--------------------------------------------------------------------------------
/components/PluginCard.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import PluginCard from './PluginCard';
5 | import moment from 'moment';
6 | import { Plugin } from '@prisma/client';
7 |
8 | describe('PluginCard', () => {
9 | const plugin: Plugin = {
10 | id: '1',
11 | pluginId: 'plugin-1',
12 | name: 'Test Plugin',
13 | author: 'Author Name',
14 | description: 'This is a test plugin description.',
15 | repo: 'https://github.com/test/plugin-repo',
16 | createdAt: moment().subtract(1, 'days').valueOf(),
17 | nextUpdateAt: moment().add(1, 'days').valueOf(),
18 | lastCommitAt: moment().subtract(1, 'hours').valueOf(),
19 | stargazers: 100,
20 | subscribers: 50,
21 | forks: 10,
22 | latestRelease: 'v1.0.0',
23 | latestReleaseDesc: 'Initial release',
24 | latestReleaseAt: moment().subtract(2, 'days').valueOf(),
25 | totalDownloads: 1000,
26 | totalIssues: 20,
27 | closedIssues: 15,
28 | openIssues: 5,
29 | totalPR: 10,
30 | openPR: 2,
31 | closedPR: 5,
32 | mergedPR: 3,
33 | commitCountInLastYear: 200,
34 | zScoreTrending: 1.5,
35 | osDescription: 'AI generated description',
36 | osCategory: 'Category1, Category2',
37 | osTags: 'Tag1, Tag2',
38 | score: 4.5,
39 | scoreReason: 'High quality and popular plugin',
40 | };
41 |
42 | it('renders plugin name, author, and creation time', () => {
43 | render( );
44 | expect(screen.getByText('Test Plugin')).toBeInTheDocument();
45 | expect(screen.getByText(`Author Name`)).toBeInTheDocument();
46 | expect(
47 | screen.getByText(moment(plugin.createdAt).fromNow())
48 | ).toBeInTheDocument();
49 | });
50 |
51 | it('renders plugin description when showDescription is true', () => {
52 | render( );
53 | expect(
54 | screen.getByText('This is a test plugin description.')
55 | ).toBeInTheDocument();
56 | });
57 |
58 | it('does not render plugin description when showDescription is false', () => {
59 | render( );
60 | expect(
61 | screen.queryByText('This is a test plugin description.')
62 | ).not.toBeInTheDocument();
63 | });
64 |
65 | it('has correct link to plugin page', () => {
66 | render( );
67 | const linkElement = screen.getByRole('link');
68 | expect(linkElement).toHaveAttribute('href', `/plugins/${plugin.pluginId}`);
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/components/PluginCard.tsx:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import { Plugin } from '@prisma/client';
3 |
4 | interface INewPluginProps {
5 | plugin: Plugin;
6 | showDescription?: boolean;
7 | }
8 |
9 | const PluginCard = ({ plugin, showDescription }: INewPluginProps) => {
10 | return (
11 |
18 |
19 | {plugin.name}
20 |
21 |
22 | {moment(plugin.createdAt).fromNow()} by{' '}
23 | {plugin.author}
24 |
25 | {showDescription && (
26 | {plugin.description}
27 | )}
28 |
29 | );
30 | };
31 |
32 | export default PluginCard;
33 |
--------------------------------------------------------------------------------
/components/PluginEcosystemStats.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import PluginEcosystemStats from './PluginEcosystemStats';
5 |
6 | describe('PluginEcosystemStats', () => {
7 | const props = {
8 | totalPluginsCount: 100,
9 | newPluginsCount: 10,
10 | newReleasesCount: 5,
11 | };
12 |
13 | test('renders new plugins stat', () => {
14 | render( );
15 | const newPluginsStat = screen.getByText(props.newPluginsCount.toString());
16 | expect(newPluginsStat).toBeInTheDocument();
17 | expect(newPluginsStat).toHaveClass(
18 | 'text-7xl font-bold tracking-tight text-violet-900 dark:text-white text-center'
19 | );
20 | const newPluginsTitle = screen.getByText('New Plugins');
21 | expect(newPluginsTitle).toBeInTheDocument();
22 | });
23 |
24 | test('renders recently updated plugins stat', () => {
25 | render( );
26 | const newReleasesStat = screen.getByText(props.newReleasesCount.toString());
27 | expect(newReleasesStat).toBeInTheDocument();
28 | expect(newReleasesStat).toHaveClass(
29 | 'text-7xl font-bold tracking-tight text-violet-900 dark:text-white text-center'
30 | );
31 | const newReleasesTitle = screen.getByText('Recently Updated Plugins');
32 | expect(newReleasesTitle).toBeInTheDocument();
33 | });
34 |
35 | test('renders total plugins stat', () => {
36 | render( );
37 | const totalPluginsStat = screen.getByText(
38 | props.totalPluginsCount.toString()
39 | );
40 | expect(totalPluginsStat).toBeInTheDocument();
41 | expect(totalPluginsStat).toHaveClass(
42 | 'text-7xl font-bold tracking-tight text-violet-900 dark:text-white text-center'
43 | );
44 | const totalPluginsTitle = screen.getByText('Total Plugins');
45 | expect(totalPluginsTitle).toBeInTheDocument();
46 | });
47 |
48 | test('renders correct links for stats', () => {
49 | render( );
50 | const newPluginsLink = screen.getByRole('link', { name: /New Plugins/i });
51 | expect(newPluginsLink).toHaveAttribute('href', '/new');
52 | const newReleasesLink = screen.getByRole('link', {
53 | name: /Recently Updated Plugins/i,
54 | });
55 | expect(newReleasesLink).toHaveAttribute('href', '/updates');
56 | const totalPluginsLink = screen.getByRole('link', {
57 | name: /Total Plugins/i,
58 | });
59 | expect(totalPluginsLink).toHaveAttribute('href', '/plugins');
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/components/PluginEcosystemStats.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card } from 'flowbite-react';
3 |
4 | interface IPluginEcosystemStatsProps {
5 | totalPluginsCount: number;
6 | newPluginsCount: number;
7 | newReleasesCount: number;
8 | }
9 |
10 | const PluginEcosystemStats = ({
11 | totalPluginsCount,
12 | newPluginsCount,
13 | newReleasesCount,
14 | }: IPluginEcosystemStatsProps) => {
15 | return (
16 |
38 | );
39 | };
40 |
41 | const PluginEcosystemStat = ({ title, count, href, id }) => {
42 | return (
43 |
44 |
45 | {count}
46 |
47 |
48 | {' '}
49 | {title}{' '}
50 |
51 |
52 | );
53 | };
54 |
55 | export default PluginEcosystemStats;
56 |
--------------------------------------------------------------------------------
/components/PluginsBanner.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import React, { useRef, useEffect } from 'react';
3 | import * as Icons from 'react-icons';
4 |
5 | const getRandom = (min, max) => Math.random() * (max - min) + min;
6 |
7 | export function PluginsBanner({ iconNames = [] }) {
8 | const canvasRef = useRef(null);
9 |
10 | useEffect(() => {
11 | const canvas = canvasRef.current;
12 | const ctx = canvas.getContext('2d');
13 | const width = (canvas.width = window.innerWidth);
14 | const height = (canvas.height = 300);
15 |
16 | // Create floating icons
17 | const icons = iconNames.map((name) => {
18 | const IconComponent = Icons[name];
19 | const wrapper = document.createElement('div');
20 |
21 | // Apply styles to icon
22 | wrapper.style.position = 'absolute';
23 | wrapper.style.pointerEvents = 'none';
24 | wrapper.style.fontSize = `${getRandom(20, 40)}px`;
25 | wrapper.style.color = 'white';
26 | wrapper.style.opacity = '0.8';
27 | wrapper.style.transform = `translate(-50%, -50%)`;
28 | wrapper.style.zIndex = '10';
29 |
30 | // Render icon inside div
31 | const iconMarkup = document.createElement('span');
32 | iconMarkup.innerHTML = (
33 | React.createElement(IconComponent).props as any
34 | ).children;
35 | wrapper.appendChild(iconMarkup);
36 |
37 | document.body.appendChild(wrapper);
38 |
39 | return {
40 | el: wrapper,
41 | x: getRandom(0, width),
42 | y: getRandom(0, height),
43 | speedX: getRandom(-0.5, 0.5),
44 | speedY: getRandom(-0.2, -0.5),
45 | };
46 | });
47 |
48 | const drawGradient = () => {
49 | const gradient = ctx.createLinearGradient(0, 0, width, height);
50 | gradient.addColorStop(0, '#0f2027');
51 | gradient.addColorStop(0.5, '#203a43');
52 | gradient.addColorStop(1, '#2c5364');
53 | ctx.fillStyle = gradient;
54 | ctx.fillRect(0, 0, width, height);
55 | };
56 |
57 | const animate = () => {
58 | drawGradient();
59 | icons.forEach((icon) => {
60 | icon.x += icon.speedX;
61 | icon.y += icon.speedY;
62 |
63 | // Wrap around
64 | if (icon.x > width) icon.x = 0;
65 | if (icon.x < 0) icon.x = width;
66 | if (icon.y < -20) icon.y = height;
67 |
68 | icon.el.style.left = `${icon.x}px`;
69 | icon.el.style.top = `${icon.y}px`;
70 | });
71 |
72 | requestAnimationFrame(animate);
73 | };
74 |
75 | animate();
76 |
77 | return () => {
78 | icons.forEach((icon) => icon.el.remove());
79 | };
80 | }, [iconNames]);
81 |
82 | return (
83 |
84 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/components/Score.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen, fireEvent, act } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { Score } from './Score';
5 | import { Plugin } from '@prisma/client';
6 |
7 | const plugin: Plugin = {
8 | name: 'Dummy Plugin',
9 | score: 0.75,
10 | scoreReason:
11 | 'stargazers:21:0.05:0.03:0.00\nforks:3:0.10:0.02:0.00\nclosedIssuesRatio:0.5714285714285714:0.57:0.20:0.11\nresolvedPRRatio:0:0.00:0.15:0.00\ncommitCountInLastYear:0:0.00:0.10:0.00\ntotalDownloads:5373:0.05:0.20:0.01\n\nlatestReleaseAt:1688483038000:0.52:0.20:0.10\ncreatedAt:-94539615918:0.95:0.10:0.09',
12 | id: 'dummy-id',
13 | pluginId: 'dummy-plugin-id',
14 | author: 'Dummy Author',
15 | description: 'This is a dummy plugin for testing purposes.',
16 | repo: 'https://github.com/dummy/repo',
17 | createdAt: Date.now(),
18 | nextUpdateAt: Date.now() + 100000,
19 | lastCommitAt: Date.now() - 100000,
20 | stargazers: 50,
21 | subscribers: 10,
22 | forks: 5,
23 | latestRelease: 'v1.0.0',
24 | latestReleaseDesc: 'Initial release',
25 | latestReleaseAt: Date.now() - 50000,
26 | totalDownloads: 1000,
27 | totalIssues: 20,
28 | closedIssues: 15,
29 | openIssues: 5,
30 | totalPR: 10,
31 | openPR: 2,
32 | closedPR: 5,
33 | mergedPR: 3,
34 | commitCountInLastYear: 100,
35 | zScoreTrending: 1.5,
36 | osDescription: 'AI generated description for dummy plugin.',
37 | osCategory: 'category1, category2',
38 | osTags: 'tag1, tag2',
39 | };
40 |
41 | describe('Score', () => {
42 | test('renders score correctly', () => {
43 | render( );
44 | const scoreElement = screen.getByText('75');
45 | expect(scoreElement).toBeInTheDocument();
46 | expect(scoreElement).toHaveClass(
47 | 'text-4xl font-sans font-bold text-lime-500'
48 | );
49 | });
50 |
51 | test('renders tooltip and modal', () => {
52 | render( );
53 | const infoIcon = screen.queryByTestId('score-info');
54 | expect(infoIcon).toBeInTheDocument();
55 |
56 | act(() => {
57 | fireEvent.click(infoIcon);
58 | });
59 |
60 | const modalHeader = screen.getByText(`Score explanation for`);
61 | expect(modalHeader).toBeInTheDocument();
62 |
63 | const metricCell = screen.getByText('Stars');
64 | expect(metricCell).toBeInTheDocument();
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/components/Sponsorship.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Button } from 'flowbite-react';
3 |
4 | export const Sponsorship = (props) => {
5 | const sponsors = props.sponsors || [];
6 | return (
7 |
13 | );
14 | };
15 |
16 | const FirstSponsorship = () => {
17 | return (
18 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/components/StarRating.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { PluginRatingInfo } from '@/domain/plugins/models/PluginRatingInfo';
3 | import { Progress } from '@/components/ui/progress';
4 |
5 | interface StarRatingProps {
6 | ratingInfo: PluginRatingInfo;
7 | }
8 |
9 | export const StarRating = ({ ratingInfo }: StarRatingProps) => {
10 | if (!ratingInfo) {
11 | ratingInfo = {
12 | avgRating: 0,
13 | ratingCount: 0,
14 | star1Count: 0,
15 | star2Count: 0,
16 | star3Count: 0,
17 | star4Count: 0,
18 | star5Count: 0,
19 | };
20 | }
21 |
22 | const stars = [];
23 | for (let i = 1; i <= 5; i++) {
24 | const filled = i <= (ratingInfo?.avgRating ?? 0);
25 | stars.push(
26 |
30 | ★
31 |
32 | );
33 | }
34 |
35 | const bars = [];
36 | for (let i = 5; i > 0; i--) {
37 | let percentage = 0;
38 | switch (i) {
39 | case 1:
40 | percentage =
41 | ratingInfo.ratingCount === 0
42 | ? 0
43 | : (ratingInfo.star1Count / ratingInfo.ratingCount) * 100;
44 | break;
45 | case 2:
46 | percentage =
47 | ratingInfo.ratingCount === 0
48 | ? 0
49 | : (ratingInfo.star2Count / ratingInfo.ratingCount) * 100;
50 | break;
51 | case 3:
52 | percentage =
53 | ratingInfo.ratingCount === 0
54 | ? 0
55 | : (ratingInfo.star3Count / ratingInfo.ratingCount) * 100;
56 | break;
57 | case 4:
58 | percentage =
59 | ratingInfo.ratingCount === 0
60 | ? 0
61 | : (ratingInfo.star4Count / ratingInfo.ratingCount) * 100;
62 | break;
63 | case 5:
64 | percentage =
65 | ratingInfo.ratingCount === 0
66 | ? 0
67 | : (ratingInfo.star5Count / ratingInfo.ratingCount) * 100;
68 | break;
69 | }
70 |
71 | bars.push(
72 |
80 | );
81 | }
82 |
83 | return (
84 |
85 |
86 |
87 | {(ratingInfo.avgRating ?? 0).toFixed(1)}
88 |
89 |
{stars}
90 |
91 | ({ratingInfo.ratingCount ?? 0})
92 |
93 |
94 |
{bars}
95 |
96 | );
97 | };
98 |
--------------------------------------------------------------------------------
/components/StarRatingInput.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useState } from 'react';
3 |
4 | interface StarRatingInputProps {
5 | rating?: number;
6 | setRating?: (rating: number) => void;
7 | }
8 |
9 | export const StarRatingInput = ({
10 | rating = 0,
11 | setRating,
12 | }: StarRatingInputProps) => {
13 | const [hoveredRating, setHoveredRating] = useState(null);
14 | const stars = [];
15 |
16 | const handleClick = (index: number) => {
17 | if (setRating) {
18 | setRating(index);
19 | }
20 | };
21 |
22 | const handleMouseEnter = (index: number) => {
23 | setHoveredRating(index);
24 | };
25 |
26 | const handleMouseLeave = () => {
27 | setHoveredRating(null);
28 | };
29 |
30 | for (let i = 1; i <= 5; i++) {
31 | const hovered = i <= hoveredRating;
32 | const filled = i <= rating;
33 | stars.push(
34 | handleClick(i)}
37 | onMouseEnter={() => handleMouseEnter(i)}
38 | onMouseLeave={handleMouseLeave}
39 | className={`cursor-pointer ${hovered ? 'text-yellow-500' : filled ? 'text-yellow-400' : 'text-gray-300'} text-6xl transition-colors duration-200`}
40 | >
41 | ★
42 |
43 | );
44 | }
45 |
46 | return {stars}
;
47 | };
48 |
--------------------------------------------------------------------------------
/components/background/wave-background.tsx:
--------------------------------------------------------------------------------
1 |
2 | const waveStyle = {
3 | backgroundImage:
4 | "url('https://1.bp.blogspot.com/-xQUc-TovqDk/XdxogmMqIRI/AAAAAAAACvI/AizpnE509UMGBcTiLJ58BC6iViPYGYQfQCLcBGAsYHQ/s1600/wave.png')",
5 | backgroundSize: '1000px 50px',
6 | };
7 |
8 | interface IWavesBackgroundProps {
9 | title?: string;
10 | }
11 |
12 | const WavesBackground = ({ title }: IWavesBackgroundProps) => {
13 | return (
14 |
15 | {/*
*/}
16 | {title}
17 |
35 |
36 | );
37 | };
38 |
39 | export default WavesBackground;
--------------------------------------------------------------------------------
/components/home/Highlights.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { Highlights } from './Highlights';
5 | import { Highlight } from '../../lib/abstractions';
6 |
7 | const mockHighlights: Highlight[] = [
8 | {
9 | title: 'Highlight One',
10 | image: '/images/highlight-one.png',
11 | description: 'Description for highlight one.',
12 | link: '/posts/highlight-one',
13 | ctaText: 'Read More',
14 | bgClasses: 'bg-linear-to-tr from-fuchsia-400 to-purple-400 text-black',
15 | },
16 | {
17 | title: 'Highlight Two',
18 | image: '/images/highlight-two.png',
19 | description: 'Description for highlight two.',
20 | link: '/posts/highlight-two',
21 | ctaText: 'Read More',
22 | bgClasses: 'bg-linear-to-tr from-fuchsia-400 to-purple-400 text-black',
23 | },
24 | ];
25 |
26 | describe('Highlights Component', () => {
27 | it('renders without carousel when there is only one highlight', () => {
28 | render( );
29 | expect(screen.queryByTestId('carousel')).not.toBeInTheDocument();
30 | expect(screen.getByText('Highlight One')).toBeInTheDocument();
31 | expect(
32 | screen.getByText('Description for highlight one.')
33 | ).toBeInTheDocument();
34 | expect(screen.getByText('Read More')).toBeInTheDocument();
35 | });
36 |
37 | it('renders with carousel when there are multiple highlights', () => {
38 | render( );
39 | expect(screen.getByTestId('carousel')).toBeInTheDocument();
40 | expect(screen.getByText('Highlight One')).toBeInTheDocument();
41 | expect(
42 | screen.getByText('Description for highlight one.')
43 | ).toBeInTheDocument();
44 | expect(screen.getByText('Highlight Two')).toBeInTheDocument();
45 | expect(
46 | screen.getByText('Description for highlight two.')
47 | ).toBeInTheDocument();
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/components/home/MostDownloaded.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import InfoBar from '../InfoBar';
3 | import { LinkButton } from '../LinkButton';
4 | import { Plugin } from '@prisma/client';
5 | import { Download } from 'react-feather';
6 | import { useRouter } from 'next/router';
7 | import { useAnalytics } from '../../lib/analytics/analytics';
8 |
9 | interface IMostDownloadedProps {
10 | overall: Plugin[];
11 | last7Days: Plugin[];
12 | last30Days: Plugin[];
13 | }
14 |
15 | export const MostDownloadedPlugins = ({
16 | overall,
17 | last7Days,
18 | last30Days,
19 | }: IMostDownloadedProps) => {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | const List = ({ plugins, title }) => {
41 | const router = useRouter();
42 | const { trackEvent } = useAnalytics();
43 |
44 | const handlePluginClick = (pluginId: string) => {
45 | trackEvent(`Home Most Downloaded Plugin Card Click`);
46 | router.push(`/plugins/${pluginId}`);
47 | };
48 |
49 | return (
50 |
51 |
52 | {title}
53 |
54 | {plugins.map((plugin, index) => {
55 | return (
56 |
handlePluginClick(plugin.pluginId)}
59 | id={`most-downloaded-${index}`}
60 | //theme={ComponentTheme.mostDownloadedCardTheme}
61 | className={`flex justify-between w-full ${index % 2 === 0 ? 'bg-tranparent' : 'bg-gray-100'} cursor-pointer`}
62 | >
63 |
64 |
{index + 1}.
65 |
66 | {plugin.name}
67 |
68 |
69 |
70 |
71 | {' '}
72 | {plugin.totalDownloads.toLocaleString('en-US')}
73 |
74 |
75 |
76 | );
77 | })}
78 |
79 | );
80 | };
81 |
--------------------------------------------------------------------------------
/components/home/SubstackNewsletter.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { SubstackNewsletter } from './SubstackNewsletter';
5 |
6 | describe('SubstackNewsletter Component', () => {
7 | it('renders the SubstackNewsletter component', () => {
8 | render( );
9 | expect(screen.queryByTestId('substack-newsletter')).toBeInTheDocument();
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/components/home/SubstackNewsletter.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from 'flowbite-react';
2 | import React from 'react';
3 | import { Rss } from 'react-feather';
4 |
5 | export const SubstackNewsletter = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | Subscribe to our RSS feeds
12 |
13 |
14 | to get the latest updates on new plugins, weekly plugin updates, and
15 | posts.
16 |
17 |
41 |
42 |
56 |
57 | {/*
58 |
68 |
*/}
69 |
70 |
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/components/plugins/PluginSection.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Plugin } from "@/domain/plugins/models/Plugin";
3 | import { DataviewSection } from "./dataview";
4 | import { ObsidianTasksPluginSection } from "./obsidian-tasks-plugin";
5 |
6 | const PluginSpecificComponentMap = {
7 | 'dataview': ,
8 | 'obsidian-tasks-plugin':
9 | }
10 |
11 | interface IPluginSectionProps {
12 | plugin: Plugin
13 | }
14 |
15 | export const PluginSection = ({plugin}: IPluginSectionProps) => {
16 | const content = PluginSpecificComponentMap[plugin.pluginId];
17 | return (
18 |
19 | {!!content && content}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/components/plugins/dataview.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import { useAnalytics } from "@/lib/analytics/analytics";
5 | import { Button, Card } from "flowbite-react";
6 | import { RiOpenaiFill } from "react-icons/ri";
7 | import { customCardTheme } from "./theme";
8 |
9 | export const DataviewSection = () => {
10 | const { trackEvent } = useAnalytics();
11 |
12 | const handleCTAButtonClick = () => {
13 | trackEvent('Plugin: Dataview Query Wizard CTA Button Click');
14 | window.open("https://chatgpt.com/g/g-67f63dc319588191a4bb13d0def278b0-obsidian-dataview-query-wizard", "_blank");
15 | }
16 |
17 | return (
18 |
19 |
20 |
Dataview Query Wizard
21 |
A custom GPT that helps Obsidian users write, understand, and debug Dataview queries.
22 |
Great for creating tables, tracking tasks, filtering notes, and exploring metadata in your vault.
23 |
Supports YAML, inline fields, and DataviewJS.
24 |
25 | Chat with Wizard
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/components/plugins/obsidian-tasks-plugin.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import { useAnalytics } from "@/lib/analytics/analytics";
5 | import { Button, Card } from "flowbite-react";
6 | import { RiOpenaiFill } from "react-icons/ri";
7 | import { customCardTheme } from "./theme";
8 |
9 | export const ObsidianTasksPluginSection = () => {
10 | const { trackEvent } = useAnalytics();
11 |
12 | const handleCTAButtonClick = () => {
13 | trackEvent('Plugin: Tasks Query Wizard CTA Button Click');
14 | window.open("https://chatgpt.com/g/g-68236a25c43c8191ae356408a73d2fd1-obsidian-tasks-query-wizard", "_blank");
15 | }
16 |
17 | return (
18 |
19 |
20 |
Tasks Query Wizard
21 |
Describe what you need - overdue tasks, weekly reviews, project-specific filters - and let the Query Wizard craft the query for you. No syntax memorization needed.
22 |
23 | Chat with Wizard
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/components/plugins/theme.ts:
--------------------------------------------------------------------------------
1 | import { CustomFlowbiteTheme } from "flowbite-react";
2 |
3 | export const customCardTheme: CustomFlowbiteTheme['card'] = {
4 | root: {
5 | base: 'flex rounded-lg border border-violet-500 bg-white shadow-md dark:border-gray-700 dark:bg-gray-800',
6 | children: 'flex h-full flex-col justify-center gap-0 py-5 px-5 rounded',
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/components/post/LatestPosts.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { LatestPosts } from './LatestPosts';
5 | import moment from 'moment';
6 |
7 | const mockPosts = [
8 | {
9 | id: '1',
10 | title: 'First Post',
11 | publishedDate: '2023-10-01',
12 | tags: ['tag1', 'tag2'],
13 | },
14 | {
15 | id: '2',
16 | title: 'Second Post',
17 | publishedDate: '2023-10-02',
18 | tags: ['tag3', 'tag4'],
19 | },
20 | ];
21 |
22 | describe('LatestPosts Component', () => {
23 | it('renders the LatestPosts component', () => {
24 | render( );
25 |
26 | // Check if the InfoBar title is rendered
27 | expect(screen.getByText('Latest Posts')).toBeInTheDocument();
28 |
29 | // Check if the posts are rendered
30 | mockPosts.forEach((post) => {
31 | expect(screen.getByText(post.title)).toBeInTheDocument();
32 | expect(
33 | screen.getByText(moment(post.publishedDate).format('MMMM DD, YYYY'))
34 | ).toBeInTheDocument();
35 | });
36 |
37 | // Check if the "View all posts" link is rendered
38 | expect(screen.getByText('View all posts ⟶')).toBeInTheDocument();
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/components/post/LatestPosts.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import InfoBar from '../InfoBar';
3 | import { PostIcon } from './PostIcon';
4 | import moment from 'moment';
5 | import { LinkButton } from '../LinkButton';
6 | import Image from 'next/image';
7 | import { useRouter } from 'next/router';
8 | import { useAnalytics } from '../../lib/analytics/analytics';
9 |
10 | export const LatestPosts = ({ posts }) => {
11 | const router = useRouter();
12 | const { trackEvent } = useAnalytics();
13 |
14 | const handlePostClick = (postId) => {
15 | trackEvent('Home Latest Posts Click');
16 | router.push(`/posts/${postId}`);
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | {posts.map((post) => (
26 |
27 | handlePostClick(post.id)}
29 | className="flex justify-between py-4 px-2 cursor-pointer"
30 | >
31 |
32 |
35 |
36 |
37 | {post.title}
38 |
39 |
40 | {moment(post.publishedDate).format('MMMM DD, YYYY')}
41 |
42 | {/*
43 | {post.excerpt}
44 |
*/}
45 |
46 |
47 |
48 |
49 | ))}
50 |
51 |
52 |
53 |
54 |
60 |
61 |
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/components/post/PostIcon.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { PostIcon } from './PostIcon';
5 |
6 | describe('PostIcon Component', () => {
7 | it('renders Calendar icon for weekly-plugin-updates tag', () => {
8 | render( );
9 | const icon = screen.queryByTestId('calendar-icon');
10 | expect(icon).toBeInTheDocument();
11 | expect(icon).toHaveClass('text-violet-700');
12 | });
13 |
14 | it('renders Star icon for wrapped-yearly-post tag', () => {
15 | render( );
16 | const icon = screen.queryByTestId('star-icon');
17 | expect(icon).toBeInTheDocument();
18 | expect(icon).toHaveClass('text-yellow-400');
19 | });
20 |
21 | it('renders List icon for workflow tag', () => {
22 | render( );
23 | const icon = screen.queryByTestId('list-icon');
24 | expect(icon).toBeInTheDocument();
25 | expect(icon).toHaveClass('text-green-400');
26 | });
27 |
28 | it('renders Zap icon for feature tag', () => {
29 | render( );
30 | const icon = screen.queryByTestId('zap-icon');
31 | expect(icon).toBeInTheDocument();
32 | expect(icon).toHaveClass('text-sky-700');
33 | });
34 |
35 | it('renders nothing for unknown tag', () => {
36 | render( );
37 | const calIcon = screen.queryByTestId('calendar-icon');
38 | expect(calIcon).not.toBeInTheDocument();
39 | const starIcon = screen.queryByTestId('star-icon');
40 | expect(starIcon).not.toBeInTheDocument();
41 | const listIcon = screen.queryByTestId('list-icon');
42 | expect(listIcon).not.toBeInTheDocument();
43 | const zapIcon = screen.queryByTestId('zap-icon');
44 | expect(zapIcon).not.toBeInTheDocument();
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/components/post/PostIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BookOpen, Calendar, Cpu, FileText, Package, Star, Zap } from 'react-feather';
3 |
4 | export const PostIcon = (props) => {
5 | if (props.tags && props.tags.includes('weekly-updates')) {
6 | return (
7 |
12 | );
13 | } else if (props.tags && props.tags.includes('wrapped-yearly-post')) {
14 | return (
15 |
20 | );
21 | } else if (props.tags && props.tags.includes('workflow')) {
22 | return (
23 |
28 | );
29 | } else if (props.tags && props.tags.includes('feature')) {
30 | return (
31 |
36 | );
37 | } else if (props.tags && props.tags.includes('ai')) {
38 | return (
39 |
44 | );
45 | } else if (props.tags && props.tags.includes('tutorial')) {
46 | return (
47 |
52 | );
53 | } else {
54 | return (
55 |
60 | );
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
15 | outline:
16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost:
20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27 | icon: "size-9",
28 | "icon-sm": "size-8",
29 | "icon-lg": "size-10",
30 | },
31 | },
32 | defaultVariants: {
33 | variant: "default",
34 | size: "default",
35 | },
36 | }
37 | )
38 |
39 | function Button({
40 | className,
41 | variant,
42 | size,
43 | asChild = false,
44 | ...props
45 | }: React.ComponentProps<"button"> &
46 | VariantProps & {
47 | asChild?: boolean
48 | }) {
49 | const Comp = asChild ? Slot : "button"
50 |
51 | return (
52 |
57 | )
58 | }
59 |
60 | export { Button, buttonVariants }
61 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
3 | import { Check } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
22 |
23 |
24 |
25 | ));
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
27 |
28 | export { Checkbox };
29 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | );
18 | }
19 | );
20 | Input.displayName = 'Input';
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ProgressPrimitive from '@radix-ui/react-progress';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | interface CustomProgressProps
7 | extends React.ComponentPropsWithoutRef {
8 | indicatorColor: string;
9 | }
10 |
11 | const Progress = React.forwardRef<
12 | React.ElementRef,
13 | CustomProgressProps
14 | >(({ className, value, indicatorColor, ...props }, ref) => (
15 |
23 |
27 |
28 | ));
29 | Progress.displayName = ProgressPrimitive.Root.displayName;
30 |
31 | export { Progress };
32 |
--------------------------------------------------------------------------------
/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | // https://shadcnui-expansions.typeart.cc/docs/spinner
2 |
3 | import React from 'react';
4 | import { cn } from '@/lib/utils';
5 | import { VariantProps, cva } from 'class-variance-authority';
6 | import { Loader2 } from 'lucide-react';
7 |
8 | const spinnerVariants = cva('flex-col items-center justify-center', {
9 | variants: {
10 | show: {
11 | true: 'flex',
12 | false: 'hidden',
13 | },
14 | },
15 | defaultVariants: {
16 | show: true,
17 | },
18 | });
19 |
20 | const loaderVariants = cva('animate-spin text-primary', {
21 | variants: {
22 | size: {
23 | small: 'size-6',
24 | medium: 'size-8',
25 | large: 'size-12',
26 | },
27 | },
28 | defaultVariants: {
29 | size: 'medium',
30 | },
31 | });
32 |
33 | interface SpinnerContentProps
34 | extends VariantProps,
35 | VariantProps {
36 | className?: string;
37 | children?: React.ReactNode;
38 | }
39 |
40 | export function Spinner({
41 | size,
42 | show,
43 | children,
44 | className,
45 | }: SpinnerContentProps) {
46 | return (
47 |
48 |
49 | {children}
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<'textarea'>
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | });
20 | Textarea.displayName = 'Textarea';
21 |
22 | export { Textarea };
23 |
--------------------------------------------------------------------------------
/constants.ts:
--------------------------------------------------------------------------------
1 | const Constants = {
2 | AppName: 'Obsidian Stats',
3 | };
4 |
5 | export default Constants;
6 |
--------------------------------------------------------------------------------
/data/indexnow.json:
--------------------------------------------------------------------------------
1 | {
2 | "lastSubmission": "2025-05-12T10:25:50.259Z"
3 | }
--------------------------------------------------------------------------------
/data/siteData.test.ts:
--------------------------------------------------------------------------------
1 | import { SiteData } from './siteData';
2 |
3 | describe('SiteData', () => {
4 | it('should have highlights', () => {
5 | expect(SiteData.highlights).toBeDefined();
6 | expect(SiteData.highlights.length).toBeGreaterThan(0);
7 | });
8 |
9 | it('should have faqs', () => {
10 | expect(SiteData.faqs).toBeDefined();
11 | expect(SiteData.faqs.length).toBeGreaterThan(0);
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/domain/plugins/models/Plugin.ts:
--------------------------------------------------------------------------------
1 | import { Plugin as PluginRecord } from '@prisma/client';
2 | import { PluginRatingInfo } from './PluginRatingInfo';
3 |
4 | export type Plugin = PluginRecord & {
5 | pluginRatingInfo?: PluginRatingInfo;
6 | };
7 |
--------------------------------------------------------------------------------
/domain/plugins/models/PluginRatingInfo.ts:
--------------------------------------------------------------------------------
1 | export type PluginRatingInfo = {
2 | avgRating: number;
3 | ratingCount: number;
4 | star5Count: number;
5 | star4Count: number;
6 | star3Count: number;
7 | star2Count: number;
8 | star1Count: number;
9 | };
10 |
--------------------------------------------------------------------------------
/domain/remark/ad-hander.ts:
--------------------------------------------------------------------------------
1 | import { visit } from 'unist-util-visit';
2 |
3 | export const remarkAdHandler = () => {
4 | return (tree) => {
5 | visit(tree, 'code', (node) => {
6 | if (node.lang === 'cust-ad') {
7 | const type = node.value.trim();
8 | // as of now only type === 'line' is supported
9 | node.type = 'html';
10 | node.value = 'Test'; //`
`;
11 | }
12 | });
13 | };
14 | };
15 |
--------------------------------------------------------------------------------
/domain/remark/plugin-image-handler.ts:
--------------------------------------------------------------------------------
1 | import { visit } from 'unist-util-visit';
2 |
3 | export const remarkPluginImageHandler = () => {
4 | return (tree) => {
5 | visit(tree, 'code', (node: any) => {
6 | if (node.lang === 'plugin-image') {
7 | const value = (node.value || '').trim();
8 | const data: Record = {};
9 | value.split('\n').forEach((line) => {
10 | const idx = line.indexOf(':');
11 | if (idx !== -1) {
12 | const key = line.slice(0, idx).trim();
13 | const val = line.slice(idx + 1).trim();
14 | data[key] = val;
15 | }
16 | });
17 |
18 | const description = data['description'] || '';
19 | const url = data['url'] || '';
20 | const source = (data['source'] || '').toLowerCase();
21 |
22 | // Build HTML: description (if any), image centered, and optional attribution for github
23 | let html = ``;
24 | if (description) {
25 | html += `
${description}
`;
26 | }
27 |
28 | html += `
`;
31 |
32 | if (source === 'github' || source === 'gh') {
33 | html += `
Image from the plugin's source github repo
`;
34 | }
35 |
36 | html += `
`;
37 |
38 | node.type = 'html';
39 | node.value = html;
40 | }
41 | });
42 | };
43 | };
44 |
45 | function escapeHtml(str: string) {
46 | return str
47 | .replace(/&/g, '&')
48 | .replace(//g, '>')
50 | .replace(/"/g, '"')
51 | .replace(/'/g, ''');
52 | }
53 |
--------------------------------------------------------------------------------
/domain/scorer/ScorerUtils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Utility class for scoring-related functions.
3 | *
4 | * @category Scorer
5 | */
6 | export class ScorerUtils {
7 | /**
8 | * Normalizes a value within a given range.
9 | *
10 | * @param value - The value to normalize.
11 | * @param min - The minimum value of the range.
12 | * @param max - The maximum value of the range.
13 | * @returns The normalized value between 0 and 1.
14 | * Returns 0 if the value is less than or equal to the minimum.
15 | * Returns 1 if the value is greater than or equal to the maximum.
16 | * Returns 0.5 if the minimum and maximum values are equal.
17 | */
18 | normalize(value: number, min: number, max: number): number {
19 | if (value <= min) return 0;
20 | if (value >= max) return 1;
21 | if (min === max) return 0.5;
22 | return (value - min) / (max - min);
23 | }
24 |
25 | /**
26 | * Applies a normalized sigmoid function to a value within a given range.
27 | *
28 | * @param value - The value to apply the sigmoid function to.
29 | * @param min - The minimum value of the range.
30 | * @param max - The maximum value of the range.
31 | * @param k - The steepness of the sigmoid curve (default is 0.1).
32 | * @returns The value transformed by the sigmoid function, normalized between 0 and 1.
33 | * Returns 0 if the value is less than the minimum.
34 | * Returns 1 if the value is greater than the maximum.
35 | * Returns 0.5 if the minimum and maximum values are equal.
36 | */
37 | normalizedSigmoid(
38 | x: number,
39 | min: number,
40 | max: number,
41 | k: number = 0.1
42 | ): number {
43 | if (x < min) return 0;
44 | if (x > max) return 1;
45 | if (max === min) return 0.5;
46 |
47 | const midpoint = (min + max) / 2;
48 |
49 | // Sigmoid function centered at the midpoint
50 | const sigmoid = (value: number): number => {
51 | return 1 / (1 + Math.exp(-k * (value - midpoint)));
52 | };
53 |
54 | // Values at the boundaries
55 | const sMin = sigmoid(min);
56 | const sMax = sigmoid(max);
57 |
58 | // Normalize between 0 and 1
59 | return (sigmoid(x) - sMin) / (sMax - sMin);
60 | }
61 |
62 | /**
63 | * Removes duplicate elements from an array.
64 | *
65 | * @param array - The array from which to remove duplicates.
66 | * @param keyMapper - Optional function to map array elements to a key for comparison.
67 | * @returns A new array with duplicates removed.
68 | */
69 | removeDuplicates(array: T[], keyMapper?: (obj: T) => boolean): T[] {
70 | const map = new Map();
71 | if (keyMapper) {
72 | array.forEach((value) => map.set(keyMapper(value), value));
73 | return Array.from(map.values());
74 | }
75 | array.forEach((value) => map.set(value, value));
76 | return Array.from(map.values());
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/domain/scorer/models/PluginMetrics.model.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Represents the metrics of a plugin.
3 | *
4 | * @category Scorer
5 | * @typedef {object} PluginMetrics
6 | */
7 | export type PluginMetrics = {
8 | /**
9 | * The unique identifier of the plugin.
10 | */
11 | pluginId: string;
12 |
13 | /**
14 | * The name of the plugin.
15 | */
16 | name: string;
17 |
18 | /**
19 | * The timestamp when the plugin was created.
20 | */
21 | createdAt: number;
22 |
23 | /**
24 | * The timestamp of the last commit made to the plugin.
25 | */
26 | lastCommitAt: number;
27 |
28 | /**
29 | * The number of stargazers of the plugin.
30 | */
31 | stargazers: number;
32 |
33 | /**
34 | * The number of subscribers of the plugin.
35 | */
36 | subscribers: number;
37 |
38 | /**
39 | * The number of forks of the plugin.
40 | */
41 | forks: number;
42 |
43 | /**
44 | * The version of the latest release of the plugin.
45 | */
46 | latestRelease: string;
47 |
48 | /**
49 | * The description of the latest release of the plugin.
50 | */
51 | latestReleaseDesc: string;
52 |
53 | /**
54 | * The timestamp of the latest release of the plugin.
55 | */
56 | latestReleaseAt: number;
57 |
58 | /**
59 | * The total number of downloads of the plugin.
60 | */
61 | totalDownloads: number;
62 |
63 | /**
64 | * The total number of issues reported for the plugin.
65 | */
66 | totalIssues: number;
67 |
68 | /**
69 | * The number of closed issues of the plugin.
70 | */
71 | closedIssues: number;
72 |
73 | /**
74 | * The number of open issues of the plugin.
75 | */
76 | openIssues: number;
77 |
78 | /**
79 | * The total number of pull requests made to the plugin.
80 | */
81 | totalPR: number;
82 |
83 | /**
84 | * The number of open pull requests of the plugin.
85 | */
86 | openPR: number;
87 |
88 | /**
89 | * The number of closed pull requests of the plugin.
90 | */
91 | closedPR: number;
92 |
93 | /**
94 | * The number of merged pull requests of the plugin.
95 | */
96 | mergedPR: number;
97 |
98 | /**
99 | * The number of commits made to the plugin in the last year.
100 | */
101 | commitCountInLastYear: number;
102 |
103 | /**
104 | * The score of the plugin based on various metrics.
105 | */
106 | score: number;
107 | };
108 |
--------------------------------------------------------------------------------
/domain/suggestions/models/index.ts:
--------------------------------------------------------------------------------
1 | import { Post } from '../../../lib/abstractions';
2 | import { Plugin } from '@prisma/client';
3 |
4 | export type PageInfo = {
5 | type: 'posts' | 'post' | 'plugins' | 'plugin' | 'tool' | 'page';
6 | slug: string;
7 | };
8 |
9 | export type Suggestions = {
10 | tools: ToolSuggestion[];
11 | posts: Post[];
12 | similarPlugins: Plugin[];
13 | hasMoreSimilarPlugins?: boolean;
14 | };
15 |
16 | export type ToolSuggestion = {
17 | name: string;
18 | description: string;
19 | link: string;
20 | };
21 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import globals from 'globals';
2 | import pluginJs from '@eslint/js';
3 | import tseslint from 'typescript-eslint';
4 | import pluginReact from 'eslint-plugin-react';
5 |
6 | /** @type {import('eslint').Linter.Config[]} */
7 | export default [
8 | { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] },
9 | { languageOptions: { globals: { ...globals.browser, ...globals.node } } },
10 | pluginJs.configs.recommended,
11 | ...tseslint.configs.recommended,
12 | pluginReact.configs.flat.recommended,
13 | ];
14 |
--------------------------------------------------------------------------------
/hooks/useCustomScore.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { patchPluginsWithCustomScore } from '../lib/scorer';
3 | import { useScoreListStore, useScorerStore } from '../store/scorer-store';
4 | import { PluginMetrics } from '../cache/plugins-cache';
5 | import { Plugin } from '@prisma/client';
6 |
7 | export const useCustomScore = (initialPlugins: PluginMetrics[] | Plugin[]) => {
8 | const pluginsScoreMap = useScoreListStore((state) => state.scores);
9 | const getActiveScorer = useScorerStore((state) => state.getActiveScorer);
10 | const activeScorer = getActiveScorer();
11 | const enableCustomScorer = useScorerStore(
12 | (state) => state.enableCustomScorer
13 | );
14 | const [plugins, setPlugins] = useState(initialPlugins);
15 |
16 | useEffect(() => {
17 | if (enableCustomScorer && activeScorer) {
18 | const patchedPlugins = patchPluginsWithCustomScore(
19 | initialPlugins,
20 | pluginsScoreMap
21 | );
22 | setPlugins(patchedPlugins);
23 | }
24 | }, [pluginsScoreMap]);
25 |
26 | return plugins;
27 | };
28 |
--------------------------------------------------------------------------------
/hooks/useCustomScoreWithScoreUpdater.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { patchPluginsWithCustomScore } from '../lib/scorer';
3 | import { useScoreListStore, useScorerStore } from '../store/scorer-store';
4 | import { Plugin } from '@prisma/client';
5 | import { scorePlugins } from '../lib/scorer';
6 |
7 | export const useCustomScoreWithScoreUpdater = (initialPlugins: Plugin[]) => {
8 | const pluginsScoreMap = useScoreListStore((state) => state.scores);
9 | const getActiveScorer = useScorerStore((state) => state.getActiveScorer);
10 |
11 | const enableCustomScorer = useScorerStore(
12 | (state) => state.enableCustomScorer
13 | );
14 | const activeScorer = getActiveScorer();
15 |
16 | const scoringTimestamp = useScoreListStore((state) => state.scoringTimestamp);
17 | const setScores = useScoreListStore((state) => state.setScores);
18 |
19 | const [plugins, setPlugins] = useState(initialPlugins);
20 |
21 | useEffect(() => {
22 | if (enableCustomScorer && activeScorer) {
23 | const hasNewPlugins = initialPlugins.some(
24 | (plugin) => !pluginsScoreMap[plugin.pluginId]
25 | );
26 | const scoreOutdated = Date.now() - scoringTimestamp > 86400000; // Older than 24 hours
27 |
28 | if (hasNewPlugins || scoreOutdated) {
29 | console.log(
30 | `useCustomScoreWithScoreUpdater: Scoring plugins with custom scorer ${activeScorer.id} ${activeScorer.name}`
31 | );
32 | try {
33 | const pluginsScoreMap = scorePlugins(initialPlugins, activeScorer);
34 | setScores(pluginsScoreMap, activeScorer.updatedAt, Date.now());
35 | } catch (error) {
36 | console.error('Error executing code:', error);
37 | }
38 | }
39 | }
40 | }, []);
41 |
42 | useEffect(() => {
43 | if (enableCustomScorer && activeScorer) {
44 | const patchedPlugins = patchPluginsWithCustomScore(
45 | initialPlugins,
46 | pluginsScoreMap
47 | );
48 | setPlugins(patchedPlugins);
49 | }
50 | }, [pluginsScoreMap]);
51 |
52 | return plugins;
53 | };
54 |
--------------------------------------------------------------------------------
/hooks/useIsLessThanLarge.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export const useIsLessThanLarge = () => {
4 | const [isLessThanLarge, setIsLessThanLarge] = useState(false);
5 |
6 | useEffect(() => {
7 | const checkScreenSize = () => {
8 | setIsLessThanLarge(window.innerWidth < 1024); // Tailwind's `md` breakpoint is 768px
9 | };
10 |
11 | checkScreenSize();
12 | window.addEventListener('resize', checkScreenSize);
13 | return () => window.removeEventListener('resize', checkScreenSize);
14 | }, []);
15 |
16 | return isLessThanLarge;
17 | };
18 |
--------------------------------------------------------------------------------
/hooks/useScoreUpdater.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { scorePlugins } from '../lib/scorer';
3 | import { useScoreListStore, useScorerStore } from '../store/scorer-store';
4 | import { PluginMetrics } from '../cache/plugins-cache';
5 | import { Plugin } from '@prisma/client';
6 |
7 | export const useScoreUpdater = (initialPlugins: PluginMetrics[] | Plugin[]) => {
8 | const getActiveScorer = useScorerStore((state) => state.getActiveScorer);
9 | const enableCustomScorer = useScorerStore(
10 | (state) => state.enableCustomScorer
11 | );
12 | const activeScorer = getActiveScorer();
13 | const pluginsScoreMap = useScoreListStore((state) => state.scores);
14 | const scoringTimestamp = useScoreListStore((state) => state.scoringTimestamp);
15 | const setScores = useScoreListStore((state) => state.setScores);
16 |
17 | useEffect(() => {
18 | if (enableCustomScorer && activeScorer) {
19 | const hasNewPlugins = initialPlugins.some(
20 | (plugin) => !pluginsScoreMap[plugin.pluginId]
21 | );
22 | const scoreOutdated = Date.now() - scoringTimestamp > 86400000; // Older than 24 hours
23 |
24 | if (hasNewPlugins || scoreOutdated) {
25 | console.log(
26 | `useCustomScore: Scoring plugins with custom scorer ${activeScorer.id} ${activeScorer.name}`
27 | );
28 | try {
29 | const pluginsScoreMap = scorePlugins(initialPlugins, activeScorer);
30 | setScores(pluginsScoreMap, activeScorer.updatedAt, Date.now());
31 | } catch (error) {
32 | console.error('Error executing code:', error);
33 | }
34 | }
35 | }
36 | }, []);
37 | };
38 |
--------------------------------------------------------------------------------
/hooks/useUser.ts:
--------------------------------------------------------------------------------
1 | import { supabase } from '@/lib/supabase';
2 | import { useEffect, useState } from 'react';
3 |
4 | const useUser = () => {
5 | const [user, setUser] = useState(null);
6 | const [loading, setLoading] = useState(true);
7 | const [authChanged, setAuthChanged] = useState(false);
8 |
9 | useEffect(() => {
10 | const fetchUser = async () => {
11 | setLoading(true);
12 | const {
13 | data: { user },
14 | error,
15 | } = await supabase.auth.getUser();
16 | if (!error) {
17 | setUser(user);
18 | }
19 | setLoading(false);
20 | };
21 |
22 | fetchUser();
23 | }, [authChanged]);
24 |
25 | const login = () => {
26 | const returnTo = window.location.pathname;
27 | supabase.auth
28 | .signInWithOAuth({
29 | provider: 'google',
30 | options: {
31 | redirectTo: `${window.location.origin}/auth/callback?returnTo=${encodeURIComponent(returnTo)}`,
32 | },
33 | })
34 | .then(() => setAuthChanged((prev) => !prev));
35 | };
36 |
37 | const logout = async () => {
38 | await supabase.auth.signOut();
39 | setUser(null);
40 | setAuthChanged((prev) => !prev);
41 | };
42 |
43 | return { user, loading, login, logout };
44 | };
45 |
46 | export default useUser;
47 |
--------------------------------------------------------------------------------
/jsdoc.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["node_modules/better-docs/typescript", "better-docs/category"],
3 | "source": {
4 | "include": ["./domain/scorer"],
5 | "includePattern": ".+\\.ts$"
6 | },
7 | "tags": {
8 | "allowUnknownTags": true,
9 | "dictionaries": ["jsdoc"]
10 | },
11 | "opts": {
12 | "encoding": "utf8",
13 | "destination": "public/docs/",
14 | "recurse": true,
15 | "verbose": true,
16 | "readme": "./docs-readme.md",
17 | "template": "node_modules/better-docs"
18 | },
19 | "templates": {
20 | "better-docs": {
21 | "name": "Obsidian Stats",
22 | "title": "Docs | Obsidian Stats",
23 | "footer": false
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/lib/abstractions.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | export interface Post {
4 | id: string;
5 | title: string;
6 | description?: string;
7 | excerpt?: string;
8 | publishedDate: string;
9 | modifiedDate: string;
10 | ogImage?: string;
11 | bannerImage?: string;
12 | tags?: string[];
13 | plugins?: string[];
14 | contentHtml?: string;
15 | content: string;
16 | }
17 |
18 | export interface Highlight {
19 | title: string;
20 | image: string;
21 | description: string;
22 | link: string;
23 | ctaText: string;
24 | bgClasses: string;
25 | }
26 |
27 | export interface Faq {
28 | question: string;
29 | answer: string;
30 | }
31 |
32 | export interface Scorer {
33 | id: string;
34 | name: string;
35 | description: string;
36 | code: string;
37 | updatedAt: number;
38 | }
39 |
--------------------------------------------------------------------------------
/lib/analytics/analytics.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import { createContext, useContext } from 'react';
5 | import { IAnalyticsStrategy } from './types/analytics';
6 | import {
7 | usePlausibleStrategy,
8 | withPlausibleProvider,
9 | } from './strategies/plausible.strategy';
10 |
11 | interface AnalyticsContextType {
12 | strategy: IAnalyticsStrategy;
13 | }
14 |
15 | const AnalyticsContext = createContext(
16 | undefined
17 | );
18 |
19 | export const AnalyticsProvider = ({
20 | children,
21 | }: {
22 | children: React.ReactNode;
23 | }) => {
24 | const strategy = usePlausibleStrategy();
25 |
26 | const wrappedChildren = withPlausibleProvider(
27 |
28 | {children}
29 |
30 | );
31 |
32 | return wrappedChildren;
33 | };
34 |
35 | export const useAnalytics = () => {
36 | const context = useContext(AnalyticsContext);
37 | if (!context) {
38 | throw new Error('useAnalytics must be used within AnalyticsProvider');
39 | }
40 | return context.strategy;
41 | };
42 |
--------------------------------------------------------------------------------
/lib/analytics/strategies/plausible.strategy.tsx:
--------------------------------------------------------------------------------
1 |
2 | import React, { ReactNode } from 'react';
3 | import PlausibleProvider, { usePlausible } from 'next-plausible';
4 | import { IAnalyticsStrategy } from '../types/analytics';
5 |
6 | export const usePlausibleStrategy = (): IAnalyticsStrategy => {
7 | const plausible = usePlausible();
8 | return {
9 | trackEvent: (event, props) => plausible(event, props),
10 | };
11 | };
12 |
13 | export const withPlausibleProvider = (children: ReactNode) => {
14 | return (
15 |
22 | {children}
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/lib/analytics/types/analytics.ts:
--------------------------------------------------------------------------------
1 | export interface IAnalyticsStrategy {
2 | trackEvent: (eventName: string, eventData?: Record) => void;
3 | }
4 |
--------------------------------------------------------------------------------
/lib/customThemes.test.ts:
--------------------------------------------------------------------------------
1 | import { getScoreBgClass, getScoreTextClass } from './customThemes';
2 |
3 | describe('customThemes', () => {
4 | describe('getScoreBgClass', () => {
5 | it('should return correct class for score > 0.8', () => {
6 | expect(getScoreBgClass(0.9)).toBe(
7 | 'bg-emerald-500 text-white rounded-full p-1'
8 | );
9 | });
10 |
11 | it('should return correct class for score > 0.6', () => {
12 | expect(getScoreBgClass(0.7)).toBe(
13 | 'bg-lime-500 text-white rounded-full p-1'
14 | );
15 | });
16 |
17 | it('should return correct class for score > 0.4', () => {
18 | expect(getScoreBgClass(0.5)).toBe(
19 | 'bg-yellow-500 text-white rounded-full p-1'
20 | );
21 | });
22 |
23 | it('should return correct class for score > 0.2', () => {
24 | expect(getScoreBgClass(0.3)).toBe(
25 | 'bg-amber-500 text-white rounded-full p-1'
26 | );
27 | });
28 |
29 | it('should return correct class for score <= 0.2', () => {
30 | expect(getScoreBgClass(0.1)).toBe(
31 | 'bg-red-500 text-white rounded-full p-1'
32 | );
33 | });
34 | });
35 |
36 | describe('getScoreTextClass', () => {
37 | it('should return correct class for score > 0.8', () => {
38 | expect(getScoreTextClass(0.9)).toBe('text-emerald-500');
39 | });
40 |
41 | it('should return correct class for score > 0.6', () => {
42 | expect(getScoreTextClass(0.7)).toBe('text-lime-500');
43 | });
44 |
45 | it('should return correct class for score > 0.4', () => {
46 | expect(getScoreTextClass(0.5)).toBe('text-yellow-500');
47 | });
48 |
49 | it('should return correct class for score > 0.2', () => {
50 | expect(getScoreTextClass(0.3)).toBe('text-amber-500');
51 | });
52 |
53 | it('should return correct class for score <= 0.2', () => {
54 | expect(getScoreTextClass(0.1)).toBe('text-red-500');
55 | });
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/lib/environment.ts:
--------------------------------------------------------------------------------
1 | export const isLocalhost = process.env.NODE_ENV === 'development';
--------------------------------------------------------------------------------
/lib/feature-flag/feature-flags.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import { ReactNode, useEffect, useRef } from 'react';
5 | import {
6 | GrowthBook,
7 | GrowthBookProvider,
8 | useGrowthBook,
9 | } from '@growthbook/growthbook-react';
10 | import { FeatureFlagKey, FeatureFlagKeyMap, FeatureFlags } from './types/flags';
11 | import { useAnalytics } from '../analytics/analytics';
12 |
13 | const growthbook = new GrowthBook({
14 | apiHost: 'https://growthbookapi.obsidianstats.com',
15 | clientKey: 'sdk-enqBUVjMi1J4Nvcx', // dev = "sdk-LX4vbNdwC77FVA",
16 | enableDevMode: process.env.NODE_ENV === 'development',
17 | trackingCallback: (experiment, result) => {
18 | // This is where you would send an event to your analytics provider
19 | console.log('Viewed Experiment', {
20 | experimentId: experiment.key,
21 | variationId: result.key,
22 | });
23 | },
24 | });
25 |
26 | type Props = {
27 | children: ReactNode;
28 | };
29 |
30 | const getGbAnonUserId = () => {
31 | if (typeof window === 'undefined') {
32 | return 'nouser';
33 | }
34 |
35 | let anonId = localStorage.getItem('gbAnonUserId');
36 | if (!anonId) {
37 | anonId = crypto.randomUUID();
38 | localStorage.setItem('gbAnonId', anonId);
39 | }
40 | return anonId;
41 | };
42 |
43 | export const FeatureFlagProvider = ({ children }: Props) => {
44 | useEffect(() => {
45 | growthbook.setAttributes({
46 | id: getGbAnonUserId(),
47 | });
48 |
49 | if (typeof window !== 'undefined' && !!localStorage) {
50 | growthbook.updateAttributes({
51 | com: {
52 | obsidianstats: {
53 | plugins: {
54 | enableRating:
55 | localStorage.getItem(
56 | 'com.obsidianstats.plugins.enableRating'
57 | ) === 'true',
58 | },
59 | },
60 | },
61 | });
62 | }
63 |
64 | growthbook.init({
65 | streaming: false,
66 | });
67 | }, []);
68 |
69 | return (
70 | {children}
71 | );
72 | };
73 |
74 | export const useFeatureFlag = (
75 | flagKey: K,
76 | defaultValue: FeatureFlags[K]
77 | ): FeatureFlags[K] => {
78 | const sbFlagkey = FeatureFlagKeyMap[flagKey];
79 |
80 | const gb = useGrowthBook();
81 | const { trackEvent } = useAnalytics();
82 | const tracked = useRef(false);
83 | const value = (gb?.getFeatureValue?.(sbFlagkey, defaultValue) ??
84 | defaultValue) as FeatureFlags[K];
85 |
86 | useEffect(() => {
87 | if (typeof window === 'undefined') return; // skip SSR
88 | if (!tracked.current) {
89 | trackEvent('Feature Flag Consumed', {
90 | props: {
91 | [sbFlagkey]: value,
92 | },
93 | });
94 | tracked.current = true;
95 | }
96 | }, [sbFlagkey, value, trackEvent]);
97 |
98 | return value;
99 | };
100 |
--------------------------------------------------------------------------------
/lib/feature-flag/types/flags.ts:
--------------------------------------------------------------------------------
1 | export interface FeatureFlags {
2 | enablePluginRating: boolean;
3 | }
4 |
5 | export const FeatureFlagKeyMap = {
6 | enablePluginRating: 'enable-plugin-rating',
7 | };
8 |
9 | export type FeatureFlagKey = keyof FeatureFlags;
10 |
--------------------------------------------------------------------------------
/lib/flags.ts:
--------------------------------------------------------------------------------
1 | export const AppFlags = {
2 | enableGoogleAds: true,
3 | enableSponsorAds: false,
4 | };
5 |
--------------------------------------------------------------------------------
/lib/legalDocs.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import matter from 'gray-matter';
4 |
5 | const legalDocsDirectory = path.join(process.cwd(), 'legal-docs');
6 |
7 | export function getAllLegalDocIds() {
8 | const fileNames = fs.readdirSync(legalDocsDirectory);
9 | return fileNames.map((fileName) => {
10 | return {
11 | params: {
12 | legal: fileName.replace(/\.md$/, ''),
13 | },
14 | };
15 | });
16 | }
17 |
18 | export function getLegalDocData(id: string) {
19 | const fullPath = path.join(legalDocsDirectory, `${id}.md`);
20 | const fileContents = fs.readFileSync(fullPath, 'utf8');
21 | const matterResult = matter(fileContents);
22 |
23 | return {
24 | id,
25 | content: matterResult.content,
26 | ...matterResult.data,
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/lib/plugins.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 | import moment from 'moment';
3 | import { PluginsCache } from '../cache/plugins-cache';
4 |
5 | interface TopDownloadedStat {
6 | _id: string;
7 | totalDownloads: number;
8 | }
9 |
10 | export const getMostDownloadedPlugins = async (nDays: number, topN: number) => {
11 | const prisma = new PrismaClient();
12 | const plugins = await PluginsCache.get();
13 |
14 | try {
15 | // Calculate the timestamp for the specified number of days ago
16 | const daysAgo = moment().subtract(nDays, 'days').startOf('day').valueOf();
17 |
18 | const topPluginsJson = await prisma.deltaDownloads.aggregateRaw({
19 | pipeline: [
20 | // 1. Only consider the documents for the last 7 days
21 | { $match: { timestamp: { $gte: daysAgo } } },
22 |
23 | // 2. Sort by pluginId first (ascending), then by timestamp (descending)
24 | { $sort: { timestamp: -1 } },
25 |
26 | // 3. Group by pluginId and collect deltaDownload values in an array
27 | {
28 | $group: {
29 | _id: '$pluginId',
30 | downloadsArray: { $push: '$deltaDownloads' },
31 | },
32 | },
33 |
34 | // 4. Slice the first 7 from downloadsArray and sum them
35 | {
36 | $project: {
37 | _id: 1,
38 | totalDownloads: { $sum: { $slice: ['$downloadsArray', nDays] } },
39 | },
40 | },
41 |
42 | // 5. Sort by sumLast7Days in descending order and limit to top 25
43 | { $sort: { totalDownloads: -1 } },
44 | { $limit: topN },
45 | ],
46 | });
47 |
48 | const topPlugins = topPluginsJson as any as TopDownloadedStat[];
49 |
50 | return topPlugins.map((plugin) => ({
51 | ...plugins.find((p) => p.pluginId === plugin._id),
52 | totalDownloads: plugin.totalDownloads,
53 | }));
54 | } catch (error) {
55 | console.error('Error fetching top plugins:', error);
56 | throw error;
57 | } finally {
58 | await prisma.$disconnect();
59 | }
60 | };
61 |
--------------------------------------------------------------------------------
/lib/posts.test.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import matter from 'gray-matter';
4 | import { getSortedPostsData, getPostData, getAllPostIds } from './posts';
5 |
6 | // Mock the fs and path modules
7 | jest.mock('fs');
8 | jest.mock('path');
9 | jest.mock('gray-matter');
10 |
11 | describe('posts', () => {
12 | beforeEach(() => {
13 | jest.resetAllMocks();
14 | (path.join as jest.Mock).mockImplementation((...args) => args.join('/'));
15 | });
16 |
17 | describe('getSortedPostsData', () => {
18 | it('should return sorted posts data', () => {
19 | const fileNames = ['post1.md', 'post2.md'];
20 | const fileContent1 =
21 | '---\ntitle: Test Post 1\npublishedDate: 2023-01-01\n---\nContent';
22 | const matterResult1 = {
23 | data: { title: 'Test Post 1', publishedDate: '2023-01-01' },
24 | content: 'Content',
25 | };
26 | const fileContent2 =
27 | '---\ntitle: Test Post 2\npublishedDate: 2023-01-02\n---\nContent';
28 | const matterResult2 = {
29 | data: { title: 'Test Post 2', publishedDate: '2023-01-02' },
30 | content: 'Content',
31 | };
32 |
33 | (fs.readdirSync as jest.Mock).mockReturnValue(fileNames);
34 | (fs.readFileSync as jest.Mock)
35 | .mockReturnValueOnce(fileContent1)
36 | .mockReturnValueOnce(fileContent2);
37 | (matter as jest.Mock)
38 | .mockReturnValueOnce(matterResult1)
39 | .mockReturnValueOnce(matterResult2);
40 |
41 | const result = getSortedPostsData();
42 | expect(result).toEqual([
43 | { id: 'post2', title: 'Test Post 2', publishedDate: '2023-01-02' },
44 | { id: 'post1', title: 'Test Post 1', publishedDate: '2023-01-01' },
45 | ]);
46 | });
47 | });
48 |
49 | describe('getPostData', () => {
50 | it('should return post data', () => {
51 | const id = 'post1';
52 | const fileContents =
53 | '---\ntitle: Test Post\npublishedDate: 2023-01-01\n---\nContent';
54 | const matterResult = {
55 | data: { title: 'Test Post', publishedDate: '2023-01-01' },
56 | content: 'Content',
57 | };
58 |
59 | (fs.readFileSync as jest.Mock).mockReturnValue(fileContents);
60 | (matter as jest.Mock).mockReturnValue(matterResult);
61 |
62 | const result = getPostData(id);
63 | expect(result).toEqual({
64 | id: 'post1',
65 | title: 'Test Post',
66 | publishedDate: '2023-01-01',
67 | content: 'Content',
68 | });
69 | });
70 | });
71 |
72 | describe('getAllPostIds', () => {
73 | it('should return all post ids', () => {
74 | const fileNames = ['post1.md', 'post2.md'];
75 |
76 | (fs.readdirSync as jest.Mock).mockImplementation((dir) => {
77 | return fileNames;
78 | });
79 |
80 | const result = getAllPostIds();
81 | expect(result).toEqual([
82 | { params: { slug: 'post1' } },
83 | { params: { slug: 'post2' } },
84 | ]);
85 | });
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/lib/posts.ts:
--------------------------------------------------------------------------------
1 | // lib/posts.ts
2 | import fs from 'fs';
3 | import path from 'path';
4 | import matter from 'gray-matter';
5 | import { Post } from './abstractions';
6 |
7 | const postsDirectory = path.join(process.cwd(), 'posts');
8 | const postsJsxDirectory = path.join(process.cwd(), 'pages', 'posts');
9 |
10 | const isLocal = process.env.NODE_ENV === 'development';
11 |
12 | export function getSortedPostsData(): Post[] {
13 | const fileNames = fs.readdirSync(postsDirectory);
14 |
15 | const allPostsData = fileNames.map((fileName) => {
16 | const id = fileName.replace(/\.md$/, '');
17 | const fullPath = path.join(postsDirectory, fileName);
18 | const fileContents = fs.readFileSync(fullPath, 'utf8');
19 | const matterResult = matter(fileContents);
20 |
21 | return {
22 | id,
23 | ...matterResult.data,
24 | } as Post;
25 | });
26 |
27 | const now = new Date();
28 | return allPostsData
29 | .filter((post) => (isLocal ? true : new Date(post.publishedDate) < now))
30 | .sort(
31 | (a, b) =>
32 | new Date(b.publishedDate).getTime() -
33 | new Date(a.publishedDate).getTime()
34 | );
35 | }
36 |
37 | export function getPostData(id: string): Post {
38 | const fullPath = path.join(postsDirectory, `${id}.md`);
39 | const fileContents = fs.readFileSync(fullPath, 'utf8');
40 | const matterResult = matter(fileContents);
41 |
42 | return {
43 | id,
44 | content: matterResult.content,
45 | ...matterResult.data,
46 | } as Post;
47 | }
48 |
49 | export function getAllPostIds() {
50 | const isLocal = process.env.NODE_ENV === 'development';
51 | const now = new Date();
52 | const fileNames = fs.readdirSync(postsDirectory);
53 | const jsxFileNames = fs
54 | .readdirSync(postsJsxDirectory)
55 | .filter((fileName) => fileName !== '[slug].tsx' && fileName !== 'index.jsx')
56 | .map((fileName) => fileName.replace(/\.tsx$/, ''));
57 |
58 | return fileNames
59 | .filter((fileName) => !jsxFileNames.includes(fileName.replace(/\.md$/, '')))
60 | .filter((fileName) => {
61 | const date = new Date(fileName.substring(0, 10));
62 | return isLocal ? true : date.getTime() < now.getTime();
63 | })
64 | .map((fileName) => {
65 | return {
66 | params: {
67 | slug: fileName.replace(/\.md$/, ''),
68 | },
69 | };
70 | });
71 | }
72 |
--------------------------------------------------------------------------------
/lib/scorer.ts:
--------------------------------------------------------------------------------
1 | import DOMPurify from 'dompurify';
2 | import { PluginMetrics } from '../cache/plugins-cache';
3 | import { Plugin } from '@prisma/client';
4 | import { Scorer } from './abstractions';
5 | import { ScorerUtils } from '../domain/scorer/ScorerUtils';
6 |
7 | export const scorePlugins = (
8 | plugins: PluginMetrics[] | Plugin[],
9 | scorer: Scorer
10 | ): Record => {
11 | try {
12 | const { code } = scorer;
13 | const sanitizedCode = DOMPurify.sanitize(code);
14 |
15 | const func = new Function('plugins', 'utils', sanitizedCode);
16 | const timeout = setTimeout(() => {
17 | throw new Error('Code execution timed out');
18 | }, 60000);
19 | func(plugins, new ScorerUtils());
20 | clearTimeout(timeout);
21 |
22 | const pluginScoreMap = plugins
23 | .map((plugin) => {
24 | return {
25 | pluginId: plugin.pluginId,
26 | score: plugin.score,
27 | };
28 | })
29 | .reduce((acc, curr) => {
30 | acc[curr.pluginId] = curr.score;
31 | return acc;
32 | }, {});
33 |
34 | return pluginScoreMap;
35 | } catch (error) {
36 | console.error('Error executing code:', error);
37 | throw new Error('Error executing code');
38 | }
39 | };
40 |
41 | export const patchPluginsWithCustomScore = (
42 | plugins: PluginMetrics[] | Plugin[],
43 | pluginScoreMap: Record
44 | ) => {
45 | const patchedPlugins = plugins.map((plugin) => {
46 | const score = pluginScoreMap[plugin.pluginId];
47 | if (score !== undefined) {
48 | return {
49 | ...plugin,
50 | score: score,
51 | };
52 | }
53 | return {
54 | ...plugin,
55 | };
56 | });
57 |
58 | return patchedPlugins;
59 | };
60 |
61 | export const hasCustomScorer = (): boolean => {
62 | const pluginsScoreStr = localStorage.getItem('customScoreFunction');
63 | return !!pluginsScoreStr;
64 | };
65 |
--------------------------------------------------------------------------------
/lib/supabase-server.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@supabase/supabase-js';
2 |
3 | export const supabaseServer = createClient(
4 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
5 | process.env.SUPABASE_SERVICE_ROLE_KEY!
6 | );
7 |
--------------------------------------------------------------------------------
/lib/supabase.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@supabase/supabase-js';
2 |
3 | export const supabase = createClient(
4 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
5 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
6 | );
7 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/load-dot-env.ps1:
--------------------------------------------------------------------------------
1 | Get-Content .env | ForEach-Object {
2 | if ($_ -match '^\s*([^#][^=]+)=(.*)$') {
3 | $key = $matches[1].Trim()
4 | $value = $matches[2].Trim()
5 | [System.Environment]::SetEnvironmentVariable($key, $value, "Process")
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next-sitemap.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next-sitemap').IConfig} */
2 | module.exports = {
3 | siteUrl: process.env.SITE_URL || 'https://www.obsidianstats.com/',
4 | generateRobotsTxt: true,
5 | };
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const autoprefixer = require('autoprefixer');
2 |
3 | module.exports = {
4 | eslint: {
5 | // Warning: This allows production builds to successfully complete even if
6 | // your project has ESLint errors.
7 | ignoreDuringBuilds: true,
8 | },
9 | devIndicators: {
10 | autoPrerender: false,
11 | },
12 | i18n: {
13 | locales: ['en-US'],
14 | defaultLocale: 'en-US',
15 | },
16 | images: {
17 | domains: ['github.com', 'raw.githubusercontent.com', 'placehold.co'],
18 | minimumCacheTTL: 604800, // 7 days
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/pages/404.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import Link from 'next/link';
5 | import { useRouter } from 'next/router';
6 | import { useEffect } from 'react';
7 |
8 | const Custom404 = () => {
9 | const router = useRouter();
10 |
11 | useEffect(() => {
12 | const { asPath, query } = router;
13 | console.log('404 useEffect', asPath, query);
14 | }, []);
15 |
16 | return (
17 |
18 |
19 |
404
20 |
This page could not be found
21 |
22 |
23 |
Go back home
24 |
25 |
26 | );
27 | };
28 |
29 | export default Custom404;
30 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { FeatureFlagProvider } from '@/lib/feature-flag/feature-flags';
3 | import { AnalyticsProvider } from '../lib/analytics/analytics';
4 | import '../styles/globals.css';
5 | import 'dotenv/config';
6 |
7 | const ObsidianPluginStatsApp = ({ Component, pageProps }) => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default ObsidianPluginStatsApp;
18 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import GrowMeScript from '@/components/GrowmeScript';
3 | import { Html, Head, Main, NextScript } from 'next/document';
4 |
5 | export default function Document() {
6 | return (
7 |
8 |
9 | {/* Font: Google Nato Sans and Lato */}
10 |
11 |
12 |
16 | {/* First Party Analytics */}
17 | {/*
18 | */}
21 | {/* Google Analytics Tag */}
22 |
26 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/pages/_responsive-layout.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 |
3 | export interface IResponsiveLayoutProps {
4 | children: React.ReactNode;
5 | introduction?: React.ReactNode;
6 | sidebar?: React.ReactNode;
7 | }
8 |
9 | const ResponsiveLayout: React.FC = ({
10 | children,
11 | introduction,
12 | sidebar,
13 | }) => {
14 | const [isLessThanLarge, setIsLessThanLarge] = useState(false);
15 |
16 | useEffect(() => {
17 | const checkScreenSize = () => {
18 | setIsLessThanLarge(window.innerWidth < 1024); // Tailwind's `md` breakpoint is 768px
19 | };
20 |
21 | checkScreenSize();
22 | window.addEventListener('resize', checkScreenSize);
23 | return () => window.removeEventListener('resize', checkScreenSize);
24 | }, []);
25 |
26 | if (!sidebar) {
27 | return {children}
;
28 | }
29 |
30 | return (
31 |
32 | {/* Left Spacer */}
33 |
34 |
35 | {/* Main Content */}
36 |
39 | {introduction}
40 | {children}
41 | {isLessThanLarge && sidebar}
42 |
43 |
44 | {/* Sidebar */}
45 | {!isLessThanLarge && (
46 |
49 | )}
50 |
51 | {/* Right Spacer */}
52 |
53 |
54 | );
55 | };
56 |
57 | export default ResponsiveLayout;
58 |
--------------------------------------------------------------------------------
/pages/profile.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import useUser from '@/hooks/useUser';
5 | import { Button } from '@/components/ui/button';
6 | import AppNavbar from '../components/Navbar';
7 |
8 | const Profile = () => {
9 | const { user, loading, login, logout } = useUser();
10 |
11 | const handleDeleteAccount = () => {
12 | window.location.href =
13 | 'mailto:support@example.com?subject=Delete My Account';
14 | };
15 |
16 | return (
17 |
18 |
19 |
20 | {loading ? (
21 |
24 | ) : user ? (
25 |
26 |
27 | You are logged in via{' '}
28 |
29 | {user.app_metadata.provider}
30 | {' '}
31 | as {user.email}
32 |
33 |
34 |
35 | Log Out
36 |
37 |
42 | Delete My Account
43 |
44 |
45 |
46 | ) : (
47 |
48 |
49 | You are not logged in.
50 |
51 |
52 | Log In with Google
53 |
54 |
55 | )}
56 |
57 |
58 | );
59 | };
60 |
61 | export default Profile;
62 |
--------------------------------------------------------------------------------
/pages/test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | export default function PluginPromoBanner() {
3 | return (
4 |
5 |
6 | {/* Background circle shape on right */}
7 |
8 |
9 | {/* Top site name */}
10 |
11 | OBSIDIANSTATS.COM
12 |
13 |
14 | {/* Main title */}
15 |
16 | 46 Plugins that Help You Publish Your Obsidian Notes
17 |
18 |
19 | {/* Call to action button */}
20 |
21 | Read Now
22 |
23 |
24 | {/* Pattern of Xs in bottom left */}
25 |
26 | {Array.from({ length: 28 }).map((_, i) => (
27 | ×
28 | ))}
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}),
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/posts/2024-12-07-wrapped-2024.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Obsidian Plugins Wrapped 2024 - Key Milestones, Top Plugins, and Developer Highlights
3 | description: 'Discover Obsidian Plugins journey in 2024! From new plugin releases and updates to standout developers and the most downloaded plugins, explore the milestones and community contributions that shaped the Obsidian ecosystem this year.'
4 | excerpt: There are 775 new plugins and 12,512 plugin updates in 2024.
5 | publishedDate: '2024-12-07'
6 | modifiedDate: '2024-12-07'
7 | tags:
8 | - wrapped-yearly-post
9 | ---
10 |
--------------------------------------------------------------------------------
/posts/2025-04-22-recipes-plugins.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Best Plugins for Managing and Organizing Cooking Recipes in Obsidian
3 | description: Discover Obsidian plugins that turn your note-taking app into a smart recipe manager - organize, plan, and cook with ease.
4 | excerpt: Explore plugins that help you collect, tag, plan, and cook recipes right from your Obsidian vault. Perfect for chefs, foodies, and meal preppers.
5 | publishedDate: '2025-04-22'
6 | modifiedDate: '2025-05-12'
7 | ogImage: '/images/2025-04-22-recipes-workflow-og.webp'
8 | tags:
9 | - obsidian-plugins
10 | - workflow
11 | - cooking
12 | - recipes
13 | ---
14 |
15 | If you've ever used Obsidian to clip your favorite recipes, track your pantry, or plan your meals, you're not alone. This post, "From Vault to Kitchen," highlights plugins that make your cooking workflow in Obsidian effortless and enjoyable.
16 |
17 | As of _April 22, 2025_, we've identified **6 plugins** that can support food enthusiasts and home chefs in managing their kitchen life right inside Obsidian. From scraping recipes to tagging ingredients, building meal plans, and grocery lists, these tools bring your digital kitchen to life.
18 |
19 | 
20 |
21 | Whether you're just logging grandma's lasagna or planning a weekly vegan menu, these plugins will help you stay organized and inspired.
22 |
23 | ```plugin
24 | index=01
25 | pluginId=tmayoff-meals
26 | ```
27 |
28 | ---
29 |
30 | ```plugin
31 | index=02
32 | pluginId=cooklang-viewer-and-editor
33 | ```
34 |
35 | ---
36 |
37 | ```plugin
38 | index=03
39 | pluginId=cooklang-obsidian
40 | ```
41 |
42 | ---
43 |
44 | ```plugin
45 | index=04
46 | pluginId=cooksync
47 | ```
48 |
49 | ---
50 |
51 | ```plugin
52 | index=05
53 | pluginId=recipe-grabber
54 | ```
55 |
56 | ---
57 |
58 | ```plugin
59 | index=06
60 | pluginId=recipe-view
61 | ```
62 |
63 | ---
64 |
--------------------------------------------------------------------------------
/posts/2025-05-14-music-notation-plugins.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Best Plugins for Writing and Rendering Sheet Music in Obsidian
3 | description: Discover plugins that turn your Obsidian vault into a music notation workspace - compose, tab, log and render scores with ease.
4 | excerpt: This post explores Obsidian plugins designed for writing, formatting, and rendering music notation—from ABC and jTab to MIDI input and Indian percussion.
5 | publishedDate: '2025-05-14'
6 | modifiedDate: '2025-05-18'
7 | ogImage: '/images/2025-05-14-sheet-music-workflow-og.webp'
8 | tags:
9 | - obsidian-plugins
10 | - workflow
11 | - music-notation
12 | - sheet-music
13 | ---
14 |
15 | If you're a musician, composer, or music teacher using Obsidian to organize your ideas, you'll love this post-From Vault to Sheet Music. In this edition of our plugin workflow series, we spotlight tools that help you compose, annotate, and render sheet music directly inside your notes.
16 |
17 | As of __May 14, 2025__, we've found 11 music-focused plugins that support a wide range of notation formats - from ABC, jTab, and VexTab to full MEI and Guitar Pro rendering. Whether you're jotting down a melody, crafting a rhythmic composition or logging MIDI input, these tools turn your vault into a powerful creative space.
18 |
19 | 
20 |
21 |
22 | ```plugin
23 | index=01
24 | pluginId=music-code-blocks
25 | ```
26 |
27 | ---
28 |
29 | ```plugin
30 | index=02
31 | pluginId=palta-note
32 | ```
33 |
34 | ---
35 |
36 | ```plugin
37 | index=03
38 | pluginId=obsidian-jtab
39 | ```
40 |
41 | ---
42 |
43 | ```plugin
44 | index=04
45 | pluginId=markdown-chords
46 | ```
47 |
48 | ---
49 |
50 | ```plugin
51 | index=05
52 | pluginId=scales-chords
53 | ```
54 |
55 | ---
56 |
57 | ```plugin
58 | index=06
59 | pluginId=chord-lyrics
60 | ```
61 |
62 | ---
63 |
64 | ```plugin
65 | index=07
66 | pluginId=vextab
67 | ```
68 |
69 | ---
70 |
71 | ```plugin
72 | index=08
73 | pluginId=verovio-music-renderer
74 | ```
75 |
76 | ---
77 |
78 | ```plugin
79 | index=09
80 | pluginId=gtp-preview
81 | ```
82 |
83 | ---
84 |
85 | ```plugin
86 | index=10
87 | pluginId=midi-logger
88 | ```
89 |
90 | ---
91 |
92 | ```plugin
93 | index=11
94 | pluginId=lilypond
95 | ```
96 |
97 | ---
98 |
99 | This `Metronome` plugins is not exactly for music notations but it is very close to music notations.
100 |
101 | ```plugin
102 | index=12
103 | pluginId=obsidian-metronome-plugin
104 | ```
105 |
106 | ---
107 |
--------------------------------------------------------------------------------
/posts/2025-05-30-zotero-plugins.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Best Obsidian Plugins to Integrate Zetero with Obsidian
3 | description: Discover the best Obsidian plugins that integrate Zotero with Obsidian, streamlining your research and note-taking processes.
4 | excerpt: Explore the best plugins for Obsidian that enhance integration with Zotero, providing tools for efficient research management.
5 | publishedDate: '2025-05-30'
6 | modifiedDate: '2025-05-30'
7 | ogImage: /images/2025-05-30-zotero-plugins.webp
8 | bannerImage: /images/2025-05-30-zotero-plugins.webp
9 | tags:
10 | - obsidian-plugins
11 | - workflow
12 | - zotero
13 | - zotero-integration
14 | - academic-research
15 | ---
16 |
17 | Are you a researcher or academic looking to optimize your workflow? This blog post includes plugins designed to seamlessly integrate Zotero with Obsidian, enhancing your research and note-taking experience. From managing bibliographies to synchronizing references, these tools offer powerful features to streamline your academic endeavors. Explore and discover how these plugins can transform the way you organize and access your scholarly resources.
18 |
19 | As of __May 30, 2025__, we've found 10 plugins to integrate [Zotero](https://www.zotero.org/) with Obsidian.
20 |
21 | ```plugin-list
22 | pluginIds=biblib,obsidian-zotero-desktop-connector,zotero-bridge,zotero-link,obsidian-citation-plugin,simple-citations,zotero-sync-client,zotlit,obsidian-topic-linking,paste-quote
23 | ```
24 |
--------------------------------------------------------------------------------
/posts/2025-06-13-callout-plugins.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Best Obsidian Plugins to Help with Your Callouts
3 | description: Explore the best plugins for Obsidian that elevate your note-taking experience with advanced callout and layout features.
4 | excerpt: Discover the best Obsidian plugins for callouts and layouts, designed to enhance your note-taking experience.
5 | publishedDate: '2025-06-13'
6 | modifiedDate: '2025-06-13'
7 | ogImage: '/images/custom-callouts-in-obsidian-with-css-snippets.webp'
8 | bannerImage: '/images/custom-callouts-in-obsidian-with-css-snippets.webp'
9 | tags:
10 | - obsidian-plugins
11 | - callouts
12 | - workflow
13 | ---
14 |
15 | Callouts in Obsidian are visually styled blockquote elements designed to highlight important information using icons and colors. They're perfect for adding structure without breaking your note taking flow whether it's a quick tip, warning, or idea. While the built-in types like [!info], [!tip], and [!warning] are easy to use, they often fall short when your workflow demands more personalized structure. If you organize your notes by themes like morning routines, project status, or reflective journaling, you'll likely find the default options limiting. That's where customization and plugins come in. In this post, we'll explore the plugins that extend and enhance callouts in Obsidian, giving you full control over how they look, behave, and integrate into your personal system.
16 |
17 | As of 2025-06-13, there are 15 plugins to help with callouts in Obsidain.
18 |
19 | ```plugin-list
20 | pluginIds=obsidian-admonition, obsidian-list-callouts, obsidian-columns,callout-integrator,callout-suggestions,inline-admonitions,callout-copy-buttons,callout-toggles,callout-manager,docusaurus-style-admonitions,math-in-callout,callout-menu,calloutx,comments
21 | ```
22 |
--------------------------------------------------------------------------------
/posts/2025-07-10-chart-plugins.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Best Obsidian Plugins to embed Charts
3 | description: Explore the newest plugins for Obsidian that bring advanced charting and visualization capabilities to your notes.
4 | excerpt: Discover the latest Obsidian plugins designed to enhance your note-taking experience with dynamic charts and data visualizations.
5 | publishedDate: '2025-07-10'
6 | modifiedDate: '2025-07-10'
7 | ogImage: /images/2025-07-10-charts-plugins.webp
8 | bannerImage: /images/2025-07-10-charts-plugins.webp
9 | tags:
10 | - obsidian-plugins
11 | - workflow
12 | - data-visualization
13 | - charts
14 | ---
15 |
16 | Plain text reaches its limits quickly. When tracking metrics, documenting API performance, or mapping workflows, walls of text often fail. Charts step in to turn scattered numbers into patterns and technical noise into clarity.
17 |
18 | For anyone working in tech or data heavy fields, Obsidian's chart plugins are game changers. They let you embed bar graphs, timelines, pie charts, and interactive diagrams directly in your markdown. You can pull from JSON or CSV, create mermaid diagrams for sequence flows, or use chart.js to plot complex datasets all without leaving your notes.
19 |
20 | These tools turn Obsidian from a text editor into a lightweight data dashboard. They're ideal for developers, analysts, researchers, and anyone who needs to visualize relationships or trends without bouncing between apps.
21 |
22 | ```plugin-list
23 | pluginIds=obsidian-chartsview-plugin,obsidian-charts,findoc,note-metrics,size-history,sqlite-db,obsidian-vega,hill-charts,obsidian-plotly,sqlseal-charts,tinychart
24 | ```
25 |
--------------------------------------------------------------------------------
/posts/2025-07-18-pomodoro-plugins.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Best Obsidian Plugins for Pomodoro Timer Integration
3 | description: Explore the latest Pomodoro plugins for Obsidian to boost your productivity and manage time effectively.
4 | excerpt: Discover new and updated Pomodoro plugins for Obsidian that help you stay focused and organized.
5 | publishedDate: '2025-07-18'
6 | modifiedDate: '2025-07-18'
7 | ogImage: /images/2025-07-18-pomodoro-plugins.webp
8 | bannerImage: /images/2025-07-18-pomodoro-plugins.webp
9 | tags:
10 | - obsidian-plugins
11 | - workflow
12 | - pomodoro
13 | ---
14 |
15 | Time management is super important if you want to stay productive and focused, especially with all the distractions around us. Obsidian, our favourite note-taking app, now has some really handy plugins to bring the Pomodoro technique right into your workflow. These plugins come with features like timers, task lists, and even productivity tracking. With them, you can plan your work sessions better, stay on track, and take breaks at the right time. Let’s check out these cool plugins and see how they can level up your daily routine.
16 |
17 | ```plugin-list
18 | pluginIds=day-planner-og,obsidian-statusbar-pomo,pomodoro-widget,pomodoro-planner,book-smith,pomodoro-timer,obsidian-pomodoro-plugin,pomobar,obsidian-flexible-pomo,white-noise
19 | ```
20 |
--------------------------------------------------------------------------------
/public/a944fdca7e16402a80e17aead9645552.txt:
--------------------------------------------------------------------------------
1 | a944fdca7e16402a80e17aead9645552
--------------------------------------------------------------------------------
/public/bcee69848e584efbac6b1dcbadaa8c64.txt:
--------------------------------------------------------------------------------
1 | bcee69848e584efbac6b1dcbadaa8c64
--------------------------------------------------------------------------------
/public/docs/scripts/app.min.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | $().ready(function () {});
3 | var sidebarIsVisible = !1,
4 | toggleSidebar = function (e) {
5 | var a = !(0 < arguments.length && void 0 !== e) || e;
6 | $('#sidebarNav').toggleClass('sticky', a),
7 | $('#stickyNavbarOverlay').toggleClass('active', a),
8 | $('#hamburger').toggleClass('is-active'),
9 | (sidebarIsVisible = a);
10 | };
11 | $().ready(function () {
12 | $('#hamburger').click(function () {
13 | toggleSidebar(!sidebarIsVisible);
14 | }),
15 | $('#stickyNavbarOverlay').click(function () {
16 | sidebarIsVisible && toggleSidebar(!1);
17 | });
18 | });
19 | var OFFSET = 150;
20 | $().ready(function () {
21 | var o = $('#side-nav'),
22 | c = [];
23 | if (
24 | ($('.vertical-section').length || o.hide(),
25 | $('.vertical-section').each(function (e, a) {
26 | var i = $(a),
27 | t = i.find('> h1').text();
28 | if (t) {
29 | o.append($(' ').text(t));
30 | var s = $('');
31 | i.find('.members h4.name').each(function (e, a) {
32 | var i = $(a),
33 | t = i.find('.code-name').clone().children().remove().end().text(),
34 | n = i.find('a').attr('href'),
35 | r = $(' ')).text(t);
36 | s.append($(' ').append(r)),
37 | c.push({ link: r, offset: i.offset().top });
38 | }),
39 | o.append(s);
40 | } else
41 | i.find('.members h4.name').each(function (e, a) {
42 | var i = $(a),
43 | t = i.find('.code-name').clone().children().remove().end().text(),
44 | n = i.find('a').attr('href'),
45 | r = $(' ')).text(t);
46 | o.append(r), c.push({ link: r, offset: i.offset().top });
47 | });
48 | }),
49 | !$.trim(o.text()))
50 | )
51 | return o.hide();
52 | function e() {
53 | for (var e = n.scrollTop(), a = !1, i = c.length - 1; 0 <= i; i--) {
54 | var t = c[i];
55 | t.link.removeClass('is-active'),
56 | e + OFFSET >= t.offset
57 | ? a
58 | ? t.link.addClass('is-past')
59 | : (t.link.addClass('is-active'), (a = !0))
60 | : t.link.removeClass('is-past');
61 | }
62 | }
63 | var n = $('#main-content-wrapper');
64 | n.on('scroll', e),
65 | e(),
66 | c.forEach(function (e) {
67 | e.link.click(function () {
68 | n.animate({ scrollTop: e.offset - OFFSET + 1 }, 500);
69 | });
70 | });
71 | }),
72 | $().ready(function () {
73 | $('#sidebarNav a').each(function (e, a) {
74 | var i = $(a).attr('href');
75 | window.location.pathname.match('/' + i) &&
76 | ($(a).addClass('active'),
77 | $('#sidebarNav').scrollTop($(a).offset().top - 150));
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/public/docs/scripts/linenumber.js:
--------------------------------------------------------------------------------
1 | /*global document */
2 |
3 | (function () {
4 | var source = document.getElementsByClassName('prettyprint source linenums');
5 | var i = 0;
6 | var lineNumber = 0;
7 | var lineId;
8 | var lines;
9 | var totalLines;
10 | var anchorHash;
11 |
12 | if (source && source[0]) {
13 | anchorHash = document.location.hash.substring(1);
14 | lines = source[0].getElementsByTagName('li');
15 | totalLines = lines.length;
16 |
17 | for (; i < totalLines; i++) {
18 | lineNumber++;
19 | lineId = 'line' + lineNumber;
20 | lines[i].id = lineId;
21 | if (lineId === anchorHash) {
22 | lines[i].className += ' selected';
23 | }
24 | }
25 | }
26 | })();
27 |
--------------------------------------------------------------------------------
/public/docs/scripts/search.js:
--------------------------------------------------------------------------------
1 | (function () {
2 | const input = document.querySelector('#search');
3 | const targets = [...document.querySelectorAll('#sidebarNav li')];
4 | input.addEventListener('keyup', () => {
5 | // loop over each targets and hide the not corresponding ones
6 | targets.forEach((target) => {
7 | if (!target.innerText.toLowerCase().includes(input.value.toLowerCase())) {
8 | target.style.display = 'none';
9 |
10 | /**
11 | * Detects an empty list
12 | * Remove the list and the list's title if the list is not displayed
13 | */
14 | const list = [...target.parentNode.childNodes].filter(
15 | (elem) => elem.style.display !== 'none'
16 | );
17 |
18 | if (!list.length) {
19 | target.parentNode.style.display = 'none';
20 | target.parentNode.previousSibling.style.display = 'none';
21 | }
22 |
23 | /**
24 | * Detects empty category
25 | * Remove the entire category if no item is displayed
26 | */
27 | const category = [...target.parentNode.parentNode.childNodes].filter(
28 | (elem) => elem.tagName !== 'H2' && elem.style.display !== 'none'
29 | );
30 |
31 | if (!category.length) {
32 | target.parentNode.parentNode.style.display = 'none';
33 | }
34 | } else {
35 | target.parentNode.style.display = 'block';
36 | target.parentNode.previousSibling.style.display = 'block';
37 | target.parentNode.parentNode.style.display = 'block';
38 | target.style.display = 'block';
39 | }
40 | });
41 | });
42 | })();
43 |
--------------------------------------------------------------------------------
/public/docs/styles/iframe.css:
--------------------------------------------------------------------------------
1 | .bd__button {
2 | padding: 10px 0;
3 | text-align: right;
4 | }
5 | .bd__button > a {
6 | font-weight: 100;
7 | text-decoration: none;
8 | color: #bdc3cb;
9 | font-family: sans-serif;
10 | }
11 | .bd__button > a:hover {
12 | color: #798897;
13 | }
14 |
--------------------------------------------------------------------------------
/public/docs/styles/prettify-jsdoc.css:
--------------------------------------------------------------------------------
1 | /* JSDoc prettify.js theme */
2 |
3 | /* plain text */
4 | .pln {
5 | color: #000000;
6 | font-weight: normal;
7 | font-style: normal;
8 | }
9 |
10 | /* string content */
11 | .str {
12 | color: #006400;
13 | font-weight: normal;
14 | font-style: normal;
15 | }
16 |
17 | /* a keyword */
18 | .kwd {
19 | color: #000000;
20 | font-weight: bold;
21 | font-style: normal;
22 | }
23 |
24 | /* a comment */
25 | .com {
26 | font-weight: normal;
27 | font-style: italic;
28 | }
29 |
30 | /* a type name */
31 | .typ {
32 | color: #000000;
33 | font-weight: normal;
34 | font-style: normal;
35 | }
36 |
37 | /* a literal value */
38 | .lit {
39 | color: #006400;
40 | font-weight: normal;
41 | font-style: normal;
42 | }
43 |
44 | /* punctuation */
45 | .pun {
46 | color: #000000;
47 | font-weight: bold;
48 | font-style: normal;
49 | }
50 |
51 | /* lisp open bracket */
52 | .opn {
53 | color: #000000;
54 | font-weight: bold;
55 | font-style: normal;
56 | }
57 |
58 | /* lisp close bracket */
59 | .clo {
60 | color: #000000;
61 | font-weight: bold;
62 | font-style: normal;
63 | }
64 |
65 | /* a markup tag name */
66 | .tag {
67 | color: #006400;
68 | font-weight: normal;
69 | font-style: normal;
70 | }
71 |
72 | /* a markup attribute name */
73 | .atn {
74 | color: #006400;
75 | font-weight: normal;
76 | font-style: normal;
77 | }
78 |
79 | /* a markup attribute value */
80 | .atv {
81 | color: #006400;
82 | font-weight: normal;
83 | font-style: normal;
84 | }
85 |
86 | /* a declaration */
87 | .dec {
88 | color: #000000;
89 | font-weight: bold;
90 | font-style: normal;
91 | }
92 |
93 | /* a variable name */
94 | .var {
95 | color: #000000;
96 | font-weight: normal;
97 | font-style: normal;
98 | }
99 |
100 | /* a function name */
101 | .fun {
102 | color: #000000;
103 | font-weight: bold;
104 | font-style: normal;
105 | }
106 |
107 | /* Specify class=linenums on a pre to get line numbering */
108 | ol.linenums {
109 | margin-top: 0;
110 | margin-bottom: 0;
111 | }
112 |
--------------------------------------------------------------------------------
/public/docs/styles/prettify-tomorrow.css:
--------------------------------------------------------------------------------
1 | /* Tomorrow Theme */
2 | /* Original theme - https://github.com/chriskempson/tomorrow-theme */
3 | /* Pretty printing styles. Used with prettify.js. */
4 | /* SPAN elements with the classes below are added by prettyprint. */
5 | /* plain text */
6 | .pln {
7 | color: #4d4d4c;
8 | }
9 |
10 | @media screen {
11 | /* string content */
12 | .str {
13 | color: #718c00;
14 | }
15 |
16 | /* a keyword */
17 | .kwd {
18 | color: #8959a8;
19 | }
20 |
21 | /* a comment */
22 | .com {
23 | color: #8e908c;
24 | }
25 |
26 | /* a type name */
27 | .typ {
28 | color: #4271ae;
29 | }
30 |
31 | /* a literal value */
32 | .lit {
33 | color: #f5871f;
34 | }
35 |
36 | /* punctuation */
37 | .pun {
38 | color: #4d4d4c;
39 | }
40 |
41 | /* lisp open bracket */
42 | .opn {
43 | color: #4d4d4c;
44 | }
45 |
46 | /* lisp close bracket */
47 | .clo {
48 | color: #4d4d4c;
49 | }
50 |
51 | /* a markup tag name */
52 | .tag {
53 | color: #c82829;
54 | }
55 |
56 | /* a markup attribute name */
57 | .atn {
58 | color: #f5871f;
59 | }
60 |
61 | /* a markup attribute value */
62 | .atv {
63 | color: #3e999f;
64 | }
65 |
66 | /* a declaration */
67 | .dec {
68 | color: #f5871f;
69 | }
70 |
71 | /* a variable name */
72 | .var {
73 | color: #c82829;
74 | }
75 |
76 | /* a function name */
77 | .fun {
78 | color: #4271ae;
79 | }
80 | }
81 | /* Use higher contrast and text-weight for printable form. */
82 | @media print, projection {
83 | .str {
84 | color: #060;
85 | }
86 |
87 | .kwd {
88 | color: #006;
89 | font-weight: bold;
90 | }
91 |
92 | .com {
93 | color: #600;
94 | font-style: italic;
95 | }
96 |
97 | .typ {
98 | color: #404;
99 | font-weight: bold;
100 | }
101 |
102 | .lit {
103 | color: #044;
104 | }
105 |
106 | .pun,
107 | .opn,
108 | .clo {
109 | color: #440;
110 | }
111 |
112 | .tag {
113 | color: #006;
114 | font-weight: bold;
115 | }
116 |
117 | .atn {
118 | color: #404;
119 | }
120 |
121 | .atv {
122 | color: #060;
123 | }
124 | }
125 | /* Style */
126 | /*
127 | pre.prettyprint {
128 | background: white;
129 | font-family: Consolas, Monaco, 'Andale Mono', monospace;
130 | font-size: 12px;
131 | line-height: 1.5;
132 | border: 1px solid #ccc;
133 | padding: 10px; }
134 | */
135 |
136 | /* Specify class=linenums on a pre to get line numbering */
137 | ol.linenums {
138 | margin-top: 0;
139 | margin-bottom: 0;
140 | }
141 |
142 | /* IE indents via margin-left */
143 | li.L0,
144 | li.L1,
145 | li.L2,
146 | li.L3,
147 | li.L4,
148 | li.L5,
149 | li.L6,
150 | li.L7,
151 | li.L8,
152 | li.L9 {
153 | /* */
154 | }
155 |
156 | /* Alternate shading for lines */
157 | li.L1,
158 | li.L3,
159 | li.L5,
160 | li.L7,
161 | li.L9 {
162 | /* */
163 | }
164 |
--------------------------------------------------------------------------------
/public/docs/styles/reset.css:
--------------------------------------------------------------------------------
1 | /* reset css */
2 | html,
3 | body,
4 | div,
5 | span,
6 | applet,
7 | object,
8 | iframe,
9 | h1,
10 | h2,
11 | h3,
12 | h4,
13 | h5,
14 | h6,
15 | p,
16 | blockquote,
17 | pre,
18 | a,
19 | abbr,
20 | acronym,
21 | address,
22 | big,
23 | cite,
24 | code,
25 | del,
26 | dfn,
27 | em,
28 | img,
29 | ins,
30 | kbd,
31 | q,
32 | s,
33 | samp,
34 | small,
35 | strike,
36 | strong,
37 | sub,
38 | sup,
39 | tt,
40 | var,
41 | b,
42 | u,
43 | i,
44 | center,
45 | dl,
46 | dt,
47 | dd,
48 | ol,
49 | ul,
50 | li,
51 | fieldset,
52 | form,
53 | label,
54 | legend,
55 | table,
56 | caption,
57 | tbody,
58 | tfoot,
59 | thead,
60 | tr,
61 | th,
62 | td,
63 | article,
64 | aside,
65 | canvas,
66 | details,
67 | embed,
68 | figure,
69 | figcaption,
70 | footer,
71 | header,
72 | hgroup,
73 | menu,
74 | nav,
75 | output,
76 | ruby,
77 | section,
78 | summary,
79 | time,
80 | mark,
81 | audio,
82 | video {
83 | margin: 0;
84 | padding: 0;
85 | border: 0;
86 | font-size: 100%;
87 | font: inherit;
88 | vertical-align: baseline;
89 | }
90 | /* HTML5 display-role reset for older browsers */
91 | article,
92 | aside,
93 | details,
94 | figcaption,
95 | figure,
96 | footer,
97 | header,
98 | hgroup,
99 | menu,
100 | nav,
101 | section {
102 | display: block;
103 | }
104 | body {
105 | line-height: 1;
106 | }
107 | ol,
108 | ul {
109 | list-style: none;
110 | }
111 | blockquote,
112 | q {
113 | quotes: none;
114 | }
115 | blockquote:before,
116 | blockquote:after,
117 | q:before,
118 | q:after {
119 | content: '';
120 | content: none;
121 | }
122 | table {
123 | border-collapse: collapse;
124 | border-spacing: 0;
125 | }
126 |
--------------------------------------------------------------------------------
/public/favicon-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/favicon-64.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/2024-12-22-weekly-plugin-updates-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2024-12-22-weekly-plugin-updates-1.webp
--------------------------------------------------------------------------------
/public/images/2025-01-05-weekly-plugin-updates-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-01-05-weekly-plugin-updates-1.webp
--------------------------------------------------------------------------------
/public/images/2025-01-12-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-01-12-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-01-19-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-01-19-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-01-25-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-01-25-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-02-01-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-02-01-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-02-24-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-02-24-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-03-10-weekly-plugin-updates-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-03-10-weekly-plugin-updates-1.webp
--------------------------------------------------------------------------------
/public/images/2025-03-16-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-03-16-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-03-18-weekly-plugin-updates-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-03-18-weekly-plugin-updates-1.webp
--------------------------------------------------------------------------------
/public/images/2025-03-30-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-03-30-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-04-08-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-04-08-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-04-13-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-04-13-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-04-16-publish-workflow-og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-04-16-publish-workflow-og.png
--------------------------------------------------------------------------------
/public/images/2025-04-16-publish-workflow.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-04-16-publish-workflow.webp
--------------------------------------------------------------------------------
/public/images/2025-04-20-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-04-20-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-04-22-recipes-workflow-og.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-04-22-recipes-workflow-og.webp
--------------------------------------------------------------------------------
/public/images/2025-04-22-recipes-workflow.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-04-22-recipes-workflow.webp
--------------------------------------------------------------------------------
/public/images/2025-04-27-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-04-27-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-05-01-spaced-repetition-workflow-og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-05-01-spaced-repetition-workflow-og.png
--------------------------------------------------------------------------------
/public/images/2025-05-01-spaced-repetition-workflow.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-05-01-spaced-repetition-workflow.webp
--------------------------------------------------------------------------------
/public/images/2025-05-04-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-05-04-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-05-11-weekly-plugin-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-05-11-weekly-plugin-updates.webp
--------------------------------------------------------------------------------
/public/images/2025-05-14-sheet-music-workflow-og.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-05-14-sheet-music-workflow-og.webp
--------------------------------------------------------------------------------
/public/images/2025-05-14-sheet-music-workflow.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-05-14-sheet-music-workflow.webp
--------------------------------------------------------------------------------
/public/images/2025-05-30-zotero-plugins.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-05-30-zotero-plugins.webp
--------------------------------------------------------------------------------
/public/images/2025-07-10-charts-plugins.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-07-10-charts-plugins.webp
--------------------------------------------------------------------------------
/public/images/2025-07-18-pomodoro-plugins.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/2025-07-18-pomodoro-plugins.webp
--------------------------------------------------------------------------------
/public/images/calendar-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/calendar-128.png
--------------------------------------------------------------------------------
/public/images/calendar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/calendar.png
--------------------------------------------------------------------------------
/public/images/chibi-obsidian-wizard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/chibi-obsidian-wizard.png
--------------------------------------------------------------------------------
/public/images/custom-callouts-in-obsidian-with-css-snippets.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/custom-callouts-in-obsidian-with-css-snippets.webp
--------------------------------------------------------------------------------
/public/images/custom-callouts-in-obsidian.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/custom-callouts-in-obsidian.png
--------------------------------------------------------------------------------
/public/images/custom-scorer-example-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/custom-scorer-example-1.png
--------------------------------------------------------------------------------
/public/images/daily-journaling.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/daily-journaling.webp
--------------------------------------------------------------------------------
/public/images/default-callout-in-obsidian.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/default-callout-in-obsidian.png
--------------------------------------------------------------------------------
/public/images/empty-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/favicon.png
--------------------------------------------------------------------------------
/public/images/feature-score-intro.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/feature-score-intro.gif
--------------------------------------------------------------------------------
/public/images/gk-coding-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/gk-coding-1.webp
--------------------------------------------------------------------------------
/public/images/gk-coding.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/gk-coding.webp
--------------------------------------------------------------------------------
/public/images/how-to-favorite-step-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/how-to-favorite-step-1.png
--------------------------------------------------------------------------------
/public/images/how-to-favorite-step-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/how-to-favorite-step-2.png
--------------------------------------------------------------------------------
/public/images/how-to-favorite-step-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/how-to-favorite-step-3.png
--------------------------------------------------------------------------------
/public/images/introducing-categories-and-tags.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/introducing-categories-and-tags.png
--------------------------------------------------------------------------------
/public/images/new-og.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/new-og.webp
--------------------------------------------------------------------------------
/public/images/obsidian-stats-ogImage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/obsidian-stats-ogImage.png
--------------------------------------------------------------------------------
/public/images/obsidian-weekly-updates.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/obsidian-weekly-updates.webp
--------------------------------------------------------------------------------
/public/images/plugin-score.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/plugin-score.png
--------------------------------------------------------------------------------
/public/images/plugin-updates-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/plugin-updates-banner.png
--------------------------------------------------------------------------------
/public/images/sample-scores.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/sample-scores.gif
--------------------------------------------------------------------------------
/public/images/score-example-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/score-example-1.png
--------------------------------------------------------------------------------
/public/images/score-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/score-example.png
--------------------------------------------------------------------------------
/public/images/scorer/all-plugins-with-score-original.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/all-plugins-with-score-original.png
--------------------------------------------------------------------------------
/public/images/scorer/all-plugins-with-score.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/all-plugins-with-score.png
--------------------------------------------------------------------------------
/public/images/scorer/empty-builder-original.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/empty-builder-original.png
--------------------------------------------------------------------------------
/public/images/scorer/empty-builder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/empty-builder.png
--------------------------------------------------------------------------------
/public/images/scorer/normalized-download-count-scorer-original.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/normalized-download-count-scorer-original.png
--------------------------------------------------------------------------------
/public/images/scorer/normalized-download-count-scorer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/normalized-download-count-scorer.png
--------------------------------------------------------------------------------
/public/images/scorer/save-and-use-original.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/save-and-use-original.png
--------------------------------------------------------------------------------
/public/images/scorer/save-and-use.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/save-and-use.png
--------------------------------------------------------------------------------
/public/images/scorer/scorer-list-original.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/scorer-list-original.png
--------------------------------------------------------------------------------
/public/images/scorer/scorer-list-with-scorers-original.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/scorer-list-with-scorers-original.png
--------------------------------------------------------------------------------
/public/images/scorer/scorer-list-with-scorers.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/scorer-list-with-scorers.png
--------------------------------------------------------------------------------
/public/images/scorer/scorer-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scorer/scorer-list.png
--------------------------------------------------------------------------------
/public/images/scoring-plugins-banner.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/scoring-plugins-banner.webp
--------------------------------------------------------------------------------
/public/images/trending-plugins.bkp-2.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/trending-plugins.bkp-2.webp
--------------------------------------------------------------------------------
/public/images/trending-plugins.bkp.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/trending-plugins.bkp.webp
--------------------------------------------------------------------------------
/public/images/trending-plugins.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/trending-plugins.webp
--------------------------------------------------------------------------------
/public/images/undraw/moving_2cfm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/images/undraw/moving_2cfm.png
--------------------------------------------------------------------------------
/public/logo-512-removebg-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/logo-512-removebg-preview.png
--------------------------------------------------------------------------------
/public/logo-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/logo-512.png
--------------------------------------------------------------------------------
/public/logo-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/logo-64.png
--------------------------------------------------------------------------------
/public/logo-apple-touch-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/public/logo-apple-touch-icon-180x180.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # *
2 | User-agent: *
3 | Allow: /
4 |
5 | # Host
6 | Host: https://www.obsidianstats.com/
7 |
8 | # Sitemaps
9 | Sitemap: https://www.obsidianstats.com/sitemap.xml
10 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
--------------------------------------------------------------------------------
/public/yandex_27ef3a93625b3d8e.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Verification: 27ef3a93625b3d8e
7 |
8 |
9 |
--------------------------------------------------------------------------------
/public/yandex_57114eb967a05d89.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Verification: 57114eb967a05d89
7 |
8 |
9 |
--------------------------------------------------------------------------------
/styles/Home.module.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/styles/Home.module.css
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const flowbite = require('flowbite-react/tailwind');
2 |
3 | module.exports = {
4 | darkMode: ['class'],
5 | content: [
6 | './pages/**/*.{js,ts,jsx,tsx}',
7 | './components/**/*.{js,ts,jsx,tsx}',
8 | './data/**/*.{js,ts,jsx,tsx}',
9 | './utils/**/*.{js,ts,jsx,tsx}',
10 | './lib/**/*.{js,ts,jsx,tsx}',
11 | flowbite.content(),
12 | ],
13 | theme: {
14 | extend: {
15 | fontFamily: {
16 | body: ['Noto sans"', 'system-ui', 'sans-serif'],
17 | heading: ['Lato', 'system-ui', 'sans-serif'],
18 | pre: ['monospace'],
19 | },
20 | borderRadius: {
21 | lg: 'var(--radius)',
22 | md: 'calc(var(--radius) - 2px)',
23 | sm: 'calc(var(--radius) - 4px)',
24 | },
25 | typography: {
26 | DEFAULT: {
27 | css: {
28 | 'h3 a': { // for weekly update post ### h3
29 | color: 'rgb(200 30 30 / var(--tw-text-opacity))', // text-red-700
30 | textDecoration: 'underline',
31 | },
32 | },
33 | },
34 | },
35 | },
36 | },
37 | plugins: [
38 | require('@tailwindcss/typography'),
39 | flowbite.plugin(),
40 | require('tailwindcss-animate'),
41 | ],
42 | };
43 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": false,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "allowSyntheticDefaultImports": true,
18 | "paths": {
19 | "@/*": ["./*"]
20 | }
21 | },
22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
23 | "exclude": ["node_modules"]
24 | }
25 |
--------------------------------------------------------------------------------
/utils/datetime.test.ts:
--------------------------------------------------------------------------------
1 | import { ONE_DAY, isNotXDaysOld, daysAgo } from './datetime';
2 |
3 | describe('datetime utils', () => {
4 | describe('ONE_DAY', () => {
5 | it('should be equal to 24 hours in milliseconds', () => {
6 | expect(ONE_DAY).toBe(24 * 60 * 60 * 1000);
7 | });
8 | });
9 |
10 | describe('isNotXDaysOld', () => {
11 | it('should return true if the datetime is within the specified number of days', () => {
12 | const now = Date.now();
13 | expect(isNotXDaysOld(now - ONE_DAY, 2)).toBe(true);
14 | expect(isNotXDaysOld(now - 2 * ONE_DAY, 2)).toBe(false);
15 | expect(isNotXDaysOld(now - 3 * ONE_DAY, 2)).toBe(false);
16 | });
17 |
18 | it('should return false if the datetime is older than the specified number of days', () => {
19 | const now = Date.now();
20 | expect(isNotXDaysOld(now - 3 * ONE_DAY, 2)).toBe(false);
21 | });
22 | });
23 |
24 | describe('daysAgo', () => {
25 | it('should return the timestamp for the specified number of days ago', () => {
26 | const now = Date.now();
27 | expect(daysAgo(1)).toBeCloseTo(now - ONE_DAY, -2);
28 | expect(daysAgo(2)).toBeCloseTo(now - 2 * ONE_DAY, -2);
29 | });
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/utils/datetime.ts:
--------------------------------------------------------------------------------
1 | export const ONE_DAY = 24 * 60 * 60 * 1000;
2 | export const isNotXDaysOld = (datetime: number, days: number) =>
3 | datetime > Date.now() - days * ONE_DAY;
4 | export const daysAgo = (days: number) =>
5 | Date.now() - days * 24 * 60 * 60 * 1000;
6 |
--------------------------------------------------------------------------------
/utils/favorites.test.ts:
--------------------------------------------------------------------------------
1 | import { setupFavorites, setFavorite, unsetFavorite } from './favorites';
2 |
3 | describe('favorites utils', () => {
4 | beforeEach(() => {
5 | localStorage.clear();
6 | jest.clearAllMocks();
7 | });
8 |
9 | describe('setupFavorites', () => {
10 | it('should set favorites from localStorage', () => {
11 | const setFavorites = jest.fn();
12 | localStorage.setItem('favorites', JSON.stringify(['plugin1', 'plugin2']));
13 | setupFavorites(setFavorites);
14 | expect(setFavorites).toHaveBeenCalledWith(['plugin1', 'plugin2']);
15 | });
16 |
17 | it('should set an empty array if no favorites in localStorage', () => {
18 | const setFavorites = jest.fn();
19 | setupFavorites(setFavorites);
20 | expect(setFavorites).toHaveBeenCalledWith([]);
21 | });
22 | });
23 |
24 | describe('setFavorite', () => {
25 | it('should add a plugin to favorites and update localStorage', () => {
26 | const setFavorites = jest.fn();
27 | setFavorite('plugin1', setFavorites);
28 | expect(localStorage.getItem('favorites')).toBe(
29 | JSON.stringify(['plugin1'])
30 | );
31 | expect(setFavorites).toHaveBeenCalledWith(['plugin1']);
32 | });
33 |
34 | it('should add a plugin to existing favorites and update localStorage', () => {
35 | const setFavorites = jest.fn();
36 | localStorage.setItem('favorites', JSON.stringify(['plugin1']));
37 | setFavorite('plugin2', setFavorites);
38 | expect(localStorage.getItem('favorites')).toBe(
39 | JSON.stringify(['plugin1', 'plugin2'])
40 | );
41 | expect(setFavorites).toHaveBeenCalledWith(['plugin1', 'plugin2']);
42 | });
43 | });
44 |
45 | describe('unsetFavorite', () => {
46 | it('should remove a plugin from favorites and update localStorage', () => {
47 | const setFavorites = jest.fn();
48 | localStorage.setItem('favorites', JSON.stringify(['plugin1', 'plugin2']));
49 | unsetFavorite('plugin1', setFavorites);
50 | expect(localStorage.getItem('favorites')).toBe(
51 | JSON.stringify(['plugin2'])
52 | );
53 | expect(setFavorites).toHaveBeenCalledWith(['plugin2']);
54 | });
55 |
56 | it('should do nothing if the plugin is not in favorites', () => {
57 | const setFavorites = jest.fn();
58 | localStorage.setItem('favorites', JSON.stringify(['plugin1']));
59 | unsetFavorite('plugin2', setFavorites);
60 | expect(localStorage.getItem('favorites')).toBe(
61 | JSON.stringify(['plugin1'])
62 | );
63 | expect(setFavorites).not.toHaveBeenCalled();
64 | });
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/utils/favorites.ts:
--------------------------------------------------------------------------------
1 | export const setupFavorites = (setFavorites) => {
2 | if (typeof window !== 'undefined' && window.localStorage) {
3 | const favorites = JSON.parse(localStorage.getItem('favorites')) || [];
4 | setFavorites(favorites);
5 | }
6 | };
7 |
8 | export const setFavorite = (pluginId, setFavorites) => {
9 | const favorites = JSON.parse(localStorage.getItem('favorites')) || [];
10 | favorites.push(pluginId);
11 | localStorage.setItem('favorites', JSON.stringify(favorites));
12 | setFavorites(favorites);
13 | };
14 |
15 | export const unsetFavorite = (pluginId, setFavorites) => {
16 | const favorites = JSON.parse(localStorage.getItem('favorites')) || [];
17 | if (favorites.includes(pluginId)) {
18 | favorites.splice(favorites.indexOf(pluginId), 1);
19 | localStorage.setItem('favorites', JSON.stringify(favorites));
20 | setFavorites(favorites);
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/utils/indexnow.mjs:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import fetch from 'node-fetch';
3 | import xml2js from 'xml2js';
4 | import path from 'path';
5 |
6 | const filePath = path.join(process.cwd(), 'data', 'indexnow.json');
7 |
8 | function readLocalFile(filePath) {
9 | if (!fs.existsSync(filePath)) {
10 | const initialData = {
11 | lastSubmission: new Date().toISOString(),
12 | };
13 | fs.writeFileSync(filePath, JSON.stringify(initialData, null, 2));
14 | }
15 | return JSON.parse(fs.readFileSync(filePath, 'utf8'));
16 | }
17 |
18 | function fetchSitemap(url) {
19 | return fetch(url).then((response) => response.text());
20 | }
21 |
22 | function parseSitemap(data) {
23 | return new Promise((resolve, reject) => {
24 | xml2js.parseString(data, (err, result) => {
25 | if (err) {
26 | reject('Error parsing XML: ' + err);
27 | } else {
28 | const urls = result.urlset.url.map((entry) => ({
29 | loc: entry.loc[0],
30 | lastmod: entry.lastmod[0],
31 | }));
32 | resolve(urls);
33 | }
34 | });
35 | });
36 | }
37 |
38 | function filterSitemap(urls, lastSubmission) {
39 | return urls.filter((url) => new Date(url.lastmod) > new Date(lastSubmission));
40 | }
41 |
42 | function submitToIndexNow(filteredUrls) {
43 | const indexNowUrl = 'https://api.indexnow.org/indexnow';
44 | const submissionData = {
45 | host: 'www.obsidianstats.com',
46 | key: 'bcee69848e584efbac6b1dcbadaa8c64',
47 | keyLocation:
48 | 'https://www.obsidianstats.com/bcee69848e584efbac6b1dcbadaa8c64.txt',
49 | urlList: filteredUrls.map((url) => url.loc),
50 | };
51 | //console.log(filteredUrls.map(url => url.loc));
52 | return fetch(indexNowUrl, {
53 | method: 'POST',
54 | headers: {
55 | 'Content-Type': 'application/json',
56 | },
57 | body: JSON.stringify(submissionData),
58 | });
59 | }
60 |
61 | async function main() {
62 | try {
63 | const fileData = readLocalFile(filePath);
64 | const sitemapData = await fetchSitemap(
65 | 'https://www.obsidianstats.com/sitemap-0.xml'
66 | );
67 | const urls = await parseSitemap(sitemapData);
68 | const filteredUrls = filterSitemap(urls, fileData.lastSubmission);
69 | await submitToIndexNow(filteredUrls);
70 | fileData.lastSubmission = new Date().toISOString();
71 | fs.writeFileSync(filePath, JSON.stringify(fileData, null, 2));
72 | console.log(`${filteredUrls.length} links submitted successfully.`);
73 | } catch (error) {
74 | console.error(error);
75 | }
76 | }
77 |
78 | main();
79 |
--------------------------------------------------------------------------------
/utils/plugins.test.ts:
--------------------------------------------------------------------------------
1 | import { sanitizeTag, getDescription, tagDenyList } from './plugins';
2 |
3 | describe('plugins utils', () => {
4 | describe('sanitizeTag', () => {
5 | it('should sanitize tags by converting to lowercase and removing spaces', () => {
6 | expect(sanitizeTag(' Obsidian Plugin ')).toBe('obsidianplugin');
7 | expect(sanitizeTag(' Obsidian-Plugin ')).toBe('obsidian-plugin');
8 | });
9 | });
10 |
11 | describe('getDescription', () => {
12 | it('should return osDescription if it is valid', () => {
13 | const plugin = {
14 | osDescription: 'This is a valid AI description.',
15 | description: 'This is a fallback description.',
16 | };
17 | expect(getDescription(plugin)).toBe('This is a valid AI description.');
18 | });
19 |
20 | it('should return description if osDescription is invalid', () => {
21 | const plugin = {
22 | osDescription: 'I apologize, but I cannot provide a summary.',
23 | description: 'This is a fallback description.',
24 | };
25 | expect(getDescription(plugin)).toBe('This is a fallback description.');
26 | });
27 |
28 | it('should return an empty string if plugin is undefined', () => {
29 | expect(getDescription(undefined)).toBe('');
30 | });
31 |
32 | it('should return an empty string if plugin has no description', () => {
33 | const plugin = {};
34 | expect(getDescription(plugin)).toBe('');
35 | });
36 |
37 | it('should remove markdown syntax from osDescription', () => {
38 | const plugin = {
39 | osDescription: '**This is a valid AI description.**',
40 | description: 'This is a fallback description.',
41 | };
42 | expect(getDescription(plugin)).toBe('This is a valid AI description.');
43 | });
44 | });
45 |
46 | describe('tagDenyList', () => {
47 | it('should contain specific tags', () => {
48 | expect(tagDenyList).toContain('obsidian');
49 | expect(tagDenyList).toContain('plugin');
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------