Merge branch 'main' of github.com:lissy93/awesome-privacy into add-esim-section

This commit is contained in:
Alicia Sykes 2026-03-07 17:04:07 +00:00
commit aed858f425
117 changed files with 12860 additions and 8434 deletions

View file

@ -19,7 +19,7 @@
You can add, edit or remove entries by opening a pull request.
All data is stored in [`awesome-privacy.yml`](https://github.com/Lissy93/awesome-privacy/blob/main/awesome-privacy.yml).
If you're adding, editing or removing a listing - **this is the only file you need to edit**.
If you're adding, editing or removing a listing - **this is the only file you need to edit**. Don't edit the README directly, as this is auto-generated from the YAML file.
### Process
@ -62,6 +62,10 @@ For software to be included in this list, it must meet the following requirement
- A stable (non-alpha/beta) release is required at a minimum
- Must be accessible to the general public, and not just a select group of people
- If technical knowledge is required to run it, the software must be well documented
- **Mature**
- Software needs to have a proven track record of commitment to maintenance
- Repositories must not be newly created, and the first stable release older than 4 months
- Projects primarily written with AI or vibe coded are not suitable for listing here
_There may be some exceptions, but these would need to be fully justified, reviewed
by the community, and the drawbacks / anti-features must be clearly listed along-side the software.
@ -74,25 +78,23 @@ Usually these entries go within the "Notable Mentions" section instead._
Your pull request must follow these requirements. Failure to do so, might result in it being closed.
- Do not edit the README directly when adding / editing a listing
- Do not edit the README directly when adding / editing a listing (it's auto-generated!)
- Ensure your PR is not a duplicate, search for existing / previous submissions first
- You must respond to any comments or requests for changes in a timely manner, 48-hours maximum
- You must respond to any comments or requests for changes in a timely manner, 14 days maximum
- Write short but descriptive git commit messages, under 50 characters. This must be in the format of `Adds [software-name] to [section-name]`. Your PR will be rejected if you name it `Updates README.md`
- Only include a single addition / amendment / removal, per pull request
- If your pull request contains multiple commits, you must squash them first
- You must complete each of the sections in the pull request template. Do not delete it!
- You must complete each of the sections in the [pull request template](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md). Do not delete it!
- Where applicable, include links to supporting material for your addition: git repo, docs, recent security audits, etc. This will make researching it much easier for reviewers
- While adding new software to the list, don't make your entry read like an advert. Be objective, and include drawbacks as well as strengths
- Your entry should be added at the bottom of the appropriate category, unless otherwise requested
- If there are other pull requests open, please help review them before submitting yours
- A pull request must receive multiple approval reviews before it can be merged
- You must be transparent about your affiliation with a product or service that you are adding. It's totally okay to submit your own projects as additions (providing they meet the requirements), but if you don't declare your association with that project then there becomes a clear conflict of interest
- You must adhere to the Contributor Covenant Code of Conduct
- You must adhere to the [Contributor Covenant Code of Conduct](https://github.com/Lissy93/awesome-privacy?tab=coc-ov-file#contributor-covenant-code-of-conduct)
- Don't open a Draft / WIP pull request while you work on the guidelines. A pull request should be 100% ready and should adhere to all the above guidelines when you open it
- Your changes must be correctly spelled, and with good grammar
- Your changes must be correctly formatted, in valid markdown
- The addition title must be a link the project, and in bold
- Your changes must be correctly formatted, in valid yaml and markdown
- The addition description must be no less than 50, and no more than 250 characters, keep it clear and to the point
- If there are other pull requests open, please help review them before submitting yours
- A pull request must receive multiple approval reviews before it can be merged
---
@ -103,8 +105,6 @@ This file may look a bit daunting to start with, but don't worry - it's pretty s
### Top-Level Structure
```mermaid
---
title: Class Diagram

3024
.github/README.md vendored

File diff suppressed because it is too large Load diff

View file

@ -1,42 +0,0 @@
# Checks domain and SSL status, then raises an issue if either is expiring soon
name: 🌎 Check Domain Expiry
on:
workflow_dispatch:
schedule:
- cron: '0 5 * * 6' # Every Saturday morning.
jobs:
check-domain:
runs-on: ubuntu-latest
name: Check domain
strategy:
matrix:
domain:
- https://awesome-privacy.xyz
steps:
- name: Check domain SSL and registry expire date
id: check-domain
uses: codex-team/action-check-domain@v1
with:
url: ${{ matrix.domain }}
- name: Raise issue if domain expiring soon
if: ${{ steps.check-domain.outputs.paid-till-days-left && steps.check-domain.outputs.paid-till-days-left < 30 }}
uses: rishabhgupta/git-action-issue@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
assignees: Lissy93
title: '[WEBSITE] Domain Expiring Soon'
body: >
**Priority Notice**
Domain, ${{ matrix.domain }} will expire in ${{ steps.check-domain.outputs.paid-till-days-left }} days.
@Lissy93 - Please take action immediately to prevent any downtime
- name: Raise issue if SSL Cert expiring soon
if: ${{ steps.check-domain.outputs.ssl-expire-days-left && steps.check-domain.outputs.ssl-expire-days-left < 14 }}
uses: rishabhgupta/git-action-issue@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
assignees: Lissy93
title: '[WEBSITE] SSL Cert Expiring Soon'
body: >
**Priority Notice**
The SSL Certificate for ${{ matrix.domain }} will expire in ${{ steps.check-domain.outputs.ssl-expire-days-left }} days, on ${{ steps.check-domain.outputs.ssl-expire-date }}.
@Lissy93 - Please take action immediately to prevent any downtime

View file

@ -1,44 +0,0 @@
# Generates and saved a PDF document from the main markdown file
# Easier to read on certain devices, or for users with accesibility needs
name: 📁 Compile PDF Document
on:
workflow_dispatch: # Manual dispatch
schedule:
- cron: '0 5 * * 6' # Every Saturday morning.
jobs:
# Job #1 - Generate an embedded SVG asset, showing all contributors
compile-pdf:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Make PDF 📄
uses: baileyjm02/markdown-to-pdf@v1.1.0
with:
input_dir: .
output_dir: .github/assets/
build_pdf: true
build_html: false
table_of_contents: false
- name: Upload Artifact 📤
uses: actions/upload-artifact@v3
with:
name: awesome-privacy-pdf
path: .github/assets/README.pdf
- name: Commit file ✅
run: |
git config --local user.email "alicia-gh-bot@mail.as93.net"
git config --local user.name "liss-bot"
git add .github/assets/*.pdf
if ! git diff-index --quiet HEAD; then
git commit -m "Generate PDF file"
else
echo "Nothing to do"
fi
- name: Push changes ➡️
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}

View file

@ -1,34 +0,0 @@
# Inserts list of contributors and community members into ./docs/credits.md
name: 📊 Generate Contributor Credits
on:
workflow_dispatch: # Manual dispatch
schedule:
- cron: '0 5 * * 6' # Every Saturday morning.
jobs:
# Job #1 - Inserts sponsors into README
insert-sponsors:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Generate Sponsors in Credits 💖
uses: JamesIves/github-sponsors-readme-action@1.0.5
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
file: '.github/README.md'
# Job #2 - Inserts contributors into README
insert-credits:
runs-on: ubuntu-latest
name: Inserts contributors into credits.md
steps:
- name: Contribute List - Credits Page
uses: akhilmhdh/contributors-readme-action@v2.2
env:
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
with:
image_size: 80
readme_path: .github/README.md
columns_per_row: 6
commit_message: 'Updates contributors list'
committer_username: liss-bot
committer_email: liss-bot@d0h.co

16
.github/workflows/mirror.yml vendored Normal file
View file

@ -0,0 +1,16 @@
# Syncs repo to the Codeberg mirror
name: 🪞 Mirror
on:
schedule: [{ cron: '0 5 * * 0' }]
push: { tags: ['v*'] }
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: lissy93/repo-mirror-action@main
with:
ssh_key: ${{ secrets.CODEBERG_SSH }}
host: git@codeberg.org
user: alicia
repo: awesome-privacy

166
.github/workflows/pr-check.yml vendored Normal file
View file

@ -0,0 +1,166 @@
name: PR Check
on:
pull_request:
branches: [main]
types: [opened, edited, synchronize, reopened]
paths:
- 'awesome-privacy.yml'
- '.github/README.md'
permissions:
contents: read
pull-requests: read
jobs:
pr-compliance:
name: PR Compliance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check README edits
id: readme
continue-on-error: true
run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Check PR metadata
id: meta
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_DRAFT: ${{ github.event.pull_request.draft }}
BASE_REF: ${{ github.event.pull_request.base.sha }}
README_FAILED: ${{ steps.readme.outcome == 'failure' && 'true' || 'false' }}
run: python lib/checks/check-pr-meta.py
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
with:
name: findings-compliance
path: /tmp/findings-compliance.json
if-no-files-found: ignore
- name: Fail if critical
if: steps.readme.outcome == 'failure' || steps.meta.outcome == 'failure'
run: exit 1
data-validation:
name: Data Validation
runs-on: ubuntu-latest
outputs:
yaml_changed: ${{ steps.changes.outputs.yaml_changed }}
steps:
- uses: actions/checkout@v4
- run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Detect changes
id: changes
run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Install dependencies
if: steps.changes.outputs.yaml_changed == 'true'
run: pip install -q -r lib/requirements.txt
- name: Schema validation
if: steps.changes.outputs.yaml_changed == 'true'
id: schema
continue-on-error: true
run: make validate
- name: YAML diff
if: steps.changes.outputs.yaml_changed == 'true'
id: diff
continue-on-error: true
run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Check additions
if: steps.changes.outputs.yaml_changed == 'true'
env:
SCHEMA_OUTCOME: ${{ steps.schema.outcome }}
run: python lib/checks/check-additions.py
- name: Upload diff data
if: always()
uses: actions/upload-artifact@v4
with:
name: pr-diff
path: |
/tmp/pr-diff.json
/tmp/pr-diff-summary.md
if-no-files-found: ignore
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
with:
name: findings-data
path: /tmp/findings-data.json
if-no-files-found: ignore
- name: Fail if critical
if: steps.changes.outputs.yaml_changed == 'true' && (steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure')
run: exit 1
submission-eligibility:
name: Submission Eligibility
needs: data-validation
if: "!cancelled() && needs.data-validation.outputs.yaml_changed == 'true'"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -q -r lib/requirements.txt
- name: Download diff data
uses: actions/download-artifact@v4
with:
name: pr-diff
path: /tmp
continue-on-error: true
- name: Check project health
env:
PR_USER: ${{ github.event.pull_request.user.login }}
PR_BODY: ${{ github.event.pull_request.body }}
GITHUB_TOKEN: ${{ github.token }}
run: python lib/checks/check-project.py
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
with:
name: findings-project
path: /tmp/findings-project.json
if-no-files-found: ignore
summary:
name: Summary
if: always()
needs: [pr-compliance, data-validation, submission-eligibility]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Download all findings
uses: actions/download-artifact@v4
with:
pattern: findings-*
path: /tmp/artifacts
merge-multiple: true
continue-on-error: true
- name: Download diff data
uses: actions/download-artifact@v4
with:
name: pr-diff
path: /tmp/artifacts
continue-on-error: true
- name: Format comment
env:
PR_USER: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_ID: ${{ github.run_id }}
run: python lib/checks/format-comment.py
- name: Upload PR metadata
if: always()
uses: actions/upload-artifact@v4
with:
name: pr-meta
path: /tmp/pr-meta/

221
.github/workflows/pr-comment.yml vendored Normal file
View file

@ -0,0 +1,221 @@
name: PR Comment
on:
workflow_run:
workflows: ["PR Check"]
types: [completed]
pull_request_review:
types: [submitted]
permissions:
actions: read
checks: read
contents: read
pull-requests: write
jobs:
comment:
name: Post PR comment
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Download PR metadata
id: download
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: pr-meta
path: pr-meta
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve PR context
id: context
uses: actions/github-script@v7
with:
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
// Determine the PR number
let prNumber;
const numberFile = 'pr-meta/number.txt';
if (fs.existsSync(numberFile)) {
prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim());
}
if (!prNumber) {
const prs = context.payload.workflow_run.pull_requests;
if (prs && prs.length > 0) {
prNumber = prs[0].number;
} else {
const headSha = context.payload.workflow_run.head_sha;
const { data: prList } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'desc',
per_page: 100,
});
const match = prList.find(pr => pr.head.sha === headSha);
if (!match) {
console.log(`No open PR found for SHA ${headSha} — skipping.`);
return;
}
prNumber = match.number;
}
}
// Fetch existing bot comment (if any) and write to file for Python
const marker = '<!-- pr-check-bot -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
fs.mkdirSync('pr-meta', { recursive: true });
fs.writeFileSync('pr-meta/existing-comment.md', existing.body);
fs.writeFileSync('pr-meta/existing-comment-id.txt', String(existing.id));
}
core.setOutput('pr_number', prNumber);
- name: Prepare comment
if: steps.context.outputs.pr_number
env:
CHECK_RUN_ID: ${{ github.event.workflow_run.id }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: python lib/checks/prepare-comment.py
- name: Post or update comment
if: steps.context.outputs.pr_number
uses: actions/github-script@v7
with:
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const actionFile = 'pr-meta/action.txt';
if (!fs.existsSync(actionFile)) return;
const action = fs.readFileSync(actionFile, 'utf8').trim();
if (action === 'skip') {
console.log('Nothing to do — skipping.');
return;
}
const bodyFile = 'pr-meta/final-comment.md';
if (!fs.existsSync(bodyFile)) {
console.log('No comment body found — skipping.');
return;
}
const body = fs.readFileSync(bodyFile, 'utf8').trim();
const prNumber = parseInt('${{ steps.context.outputs.pr_number }}');
if (action === 'create') {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
console.log('Created bot comment.');
} else if (action === 'update') {
const commentId = parseInt(fs.readFileSync('pr-meta/existing-comment-id.txt', 'utf8').trim());
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body,
});
console.log('Updated bot comment.');
}
review-notify:
name: Notify maintainer
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_review'
steps:
- uses: actions/checkout@v4
- name: Fetch review and CI context
id: context
uses: actions/github-script@v7
with:
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
// Fetch reviews — extract user login and state
const { data: reviews } = await github.rest.pulls.listReviews({
owner, repo, pull_number: pr.number, per_page: 100,
});
const reviewData = reviews.map(r => ({
user: r.user.login,
state: r.state,
}));
// Fetch check runs for the PR head SHA
const { data: { check_runs } } = await github.rest.checks.listForRef({
owner, repo, ref: pr.head.sha, per_page: 100,
});
const checkData = check_runs.map(cr => ({
status: cr.status,
conclusion: cr.conclusion,
}));
// Check if we already posted the notification
const marker = '<!-- pr-review-ready -->';
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: pr.number, per_page: 100,
});
const alreadyNotified = comments.some(c => c.body.includes(marker));
// Write data for Python
fs.mkdirSync('pr-meta', { recursive: true });
fs.writeFileSync('pr-meta/reviews.json', JSON.stringify(reviewData));
fs.writeFileSync('pr-meta/check-runs.json', JSON.stringify(checkData));
fs.writeFileSync('pr-meta/already-notified.txt', String(alreadyNotified));
core.setOutput('pr_number', pr.number);
- name: Check review readiness
run: python lib/checks/check-review-ready.py
- name: Post notification
uses: actions/github-script@v7
with:
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const actionFile = 'pr-meta/action.txt';
if (!fs.existsSync(actionFile)) return;
const action = fs.readFileSync(actionFile, 'utf8').trim();
if (action !== 'notify') {
console.log('Not ready for review — skipping.');
return;
}
const body = [
'<!-- pr-review-ready -->',
'This PR is now ready to be merged, pending maintainer review. All checks are passing and it has been peer-reviewed.',
'',
'@Lissy93 - Please evaluate, and either merge or leave feedback.',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt('${{ steps.context.outputs.pr_number }}'),
body,
});
console.log('Posted maintainer notification.');

View file

@ -1,25 +0,0 @@
# Applies labels based on the pull request category
name: 🏷️ PR Labeler
on:
pull_request:
types: [opened, edited]
jobs:
label-pr:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Apply Labels
if: "! contains(github.event.pull_request.body, 'Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc')"
uses: Naturalclar/issue-action@v2.0.2
with:
title-or-body: both
github-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
parameters: >
[
{"keywords": ["Addition"], "labels": ["Addition"] },
{"keywords": ["Amendment"], "labels": ["Amendment"] },
{"keywords": ["Removal"], "labels": ["Removal"] },
{"keywords": ["Spelling or Grammar"], "labels": ["Grammar"] },
{"keywords": ["Website Update"], "labels": ["Website"] },
{"keywords": ["Misc"], "labels": ["Misc"] }
]

View file

@ -1,20 +0,0 @@
# Spell check newly added content, when PR opened and, put typo list as comment
name: ✏️ Spell Check
on: [pull_request]
jobs:
misspell:
name: runner / misspell
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Run Spell Check 📝
uses: reviewdog/action-misspell@v1
with:
github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
locale: US
level: info
reporter: github-pr-review
path: .
filter_mode: added
fail_on_error: false

View file

@ -1,17 +0,0 @@
# Pushes the contents of the repo to the Codeberg mirror
name: 🪞 Mirror to Codeberg
on:
workflow_dispatch: # Manual dispatch
schedule:
- cron: '0 5 * * 6'
jobs:
codeberg:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url: "git@codeberg.org:alicia/awesome-privacy.git"
ssh_private_key: ${{ secrets.CODEBERG_SSH }}

View file

@ -1,40 +0,0 @@
# Checks newly opened issues contain enough info, and follow the required format
name: 🎫 Issue Validator
on:
issues:
types: [opened, edited]
jobs:
check-title:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Check Default Title
if: "endsWith(github.event.issue.title, '<title>')"
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Please ensure that your ticket has an appropriate title
- name: Check Title Contains Categroy
if: "!(startsWith(github.event.issue.title, '[') && contains(github.event.issue.title, ']'))"
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Please ensure that your ticket's title is preceded with a category.
For example, `[ADDITION]`, `[AMENDMENT]`, `[REMOVAL]` or `[QUESTION]`.
- name: Check Quality Checklist
if: "contains(github.event.issue.body, '[ ]') || !(contains(github.event.issue.body, '[X]') || contains(github.event.issue.body, '[x]'))"
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Please ensure that you've followed the issue template fully.
It's important that you complete the quality & transparency checklist.

View file

@ -1,90 +0,0 @@
# Checks that PR title conform to contributing standards (or at least !== Update README.md)
name: ⛳ Validate PR
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: write
env:
BASE_MSG: >+
Thanks for contributing to Awesome-Privacy! Your pull request will be reviewed shortly.
In the meantime, please be sure that you have read, and complied with the guidelines outlined in the
[Contributing Docs](https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md).
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Validate Title is not Default
if: "contains(github.event.pull_request.title, 'Update README.md')"
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hi @${{ github.actor }},
Please update your pull request, to include a more descriptive title.
- name: Validate Checklist is Completed
if: >
contains(github.event.pull_request.body, '[ ]') ||
!(contains(github.event.pull_request.body, '[X]') || contains(github.event.pull_request.body, '[x]'))
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hello @${{ github.actor }} 👋
${{ env.BASE_MSG }}
⚠️ It looks like you've not complete the quality and transparency checklist.
- name: Validate Affiliation Section is Present
if: >
!contains(github.event.pull_request.body, 'Affiliation')
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hello @${{ github.actor }} 👋
${{ env.BASE_MSG }}
⚠️ You must indicate if you are affiliated with any software modified by this PR.
If not applicable, you may set this field to N/A.
- name: Validate Category
if: >
contains(github.event.pull_request.body, 'Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc') ||
!(
contains(github.event.pull_request.body, 'Addition') ||
contains(github.event.pull_request.body, 'Amendment') ||
contains(github.event.pull_request.body, 'Removal') ||
contains(github.event.pull_request.body, 'Spelling or Grammar') ||
contains(github.event.pull_request.body, 'Website Update') ||
contains(github.event.pull_request.body, 'Misc')
)
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hello @${{ github.actor }} 👋
${{ env.BASE_MSG }}
⚠️ You must specify a category
Either: `Addition`, `Amendment`, `Removal`, `Spelling or Grammar`, `Website Update`, or `Misc`.
- name: Validate Supporting Material is Present
if: >
!contains(github.event.pull_request.body, 'Supporting Material')
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hello @${{ github.actor }} 👋
${{ env.BASE_MSG }}
⚠️ If applicable, please ensure you've provided supporting material.

96
.github/workflows/web-checks.yml vendored Normal file
View file

@ -0,0 +1,96 @@
name: Web Checks
on:
pull_request:
paths: ['web/**']
workflow_dispatch:
permissions:
contents: read
jobs:
lint:
name: 🧼 Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/.nvmrc
cache: yarn
cache-dependency-path: web/yarn.lock
- run: yarn install --frozen-lockfile
- run: yarn lint
format:
name: 💅 Format
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/.nvmrc
cache: yarn
cache-dependency-path: web/yarn.lock
- run: yarn install --frozen-lockfile
- run: yarn format:check
typecheck:
name: 🧩 Typecheck
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/.nvmrc
cache: yarn
cache-dependency-path: web/yarn.lock
- run: yarn install --frozen-lockfile
- run: yarn typecheck
test:
name: 🧪 Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/.nvmrc
cache: yarn
cache-dependency-path: web/yarn.lock
- run: yarn install --frozen-lockfile
- run: yarn test
summary:
name: 💬 Summary
if: always()
needs: [lint, format, typecheck, test]
runs-on: ubuntu-latest
steps:
- name: Build results table
run: |
em() { if [ "$1" = "success" ]; then echo "pass ✅"; else echo "FAIL ❌"; fi; }
line() { echo "| $1 | \`$2\` | $(em "$3") |"; }
{
echo "## Web Checks Summary"
echo ""
echo "| Check | Command | Result |"
echo "|-------|---------|--------|"
line "Lint" "yarn lint" "${{ needs.lint.result }}"
line "Format" "yarn format:check" "${{ needs.format.result }}"
line "Typecheck" "yarn typecheck" "${{ needs.typecheck.result }}"
line "Test" "yarn test" "${{ needs.test.result }}"
} >> "$GITHUB_STEP_SUMMARY"

View file

@ -1,18 +0,0 @@
name: ⭐ Hello non-Stargazers
on:
issues:
types: [opened]
jobs:
check-user:
if: ${{ github.event.comment.author_association != 'CONTRIBUTOR' }}
runs-on: ubuntu-latest
name: Add comment to issues opened by non-stargazers
steps:
- name: comment
uses: qxip/please-star-light@v4
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
autoclose: false
message: |
If you're enjoying Awesome-Privacy, consider dropping us a ⭐<br>
_<sub>🤖 I'm a bot, and this message was automated</sub>_

View file

@ -34,7 +34,7 @@ WEB_DIR := web
# Targets for lib/
install_lib_deps:
$(PYTHON) -m pip install -r $(LIB_DIR)/requirements.txt
$(PYTHON) -m pip install -q -r $(LIB_DIR)/requirements.txt
gen_readme: install_lib_deps
$(PYTHON) $(LIB_DIR)/awesome-privacy-readme-gen.py

View file

@ -135,7 +135,7 @@ categories:
Free for self-hosted data (or $3/ month hosted). Be aware that 1Password
is not fully open source, but they do regularly publish results of their
independent [security audits](https://support.1password.com/security-assessments),
and they have a solid reputation for transparently disclosing and fixing vulnerabilities
and they have a solid reputation for transparently disclosing and fixing vulnerabilities
furtherInfo: >
**Other Open Source PM**: [Buttercup](https://buttercup.pw), [Clipperz](https://clipperz.is),
[Pass](https://www.passwordstore.org), [Padloc](https://padloc.app), [TeamPass](https://teampass.net),
@ -252,11 +252,39 @@ categories:
iosApp: https://apps.apple.com/us/app/ente-auth/id6444121398
androidApp: io.ente.auth
description: |
Ente Auth is a free and open-source app which stores and generates TOTP tokens.
It can be used with an online account to backup and sync your tokens across your
devices (and access them via a web interface) in a secure, end-to-end encrypted
Ente Auth is a free and open-source app which stores and generates TOTP tokens.
It can be used with an online account to backup and sync your tokens across your
devices (and access them via a web interface) in a secure, end-to-end encrypted
fashion. It can also be used offline on a single device with no account necessary.
- name: Bitwarden Authenticator
url: https://bitwarden.com/products/authenticator/
icon: https://avatars.githubusercontent.com/u/15990069
openSource: true
github: bitwarden/ios
tosdrId: 5256
iosApp: https://apps.apple.com/app/bitwarden-authenticator/id6497335175
androidApp: com.bitwarden.authenticator
description: |
Bitwarden Authenticator is a free and open-source app which stores and generates
time-based codes for multi-factor authentication. It can be used with an online
account to backup and sync your tokens across your devices (and access them via
a web interface) in a secure, end-to-end encrypted fashion. It can also be used
offline on a single device with no account necessary.
- name: Proton Authenticator
url: https://proton.me/authenticator
icon: https://raw.githubusercontent.com/protonpass/android-pass/refs/heads/main/metadata/en-US/images/icon.png
openSource: true
github: protonpass/android-authenticator
tosdrId: 491
iosApp: https://apps.apple.com/us/app/proton-authenticator/id6741758667
androidApp: proton.android.authenticator
description: |
Proton Authenticator is free, open source, and available for both iOS and Android.
A Proton account is required to use Proton Authenticator.
Existing 2FA codes can be imported from other popular apps such as Google Authenticator and LastPass.
furtherInfo: >
Check which websites support multi-factor authentication: [2fa.directory](https://2fa.directory/)
notableMentions: >
@ -265,7 +293,7 @@ categories:
[Etopa](https://play.google.com/store/apps/details?id=de.ltheinrich.etopa) *(Android)*<br>
For KeePass users, [TrayTop](https://keepass.info/plugins.html#traytotp)
is a plugin for managing TOTP's - offline and compatible with Windows, Mac and Linux.
#############################
###### File Encryption ######
#############################
@ -309,9 +337,10 @@ categories:
- name: Picocrypt
github: Picocrypt/Picocrypt
icon: https://avatars.githubusercontent.com/u/171401041
url: ''
description: |
Picocrypt is a very small (hence Pico), very simple, yet very secure encryption tools
that you can use to protect your files. It's designed to be the go-to tool for encryption,
that you can use to protect your files. It's designed to be the go-to tool for encryption,
with a focus on security, simplicity, and reliability.
wordOfWarning: >
@ -393,7 +422,7 @@ categories:
Brave also has Tor built-in, when you open up a private tab/ window.
- name: Firefox
url: https://www.mozilla.org/firefox
url: https://www.firefox.com/
icon: https://www.mozilla.org/media/protocol/img/logos/firefox/logo.fedb52c912d6.svg
openSource: true
tosdrId: 188
@ -440,7 +469,7 @@ categories:
After installing, check the privacy & security settings, and update the configuration to
something that you are comfortable with. 12Bytes maintains a comprehensive guide on
[Firefox Configuration for Privacy and Performance](https://codeberg.org/12bytes/firefox-config-guide)
############################
###### Search Engines ######
############################
@ -511,6 +540,17 @@ categories:
description: |
British search engine providing independent and unbiased search results using
its own crawler. Has a zero tracking policy (it is not open source)
- name: Uruky
url: https://uruky.com
icon: https://uruky.com/public/images/favicon.svg
iosApp: https://apps.apple.com/us/app/uruky-private-ad-free-search/id6758864380
description: |
Uruky is an ad-free, private search engine focused on personalization. It uses,
among other providers, Mojeek and Marginalia. It is EU-based and does not keep
or track any personal data (it is not 100% open source, but after 12 months as
a paying customer, you get a copy of the source code).
notableMentions:
- name: MetaGear
url: https://metager.org
@ -524,8 +564,8 @@ categories:
and self-hostable, although using a [public instance](https://searx.space)
has the benefit of not singling out your queries to the engines used.
A fork of the original [Searx](https://searx.github.io/searx/).
- name: Communication
sections:
#################################
@ -581,10 +621,10 @@ categories:
subreddit: SimpleXChat
description: |
Simplex is gaining popularity as a secure and private messaging app renowned
for its robust encryption protocol without user IDs or phone numbers and this improves your privacy.
for its robust encryption protocol without user IDs or phone numbers and this improves your privacy.
Simplex offers instant messaging, supports media attachments and voice and video calls.
Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's
preferences for convenience, security, and accessibility.
Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's
preferences for convenience, security, and accessibility.
Learn more about the [Security Policy](https://simplex.chat/security/).
- name: XMPP
@ -615,7 +655,7 @@ categories:
an open specification and Simple pragmatic RESTful HTTP/JSON API it makes it easy
to integrates with existing 3rd party IDs to authenticate and discover users, as
well as to build apps on top of it.
notableMentions:
notableMentions:
- name: Chat Secure
url: https://chatsecure.org
- name: KeyBase
@ -649,7 +689,7 @@ categories:
be used to communicate any sensitive data.
[Wire](https://wire.com/) has also been removed, due to
a [recent acquisition](https://blog.privacytools.io/delisting-wire/)
###########################
###### P2P Messaging ######
###########################
@ -724,7 +764,7 @@ categories:
url: https://github.com/Bitmessage/PyBitmessage
- name: RetroShare
url: https://retroshare.cc
#############################
###### Encrypted Email ######
#############################
@ -792,11 +832,11 @@ categories:
custom domains (starting at $3/month). Tuta
[does not use OpenPGP](https://tuta.com/blog/posts/differences-email-encryption/)
like other encrypted mail providers, instead they use a standardized, hybrid method
consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048
consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048
or ECC (x25519) and Kyber-1024). This causes compatibility issues when communicating with contacts
using PGP. But it does allow them to encrypt much more of the header data (body,
attachments, subject lines, and sender names etc) which PGP mail providers cannot do. The recent upgrades
to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks
to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks
posed by quantum computers.
- name: Mailfence
url: https://mailfence.com?src=digitald
@ -1104,7 +1144,7 @@ categories:
url: https://www.smspool.net
icon: https://i.ibb.co/2t4MBFj/apple-touch-icon.png
description: |
Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers.
Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers.
We support over 50+ countries and support over 300+ services.
androidApp: com.smspool.app
iosApp: https://apps.apple.com/app/smspool/id6474617801
@ -1462,7 +1502,7 @@ categories:
Displays a country flag depicting the location of the current website's server, which can be useful to know at a glance.
Click icon for more tools such as site safety checks, whois, validation etc **Download**:
[Firefox](https://addons.mozilla.org/en-US/firefox/addon/flagfox/)
- name: Lightbeam
url: https://mozilla.github.io/lightbeam/
github: mozilla/lightbeam-we
@ -1533,7 +1573,7 @@ categories:
url: https://addons.mozilla.org/en-US/firefox/addon/crxviewer
description: >
A handy extension for viewing the source code of another browser extension,
which is a useful tool for verifying the code does what it says
which is a useful tool for verifying the code does what it says
wordOfWarning: |
- Having many extensions installed raises entropy, causing your fingerprint to be more unique, hence making tracking easier.
- Much of the functionality of the above addons can be applied without installing anything, by configuring browser settings yourself. For Firefox this is done in the user.js
@ -2053,7 +2093,7 @@ categories:
See [Streisand](https://github.com/StreisandEffect/streisand), to learn more, and get started with running a VPN.
[Digital Ocean](https://m.do.co/c/3838338e7f79) provides flexible,
secure and easy Linux VMs, (from $0.007/hour or $5/month),
Here is a [1-click install script](http://dovpn.carlfriess.com/)for
Here is a [1-click install script](http://dovpn.carlfriess.com/)for
on [Digital Ocean](https://m.do.co/c/3838338e7f79), by Carl Friess.
Recently distributed self-hosted solutions for running your own VPNs have
@ -2213,8 +2253,8 @@ categories:
Tor, I2P and Freenet are all anonymity networks - but they work very differently and each is good for specific purposes.
So a good and viable solution would be to use all of them, for different tasks.
*You can read more about how I2P compares to Tor, [here](https://blokt.com/guides/what-is-i2p-vs-tor-browser)*
#####################
###### Proxies ######
#####################
@ -2333,7 +2373,7 @@ categories:
Mullvads public DNS with QNAME minimization and basic ad blocking.
It has been audited by the security experts at Assured.
You can use this privacy-enhancing service even if you don't use Mullvad.
#########################
###### DNS Clients ######
#########################
@ -2653,7 +2693,7 @@ categories:
Some VPNs have ad-tracking blocking features, such as
[TrackStop with PerfectPrivacy](https://www.perfect-privacy.com/en/features/trackstop?a_aid=securitychecklist).
[Private Internet Access](https://www.privateinternetaccess.com/),
[CyberGhost](https://www.cyberghostvpn.com/),
[PureVPN](https://www.anrdoezrs.net/click-9242873-13842740),
@ -2892,7 +2932,7 @@ categories:
[5 eyes](https://en.wikipedia.org/wiki/Five_Eyes) (Australia, Canada, New Zealand, US and UK)
and [other international cooperatives](https://en.wikipedia.org/wiki/Five_Eyes#Other_international_cooperatives)
who have legal right to view your data.
###############################
###### Domain Registrars ######
###############################
@ -2930,7 +2970,7 @@ categories:
followWith: Web
description: |
Free DNS hosting provider designed with security in mind, and running
on purely open source software. deSEC is backed and funded by SSE.
on purely open source software. deSEC is backed and funded by SSE.
###########################
@ -3017,7 +3057,7 @@ categories:
All notes are saved individually as .md files, making them easy to manage.
No mobile app, built-in cloud-sync, encryption or web UI. But due to the structure of the files,
it is easy to use your own cloud sync provider, and additional features are provided through extensions.
- name: Joplin
url: https://joplinapp.org
github: laurent22/joplin
@ -3091,7 +3131,7 @@ categories:
notableMentions: |
If you are already tied into Evernote, One Note etc, then [SafeRoom](https://www.getsaferoom.com)
is a utility that encrypts your entire notebook, before it is uploaded to the cloud.
is a utility that encrypts your entire notebook, before it is uploaded to the cloud.
[Org Mode](https://orgmode.org) is a mode for [GNU Emacs](https://www.gnu.org/software/emacs/)
dedicated to working with the Org markup format. Org can be thought of as
@ -3151,6 +3191,17 @@ categories:
A key benefit the wide range of plug-ins in the NextCloud App Store,
maintained by the community. NextCloud was a hard fork off OwnCloud.
- name: bewCloud
url: https://bewcloud.com
icon: https://bewcloud.com/favicon.svg
followWith: Web
github: bewcloud/bewcloud
openSource: true
description: |
Modern and simpler alternative to Nextcloud/ownCloud crafted with TypeScript.
Unifies file management, sync, sharing, notes, RSS, expenses, calendars,
contacts, and photos, with MFA, WebDAV, CalDAV, CardDAV, SSO, and more.
notableMentions: |
Alternatively, consider a headless utility such as [Duplicacy](https://duplicacy.com)
or [Duplicity](http://duplicity.nongnu.org).
@ -3341,7 +3392,7 @@ categories:
using [Web Torrent](https://webtorrent.io).
For specifically transferring images, [Up1](https://github.com/Upload/Up1) is a good self-hosted option, with client-side encryption.
Finally [PsiTransfer](https://github.com/psi-4ward/psitransfer) is a feature-rich, self-hosted file drop, using streams.
@ -3390,7 +3441,7 @@ categories:
description: |
Simple bookmark manager written in Go, intended to be a clone of Pocket, it has both a simple and clean web
interface as well as a CLI. Shiori has easy import/ export, is portable and has webpage archiving features.
notableMentions: |
[Ymarks](https://ymarks.org) is a C-based self-hosted bookmark synchronization
server and [Chrome](https://chrome.google.com/webstore/detail/ymarks/gefignhaigoigfjfbjjobmegihhaacfi) extension.
@ -3448,7 +3499,7 @@ categories:
notableMentions: |
[Apache OpenMeetings](https://openmeetings.apache.org) provides self-hosted
video-conferencing, chat rooms, file server and tools for meetings.
[together.brave.com](https://together.brave.com) is Brave's Jitsi Fork.
For remote learning, [BigBlueButton](https://bigbluebutton.org) is self-hosted conference call software,
@ -3505,10 +3556,10 @@ categories:
it is not open source, and although there is a free version, a license
is required to access all features. VMWare performs very well when running
on a server, with hundreds of hosts and users.
For Mac users, [Parallels](https://www.parallels.com/uk/) is a popular
option which performs really well, but again is not open source.
For Windows users, there's
[Hyper-V](https://docs.microsoft.com/en-us/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v),
which is a native Windows product, developed by Microsoft.
@ -3577,7 +3628,7 @@ categories:
description: |
iOS app for encrypting/ decrypting text.
Has native keyboard integration, keychain support and app integrations which makes it quick to use in any app.
- name: FlowCrypt
url: https://flowcrypt.com
@ -3656,10 +3707,10 @@ categories:
Alternatively, with [ImageMagic](https://imagemagick.org) installed, just run
`convert -strip path/to/image.png` to remove all metadata.
If you have [GIMP](https://www.gimp.org) installed, then just go to `File --> Export As --> Export --> Advanced Options --> Uncheck the "Save EXIF data" option`.
Often you need to perform meta data removal programmatically, as part of a script or automation process.
Often you need to perform meta data removal programmatically, as part of a script or automation process.
- GoLang: [go-exif](https://github.com/dsoprea/go-exif) by @dsoprea
- JS: [exifr](https://github.com/MikeKovarik/exifr) by @MikeKovarik
- Python: [Piexif](https://github.com/hMatoba/Piexif) by @hMatoba
@ -3675,7 +3726,7 @@ categories:
[not remove it](https://uk.norton.com/internetsecurity-privacy-is-my-personal-data-really-gone-when-its-deleted-from-a-device.html)
from the disk, and recovering deleted files is a
[simple task](https://www.lifewire.com/how-to-recover-deleted-files-2622870).
Therefore, to protect your privacy, you should erase/ overwrite data from
the disk, before you destroy, sell or give away a hard drive.
services:
@ -3764,12 +3815,12 @@ categories:
Or [badblocks](https://linux.die.net/man/8/badblocks) which is intended to search for all bad blocks,
but can also be used to write zeros to a disk,
by running `sudo badblocks -wsv /dev/sdd`.
An effective method of erasing an SSD, it to use [hdparm](https://en.wikipedia.org/wiki/Hdparm)
to issue a [secure erase](https://en.wikipedia.org/wiki/Parallel_ATA#HDD_passwords_and_security)
command, to your target storage device,
for this, see step-by-step instructions via: [wiki.kernel.org](https://ata.wiki.kernel.org/index.php/ATA_Secure_Erase).
Finally, [srm](https://www.systutorials.com/docs/linux/man/1-srm/) can be
use to securely remove files or directories, just run `srm -zsv /path/to/file`
for a single pass over.
@ -3787,7 +3838,7 @@ categories:
If you are an Android user, your device has Google built-in at its core.
[Google tracks you](https://digitalcontentnext.org/blog/2018/08/21/google-data-collection-research/),
collecting a wealth of information, and logging your every move.
A [custom ROM](https://en.wikipedia.org/wiki/List_of_custom_Android_distributions),
is an open source, usually Google-free mobile OS that can be flashed to your device.
services:
@ -3796,7 +3847,7 @@ categories:
github: GrapheneOS/hardened_malloc
icon: https://grapheneos.org/apple-touch-icon.png
description: |
GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay.
GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay.
GrapheneOS is a young project, and currently only supports Pixel devices, partially due to their strong hardware security.
- name: CalyxOS
@ -3805,8 +3856,8 @@ categories:
github: CalyxOS/calyxos
tosdrId: 2558
description: |
CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users.
Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently
CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users.
Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently
only supports Pixel devices and Xiaomi Mi A2 with Fairphone 4, OnePlus 8T, OnePlus 9 test builds available. Developed by the Calyx Foundation.
- name: DivestOS
@ -3815,8 +3866,8 @@ categories:
github: Divested-Mobile/DivestOS-Build
tosdrId: 2550
description: |
DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of
discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained
DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of
discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained
solely by Tad (SkewedZeppelin) since 2014.
- name: LineageOS
@ -3825,17 +3876,17 @@ categories:
github: LineageOS/android
tosdrId: 7188
description: |
A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained,
A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained,
supports a wide range of devices, and comes bundled with Privacy Guard.
notableMentions: |
[Replicant OS](https://www.replicant.us/) is a fully-featured distro,
with an emphasis on freedom, privacy and security.
[OmniRom](https://www.omnirom.org/),
[Resurrection Remix OS](https://resurrectionremix.com/)
and [Paranoid Android](http://paranoidandroid.co/) are also popular options.
Alternatively, [Ubuntu Touch](https://ubports.com/) is a Linux (Ubuntu)- based OS.
It is secure by design and runs on almost any device, - but it does fall short when it comes to the app store.
@ -4002,7 +4053,7 @@ categories:
some technical knowledge to get started with, but once setup should perform
just as any other Windows 10 system. Note that you should only download the
LTSC ISO from the Microsoft's
[official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise)
[official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise)
#### Improve the Security and Privacy of your current OS
@ -4231,8 +4282,8 @@ categories:
and check reviews/ forums.
Create a system restore point, before making any significant changes to
your OS (such as disabling core features).
From a security and privacy perspective, Linux may be a better option.
From a security and privacy perspective, Linux may be a better option.
notableMentions: |
See also these lists:
@ -4305,7 +4356,7 @@ categories:
that offer free solutions, even if you pay for the premium package.
This includes (but not limited to) Avast, AVG, McAfee and Kasperky.
For AV to be effective, it needs intermate access to all areas of your PC,
so it is important to go with a trusted vendor, and monitor its activity closely.
so it is important to go with a trusted vendor, and monitor its activity closely.
- name: Development
@ -4360,7 +4411,7 @@ categories:
[reputation](https://srlabs.de/bites/smart-spies) when it comes to protecting
consumers privacy, there have been
[many recent breaches](https://www.theverge.com/2019/10/21/20924886/alexa-google-home-security-vulnerability-srlabs-phishing-eavesdropping).
For that reason it is recommended not to have these devices in your house.
The following are open source AI voice assistants, that aim to provide a
human voice interface while also protecting your privacy and security
@ -4396,7 +4447,7 @@ categories:
[LinTO](https://linto.ai), [Jovo](https://www.jovo.tech) and [Snips](https://snips.ai)
are private-by-design voice assistant frameworks that can be built on by developers,
or used by enterprises.
[Jasper](https://jasperproject.github.io),
[Stephanie](https://github.com/SlapBot/stephanie-va) and
[Hey Athena](https://github.com/rcbyron/hey-athena-client) are Python-based voice assistant, but neither is under active development anymore.
@ -4413,7 +4464,7 @@ categories:
description: |
An open source privacy-respecting Home Assistant, compatible with a wide range of
devices including Raspberry Pi, desktop computers, or NAS systems.
Actively developed, with good french community and various integrations
Actively developed, with good french community and various integrations
(Zigbee, Philips, Camera, Tuya, MQTT, Telegram, ...).
url: https://gladysassistant.com/
github: gladysassistant/gladys
@ -4456,7 +4507,7 @@ categories:
notableMentions: |
Other privacy-focused cryptocurrencies include:
[PIVX](https://pivx.org),
[Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/).
[Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/).
wordOfWarning: |
Not all cryptocurrencies are anonymous, and without using a privacy-focused coin,
a record of your transaction will live on a publicly available distributed ledger, forever.
@ -4593,7 +4644,7 @@ categories:
[BitMex](https://www.bitmex.com/) has more advanced trading features,
but ID verification is required for higher value trades involving Fiat currency.
For buying and selling alt-coins, [Binance](https://www.binance.com/en/register?ref=X2BHKID1) has a wide range of currencies,
~and ID verification is not needed for small-value trades~ but ID verification is required in most countries.
@ -4717,9 +4768,8 @@ categories:
[Smart Wallet](https://apps.apple.com/app/smart-wallet/id1378013954) (iOS),
[My-Budget](https://rezach.github.io/my-budget) (Desktop),
[MoneyManager EX](https://www.moneymanagerex.org),
[Skrooge](https://skrooge.org),
[kMyMoney](https://kmymoney.org) and
[Budget Zen](https://budgetzen.net) (a simple E2E encrypted budget manager)
[Skrooge](https://skrooge.org), and
[kMyMoney](https://kmymoney.org).
- name: Social
@ -4730,7 +4780,7 @@ categories:
Over the past decade, social networks have revolutionized the way we communicate
and bought the world closer together - but it came at the
[cost of our privacy](https://en.wikipedia.org/wiki/Privacy_concerns_with_social_networking_services).
Social networks are built on the principle of sharing - but you, the user
should be able to choose with whom you share what, and that is what the
following sites aim to do.
@ -4761,12 +4811,12 @@ categories:
- name: nostr
description: |
nostr stands for Notes and other stuff transmitted by relays.
It is an open protocol, not merely a platform.
This distinction enables truly censorship-resistant and global value-for-value publishing on the web.
With the power to replace data-greedy applications like Twitter and Instagram,
nostr offers a promising alternative for users seeking a more private and secure online experience
without algorithmic manipulations. ".... I feel like Im looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr.
nostr stands for Notes and other stuff transmitted by relays.
It is an open protocol, not merely a platform.
This distinction enables truly censorship-resistant and global value-for-value publishing on the web.
With the power to replace data-greedy applications like Twitter and Instagram,
nostr offers a promising alternative for users seeking a more private and secure online experience
without algorithmic manipulations. ".... I feel like Im looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr.
url: https://github.com/nostr-protocol/nostr
github: https://github.com/nostr-protocol
@ -4885,7 +4935,7 @@ categories:
then [Listed.to](https://listed.to) is a public blogging platform with
strong privacy features.
It lets you publish posts directly through the Standard Notes app or web interface.
Other minimalistic platforms include [Notepin.co](https://notepin.co) and [Pen.io](http://pen.io).
Want to write a simple text post and promote it yourself?
@ -4967,7 +5017,7 @@ categories:
url: https://newpipe.schabi.org
description: An open source, privacy-respecting YouTube client for Android.
# tosdrId: 2568
- name: FreeTube
- name: FreeTube
url: https://freetubeapp.io
description: |
An open source YouTube client for Windows, MacOS and Linux, providing
@ -4976,7 +5026,10 @@ categories:
# tosdrId: 3996
wordOfWarning: |
When proxies are involved - only use reputable services, and **never** enter any personal information
furtherInfo: |
*[alternative-front-ends](https://github.com/mendel5/alternative-front-ends) provides information on
dozens of alternative front-end options for popular platforms, such as YouTube, Twitter, Reddit,
TikTok, and more.*
- name: Media
sections:
@ -5006,7 +5059,17 @@ categories:
services: []
- name: File Converters
alternativeTo: ['format factory', 'handbrake', 'freemake video converter', 'any video converter', 'online-convert.com']
services: []
services:
- name: FFmpeg
url: https://ffmpeg.org/
icon: https://ffmpeg.org/favicon.ico
openSource: true
github: FFmpeg/FFmpeg
description: |
A complete, cross-platform solution to record, convert, and stream audio and
video. It's the industry standard multimedia framework, handling a vast range
of formats. As a command-line tool, it guarantees that all processing is done
locally on your machine.
- name: Creativity
sections:
@ -5097,7 +5160,7 @@ categories:
- name: Video Editors
alternativeTo: ['adobe premiere pro', 'final cut pro', 'davinci resolve', 'imovie', 'sony vegas pro']
services:
services:
- name: Shotcut
followWith: Windows, Mac OS, Linux
description: |
@ -5162,7 +5225,7 @@ categories:
github: NatronGitHub/Natron
icon: https://natrongithub.github.io/img/Natron_icon.svg
openSource: true
- name: Audio Editors & Recorders
alternativeTo: ['adobe audition', 'garageband', 'fl studio', 'ableton live']
services:
@ -5173,16 +5236,16 @@ categories:
great free alternative to Adobe Audition.
Features recording from real and virtual devices,
import/export to a wide range of formats, high-quality processing
advanced multi-track editing, noise reduction, pitch correction,
advanced multi-track editing, noise reduction, pitch correction,
audio restoration and much more.
It's easily extendable via community plugins, and
It's easily extendable via community plugins, and
also supports cusotm macros and many scripting options
url: https://www.audacityteam.org
github: audacity/audacity
icon: https://www.audacityteam.org/_astro/Audacity_Logo.63b57726.svg
openSource: true
tosdrId: 4516
- name: Casting & Streaming
alternativeTo: ['xsplit', 'streamlabs obs', 'twitch studio', 'wirecast']
services:
@ -5199,11 +5262,11 @@ categories:
icon: https://obsproject.com/assets/images/new_icon_small-r.png
openSource: true
tosdrId: 4227
- name: Screenshot Tools
alternativeTo: ['snagit', 'greenshot', 'lightshot', 'gyazo', 'sharex']
services: []
- name: 3D Graphics
alternativeTo: ['blender', 'autodesk maya', 'cinema 4d', '3ds max', 'sketchup']
services:
@ -5229,7 +5292,7 @@ categories:
url: https://wings3d.com
github: dgud/wings
icon: https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Wings3d.png/120px-Wings3d.png
- name: Animation
alternativeTo: ['adobe after effects', 'animate cc', 'toon boom harmony', 'moho (anime studio)', 'pencil2d']
services:

1
lib/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__pycache__

View file

@ -1,13 +1,13 @@
"""
Reads app list from awesome-privacy.yml,
formats into markdown, and inserts into README.md
formats into markdown, and inserts into README.md
"""
import os
import re
import yaml
import logging
from urllib.parse import urlparse
from urllib.parse import urlparse, quote
# Configure Logging
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
@ -44,14 +44,32 @@ def tosElement(tosdrId):
return ""
return f"[![Privacy Policy](https://shields.tosdr.org/en_{tosdrId}.svg)](https://tosdr.org/en/service/{tosdrId})"
def statsElement(isOpenSource, isSecurityAudited, isAcceptsCrypto):
def statsElement(app, categoryName, sectionName):
statsStr = ""
if isOpenSource == True:
statsStr += "📦 Open Source "
if isSecurityAudited == True:
statsStr += "🛡️ Security Audited "
if isAcceptsCrypto == True:
statsStr += "💰 Accepts Anonymous Payment "
if app.get('openSource') == True:
github = app.get('github')
if github:
link = f"https://github.com/{github}"
elif app.get('url'):
link = app.get('url')
else:
link = f"https://awesome-privacy.xyz/{slugify(categoryName)}/{slugify(sectionName)}/{slugify(app.get('name'))}"
statsStr += (
f"[![Open Source](https://img.shields.io/badge/-Open_Source-3DA639"
f"?style=flat&logo=opensourceinitiative&logoColor=white)]({link}) "
)
if app.get('securityAudited') == True:
statsStr += (
"![Security Audited](https://img.shields.io/badge/-Security_Audited-3DA639"
"?style=flat&logo=data:image/svg+xml;base64,"
"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAxTDMgNXY2YzAgNS41NSAzLjg0IDEwLjc0IDkgMTIgNS4xNi0xLjI2IDktNi40NSA5LTEyVjVsLTktNHoiLz48L3N2Zz4="
"&logoColor=white) "
)
if app.get('acceptsCrypto') == True:
statsStr += (
"![Accepts Anonymous Payment](https://img.shields.io/badge/-Anon_Payment_Accepted"
"%EF%B8%8F-3DA639?style=flat&logo=bitcoincash&logoColor=white) "
)
return statsStr
def slugify(title):
@ -63,6 +81,11 @@ def slugify(title):
title = title.replace('?', '')
return title
def shieldsEncode(text):
if not text: return ''
text = text.strip().replace('-', '--').replace('_', '__').replace(' ', '_')
return quote(text, safe='_-.')
def awesomePrivacyReport(categoryName, sectionName, serviceName):
if not serviceName:
return ""
@ -72,12 +95,74 @@ def awesomePrivacyReport(categoryName, sectionName, serviceName):
f"(https://awesome-privacy.xyz/{slugify(categoryName)}/{slugify(sectionName)}/{slugify(serviceName)})"
)
def makeStatsCard():
return (
f"\t- <details><summary>Stats</summary>\n\n"
f""
f"\n\n</details>"
)
def playStoreBadge(name, androidApp):
if not androidApp: return ""
encoded = shieldsEncode(name)
return (
f"[![{name} on Google Play](https://img.shields.io/badge/-{encoded}-3bd47f"
f"?style=flat&logo=android&logoColor=white)]"
f"(https://play.google.com/store/apps/details?id={androidApp}) "
)
def appStoreBadge(name, iosApp):
if not iosApp: return ""
encoded = shieldsEncode(name)
return (
f"[![{name} on App Store](https://img.shields.io/badge/-{encoded}-0D96F6"
f"?style=flat&logo=appstore&logoColor=white)]"
f"({iosApp}) "
)
def redditBadge(subreddit):
if not subreddit or not subreddit.strip(): return ""
sub = subreddit.strip()
return (
f"[![r/{sub} on Reddit](https://img.shields.io/badge/-{sub}-FF4500"
f"?style=flat&logo=reddit&logoColor=white)]"
f"(https://reddit.com/r/{sub}) "
)
def discordBadge(name, discordInvite):
if not discordInvite or not discordInvite.strip(): return ""
invite = discordInvite.strip()
encoded = shieldsEncode(name)
link = invite if invite.startswith('https://') else f"https://discord.gg/{invite}"
return (
f"[![{name} on Discord](https://img.shields.io/badge/-{encoded}-5865F2"
f"?style=flat&logo=discord&logoColor=white)]"
f"({link}) "
)
_MD_PATTERNS = [
re.compile(r'\[([^\]]*)\]\([^)]*\)'), # [text](url) — group 1 = visible text
re.compile(r'\*\*(.+?)\*\*'), # **bold**
re.compile(r'`([^`]+)`'), # `code`
re.compile(r'(?<!\*)\*([^*]+)\*(?!\*)'), # *italic*
]
def truncateMarkdown(text, maxLen=200):
"""Returns (truncated_text, was_truncated) preserving markdown constructs."""
if len(text) <= maxLen:
return text, False
result = []
visible = 0
i = 0
while i < len(text) and visible < maxLen:
for pattern in _MD_PATTERNS:
m = pattern.match(text, i)
if m:
result.append(m.group(0))
visible += len(m.group(1))
i = m.end()
break
else:
result.append(text[i])
visible += 1
i += 1
return ''.join(result).rstrip(), True
def makeHref(text):
if not text: return "#"
@ -116,21 +201,32 @@ def makeAwesomePrivacy():
)
# For each service, list it's name, icon, url, and description
for app in section.get('services') or []:
description, was_truncated = truncateMarkdown(app.get('description', ''))
ap_link = (
f"https://awesome-privacy.xyz/"
f"{slugify(category.get('name'))}/{slugify(section.get('name'))}/{slugify(app.get('name'))}"
)
ellipsis = f"[…]({ap_link} \"View full {app.get('name')} report\")" if was_truncated else ""
markdown += (
f"- **[{iconElement(app.get('url'), app.get('icon'))} {app.get('name')}]"
f"({app.get('url')})** - {app.get('description')}"
f"[…](https://awesome-privacy.xyz/"
f"{slugify(category.get('name'))}/{slugify(section.get('name'))}/{slugify(app.get('name'))} \"View full {app.get('name')} report\") \n"
+ ((
f"\t- <details>\n\t\t<summary>Stats</summary>\n\n\t\t"
f"{repoElement(app.get('github'))} "
f"{tosElement(app.get('tosdrId'))} "
f"{awesomePrivacyReport(category.get('name'), section.get('name'), app.get('name'))} \n"
f"{statsElement(app.get('openSource'), app.get('securityAudited'), app.get('acceptsCrypto'))}˙ \n"
f"\n\t\t</details>\n"
)
if app.get('github') or app.get('tosdrId') else '')
f"({app.get('url')})** - {description}{ellipsis} \n"
)
badges = ' '.join(filter(None, [
repoElement(app.get('github')),
tosElement(app.get('tosdrId')),
awesomePrivacyReport(category.get('name'), section.get('name'), app.get('name')),
statsElement(app, category.get('name'), section.get('name')).rstrip(),
playStoreBadge(app.get('name'), app.get('androidApp')).rstrip(),
appStoreBadge(app.get('name'), app.get('iosApp')).rstrip(),
redditBadge(app.get('subreddit')).rstrip(),
discordBadge(app.get('name'), app.get('discordInvite')).rstrip(),
]))
if badges:
markdown += (
f"\t- <details>\n\t\t<summary>Stats</summary>\n\n\t\t"
f"{badges} \n"
f"\n\t\t</details>\n"
)
markdown += "\n"
# If word of warning exists, append it
if section.get('wordOfWarning'):
@ -145,7 +241,7 @@ def makeAwesomePrivacy():
markdown += f"> - [{mention.get('name')}]({mention.get('url')})" + (
f" - {mention.get('description')}" if mention.get('description') else "\n"
)
else:
else:
notable_mentions = section.get('notableMentions').replace('\n', '\n> ')
markdown += f"> {notable_mentions}"
@ -170,7 +266,7 @@ def update_content_between_markers(content, start_marker, end_marker, new_conten
logger.info(f"Updating content between {start_marker} and {end_marker} markers...")
start_index = content.find(start_marker)
end_index = content.find(end_marker)
if start_index != -1 and end_index != -1:
before_section = content[:start_index + len(start_marker)]
after_section = content[end_index:]

View file

@ -0,0 +1,291 @@
"""Validates data quality for added/modified services using the diff JSON."""
import json
import os
import sys
import yaml
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
DIFF_PATH = "/tmp/pr-diff.json"
FINDINGS_PATH = "/tmp/findings-data.json"
REQUIRED_FIELDS = ("name", "description", "url", "icon")
CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
SCHEMA_MSG = (
"Some of the schema checks have failed. Please check that your addition"
" contains all the required fields, with acceptable values, nothing"
" additional and that it is following valid YAML syntax"
)
MULTIPLE_MSG = "Please make just one addition per pull request"
MISSING_TPL = (
"Did you include all required fields? Looks like {fields} is missing or"
f" invalid. Please see the [required fields]({CONTRIBUTING}#service-fields)"
" for available fields."
)
POSITION_MSG = (
"New entries must be added to the end of the section, unless otherwise requested"
)
OPENSOURCE_MSG = (
"You indicated this app/service is not open source. This will likely make"
" it ineligible for listing on Awesome Privacy in accordance with our"
f" [Requirements]({CONTRIBUTING}#requirements)."
" Please ensure that this is justified in your PR body."
)
DUPLICATE_NAME_MSG = (
"A service named `{name}` already exists (in {location})."
" If this is a different service, please clarify in your PR description"
)
DUPLICATE_URL_MSG = (
"The URL `{url}` is already associated with `{existing}`."
" Please check this isn't a duplicate submission"
)
DESC_LENGTH_MSG = (
"Description length ({length} chars) is outside the recommended 50\u2013250"
f" character range. Please see our [Contributing Guidelines]({CONTRIBUTING}#description)"
)
OPENSOURCE_GITHUB_MSG = (
"You marked this service as open source but didn't include a `github` field."
" Please add the repository link"
)
def load_json(path):
"""Load JSON from a file, returning None on any error."""
try:
with open(path) as f:
return json.load(f)
except Exception:
return None
def load_yaml_data(path):
"""Load YAML from a file, returning None on any error."""
try:
with open(path) as f:
return yaml.safe_load(f)
except Exception:
return None
def find_section_services(head, category, section):
"""Return the services list for a category/section pair, or None."""
for cat in head.get("categories", []):
if cat.get("name") == category:
for sec in cat.get("sections", []):
if sec.get("name") == section:
return sec.get("services", [])
return None
def find_service_fields(head, category, section, service_name):
"""Look up a service's fields in the head YAML."""
services = find_section_services(head, category, section)
if services:
for svc in services:
if svc.get("name") == service_name:
return svc
return None
def check_required_fields(diff, head):
"""Return a finding if any added/modified service is missing required fields."""
missing = set()
for svc in diff.get("services", {}).get("added", []):
fields = svc.get("fields", {})
for f in REQUIRED_FIELDS:
if fields.get(f) is None:
missing.add(f)
for svc in diff.get("services", {}).get("modified", []):
if not head:
continue
changed = svc.get("changed_fields", [])
fields = find_service_fields(
head, svc["category"], svc["section"], svc["service"]
)
if fields:
for f in REQUIRED_FIELDS:
if f in changed and fields.get(f) is None:
missing.add(f)
if missing:
names = ", ".join(f"`{f}`" for f in sorted(missing))
return MISSING_TPL.format(fields=names)
return None
def check_position(diff, head):
"""Return a finding if a newly added service is not at the end of its section."""
if not head:
return None
for svc in diff.get("services", {}).get("added", []):
services = find_section_services(head, svc["category"], svc["section"])
if services and services[-1].get("name") != svc["service"]:
return POSITION_MSG
return None
def check_open_source(diff):
"""Return a finding if an added service has openSource missing or not true."""
for svc in diff.get("services", {}).get("added", []):
fields = svc.get("fields", {})
if fields.get("openSource") is not True and not fields.get("github"):
return OPENSOURCE_MSG
return None
def check_single_entry(diff):
"""Return a finding if the diff adds multiple new services or sections."""
services = diff.get("services", {})
added_count = len(services.get("added", []))
if added_count > 1:
return MULTIPLE_MSG
if added_count == 0:
added_sections = [s for s in diff.get("sections", [])
if s.get("change_type") == "added_section"]
if len(added_sections) > 1:
return MULTIPLE_MSG
return None
def _added_keys(diff):
"""Build a set of (category, section, lowercase_name) for added services."""
keys = set()
for svc in diff.get("services", {}).get("added", []):
name = svc.get("fields", {}).get("name", "").lower().strip()
keys.add((svc.get("category", ""), svc.get("section", ""), name))
return keys
def build_name_index(head, diff):
"""Build {lowercase_name: "category > section"} from all services, excluding additions."""
index = {}
if not head:
return index
exclude = _added_keys(diff)
for cat in head.get("categories", []):
cn = cat.get("name", "")
for sec in cat.get("sections", []):
sn = sec.get("name", "")
for svc in sec.get("services", []):
name = svc.get("name", "").lower().strip()
if name and (cn, sn, name) not in exclude:
index[name] = f"{cn} > {sn}"
return index
def build_url_index(head, diff):
"""Build {url: service_name} from all services, excluding additions."""
index = {}
if not head:
return index
exclude = _added_keys(diff)
for cat in head.get("categories", []):
cn = cat.get("name", "")
for sec in cat.get("sections", []):
sn = sec.get("name", "")
for svc in sec.get("services", []):
name = svc.get("name", "").lower().strip()
if (cn, sn, name) in exclude:
continue
url = svc.get("url", "")
if url:
index[url] = svc.get("name", "")
return index
def check_duplicate_name(diff, name_index):
"""Return a finding if an added service name already exists in the YAML."""
for svc in diff.get("services", {}).get("added", []):
name = svc.get("fields", {}).get("name", "").lower().strip()
if name and name in name_index:
return DUPLICATE_NAME_MSG.format(
name=svc["fields"]["name"], location=name_index[name],
)
return None
def check_duplicate_url(diff, url_index):
"""Return a finding if an added service URL already exists in the YAML."""
for svc in diff.get("services", {}).get("added", []):
url = svc.get("fields", {}).get("url", "")
if url and url in url_index:
return DUPLICATE_URL_MSG.format(url=url, existing=url_index[url])
return None
def check_description_length(diff):
"""Return a finding if an added service description is outside 50-250 chars."""
for svc in diff.get("services", {}).get("added", []):
desc = svc.get("fields", {}).get("description", "")
length = len(desc)
if length < 50 or length > 280:
return DESC_LENGTH_MSG.format(length=length)
return None
def check_opensource_github(diff):
"""Return a finding if an added service is open source but has no github field."""
for svc in diff.get("services", {}).get("added", []):
fields = svc.get("fields", {})
if fields.get("openSource") is True and not fields.get("github"):
return OPENSOURCE_GITHUB_MSG
return None
def main():
findings = []
try:
if os.environ.get("SCHEMA_OUTCOME") == "failure":
findings.append(SCHEMA_MSG)
diff = load_json(DIFF_PATH)
head = load_yaml_data(DATA_PATH)
if diff:
finding = check_single_entry(diff)
if finding:
findings.append(finding)
finding = check_required_fields(diff, head)
if finding:
findings.append(finding)
finding = check_position(diff, head)
if finding:
findings.append(finding)
finding = check_open_source(diff)
if finding:
findings.append(finding)
name_index = build_name_index(head, diff)
url_index = build_url_index(head, diff)
finding = check_duplicate_name(diff, name_index)
if finding:
findings.append(finding)
finding = check_duplicate_url(diff, url_index)
if finding:
findings.append(finding)
finding = check_description_length(diff)
if finding:
findings.append(finding)
finding = check_opensource_github(diff)
if finding:
findings.append(finding)
except Exception:
pass
with open(FINDINGS_PATH, "w") as f:
json.dump(findings, f)
sys.exit(0)
if __name__ == "__main__":
main()

172
lib/checks/check-pr-meta.py Normal file
View file

@ -0,0 +1,172 @@
"""Checks PR metadata: title format, draft status, template completeness, and checkboxes."""
import json
import os
import re
import subprocess
import sys
FINDINGS_PATH = "/tmp/findings-compliance.json"
BAD_TITLES = {"update readme.md", "update awesome-privacy.yml"}
TITLE_MSG = (
"The pull request title does not follow the format defined in our guidelines."
" Please rename it to `[Add/Remove/Update] [software name] in [software section]`"
)
DRAFT_MSG = (
"Please avoid opening WIP pull requests."
" Your PR should be 100% ready and complete before submitting"
)
TEMPLATE_MSG = (
"Please fill in pull request template in full."
" You can find a copy of this"
" [here](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md)"
)
CHECKBOX_MSG = (
"Ensure you have completed the checklist (put a tick the checkboxes with `[x]`),"
" to confirm that you've read the contributing guidelines, checked your submission,"
" indicated your affiliation and agree to follow our CoC"
)
README_MSG = (
"Do not edit the README directly. This file is auto-generated from the"
" content in `awesome-privacy.yml`, and so your changes will be overridden!"
" Instead, only modify the YAML file, and be sure to follow our Contributing Guidelines."
)
BOT_MSG = (
"Submissions are only accepted from humans."
" This PR appears to have been authored by a bot or AI assistant."
)
_BOT_AUTHOR_RE = re.compile(
r"(?:noreply@anthropic\.com|devin-ai-integration|copilot-swe-agent|noreply@cursor\.com)",
re.IGNORECASE,
)
def extract_section(body, header):
"""Extract content between a ### header and the next delimiter."""
pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)"
match = re.search(pattern, body, re.DOTALL)
return match.group(1) if match else None
def strip_html_comments(text):
"""Remove HTML comments from text."""
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL).strip()
def check_title(title):
"""Return a finding if the PR title matches a known-bad pattern."""
if title and title.strip().lower() in BAD_TITLES:
return TITLE_MSG
return None
def check_draft(draft_str):
"""Return a finding if the PR is in draft state."""
if str(draft_str).lower() == "true":
return DRAFT_MSG
return None
def check_template(body):
"""Return a finding if required template sections are missing or empty."""
for header in ("Type", "Changes", "Checklist"):
content = extract_section(body, header)
if content is None or not strip_html_comments(content):
return TEMPLATE_MSG
return None
def check_checkboxes(body):
"""Return a finding if any checklist checkboxes are unchecked."""
section = extract_section(body, "Checklist")
if section is None:
return None
checked = re.findall(r"- \[x\]", section, re.IGNORECASE)
unchecked = re.findall(r"- \[ \]", section)
if not checked and not unchecked:
return None
if unchecked:
return CHECKBOX_MSG
return None
def check_bot_coauthors(base_ref):
"""Return a finding if any commit in the PR has a bot author or co-author."""
if not base_ref:
return None
try:
result = subprocess.run(
["git", "log", f"{base_ref}..HEAD", "--format=%aN <%aE>%n%B"],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
return None
if _BOT_AUTHOR_RE.search(result.stdout):
return BOT_MSG
except Exception:
pass
return None
def check_readme(readme_failed):
"""Return a finding if the README check reported a failure."""
if readme_failed == "true":
return README_MSG
return None
def write_findings(findings):
"""Write the findings list to the output JSON file."""
with open(FINDINGS_PATH, "w") as f:
json.dump(findings, f)
def main():
findings = []
critical = False
try:
title = os.environ.get("PR_TITLE", "")
body = os.environ.get("PR_BODY", "")
draft = os.environ.get("PR_DRAFT", "false")
readme_failed = os.environ.get("README_FAILED", "false")
base_ref = os.environ.get("BASE_REF", "")
finding = check_bot_coauthors(base_ref)
if finding:
findings.append(finding)
finding = check_title(title)
if finding:
findings.append(finding)
finding = check_draft(draft)
if finding:
findings.append(finding)
if not body or not body.strip():
findings.append(TEMPLATE_MSG)
critical = True
else:
finding = check_template(body)
if finding:
findings.append(finding)
critical = True
finding = check_checkboxes(body)
if finding:
findings.append(finding)
finding = check_readme(readme_failed)
if finding:
findings.append(finding)
except Exception:
pass
write_findings(findings)
sys.exit(1 if critical else 0)
if __name__ == "__main__":
main()

400
lib/checks/check-project.py Normal file
View file

@ -0,0 +1,400 @@
"""Checks project health: URL reachability, GitHub repo stars, activity, and author match."""
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
import requests
import yaml
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
DIFF_PATH = "/tmp/pr-diff.json"
FINDINGS_PATH = "/tmp/findings-project.json"
TIMEOUT = 10
USER_AGENT = "awesome-privacy-ci/1.0"
MIN_STARS = 100
INACTIVE_DAYS = 90
MIN_AGE_DAYS = 120
AI_COMMIT_RATIO = 0.2
AI_BOT_AUTHORS = [
"noreply@anthropic.com",
"devin-ai-integration[bot]",
"copilot-swe-agent.github.com",
"noreply@cursor.com",
]
SPAM_PR_THRESHOLD = 5
LINK_MSG = (
"Our automated checks were unable to verify the link(s) you included"
" were reachable, so please double check this yourself"
)
AUTHOR_MSG = (
"Looks like you are the author of this package. Please ensure that you"
" have clearly disclosed this in your PR body for transparency"
)
STARS_MSG = (
"It looks like your submission is quite a small project without a lot of users yet."
" In some circumstances we may ask you to resubmit this once the project"
" is more mature and has a proven track record of good practices and maintenance."
)
ACTIVITY_MSG = (
"Please confirm that the project you are adding is actively maintained,"
" as it looks to not have had any recent updates in the past 3 months."
)
MATURITY_MSG = (
"This project appears to be quite new (created less than 4 months ago)."
" Repositories should have a proven track record before listing."
)
AI_CODE_MSG = (
"This project appears to contain AI-generated code."
" Additional care will be needed when reviewing the submission."
)
FORK_MSG = (
"The GitHub link in this listing is a fork."
" Please confirm it's the correct (and actively maintained) repository"
)
LICENSE_MSG = (
"There doesn't appear to be a license included in the project's GitHub repo"
)
ARCHIVED_MSG = (
"The GitHub project linked has been archived."
" Additions must be actively maintained."
)
SECURITY_MSG = (
"This project has open security vulnerabilities (critical or high severity)"
" flagged by GitHub Dependabot. Please verify these have been addressed"
)
SPAM_MSG = (
"This user has opened up a large number of PRs to other awesome-* repos"
" in the past 24 hours, and appears to be spamming"
)
def load_diff(path):
"""Load the diff JSON, returning None on any error."""
try:
with open(path) as f:
return json.load(f)
except Exception:
return None
def check_url(url):
"""Return True if the URL is reachable, True on any error (no false positives)."""
try:
resp = requests.head(
url, timeout=TIMEOUT, allow_redirects=True,
headers={"User-Agent": USER_AGENT},
)
if resp.status_code >= 400:
resp = requests.get(
url, timeout=TIMEOUT, allow_redirects=True,
headers={"User-Agent": USER_AGENT}, stream=True,
)
resp.close()
return resp.status_code < 400
except Exception:
return True
def parse_github_field(value):
"""Parse a github field into (owner, repo), or (None, None) on failure."""
if not value:
return None, None
if value.startswith("https://github.com/"):
parts = value.removeprefix("https://github.com/").strip("/").split("/")
if len(parts) >= 2:
return parts[0], parts[1]
return None, None
if "/" in value:
parts = value.split("/")
if len(parts) == 2:
return parts[0], parts[1]
return None, None
def fetch_repo(owner, repo, token):
"""Fetch GitHub repo metadata, returning None on any error."""
try:
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
if token:
headers["Authorization"] = f"token {token}"
resp = requests.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers=headers, timeout=TIMEOUT,
)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
def load_yaml_data():
"""Load the head YAML, returning None on any error."""
try:
with open(DATA_PATH) as f:
return yaml.safe_load(f)
except Exception:
return None
def find_service_in_head(head, category, section, service_name):
"""Look up a service in the head YAML by path."""
if not head:
return None
for cat in head.get("categories", []):
if cat.get("name") == category:
for sec in cat.get("sections", []):
if sec.get("name") == section:
for svc in sec.get("services", []):
if svc.get("name") == service_name:
return svc
return None
def get_services(diff, key):
"""Safely extract a service list from the diff."""
return diff.get("services", {}).get(key, [])
def check_links(diff, head):
"""Return LINK_MSG if any service URL or icon URL is unreachable."""
for svc in get_services(diff, "added"):
fields = svc.get("fields", {})
url = fields.get("url")
if url and not check_url(url):
return LINK_MSG
icon = fields.get("icon")
if icon and not check_url(icon):
return LINK_MSG
for svc in get_services(diff, "modified"):
changed = svc.get("changed_fields", [])
if "url" not in changed and "icon" not in changed:
continue
head_svc = find_service_in_head(
head, svc["category"], svc["section"], svc["service"]
)
if head_svc:
if "url" in changed:
url = head_svc.get("url")
if url and not check_url(url):
return LINK_MSG
if "icon" in changed:
icon = head_svc.get("icon")
if icon and not check_url(icon):
return LINK_MSG
return None
def _commit_has_bot(commit, bot_set):
"""Check if a commit was authored or co-authored by a known AI bot."""
author = commit.get("commit", {}).get("author", {})
email = (author.get("email") or "").lower()
name = (author.get("name") or "").lower()
if email in bot_set or name in bot_set:
return True
message = (commit.get("commit", {}).get("message") or "").lower()
for line in message.splitlines():
if line.strip().startswith("co-authored-by:"):
if any(bot in line for bot in bot_set):
return True
return False
def check_ai_commits(owner, repo, token):
"""Return AI_CODE_MSG if recent commits contain significant AI bot activity."""
try:
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
if token:
headers["Authorization"] = f"token {token}"
resp = requests.get(
f"https://api.github.com/repos/{owner}/{repo}/commits",
headers=headers, timeout=TIMEOUT, params={"per_page": 100},
)
if resp.status_code != 200:
return None
commits = resp.json()
if not commits:
return None
bot_set = {a.lower() for a in AI_BOT_AUTHORS}
count = sum(1 for c in commits if _commit_has_bot(c, bot_set))
if count / len(commits) >= AI_COMMIT_RATIO:
return AI_CODE_MSG
except Exception:
pass
return None
def check_security_alerts(owner, repo, token):
"""Return SECURITY_MSG if the repo has open critical/high Dependabot alerts."""
try:
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
if token:
headers["Authorization"] = f"token {token}"
resp = requests.get(
f"https://api.github.com/repos/{owner}/{repo}/dependabot/alerts",
headers=headers, timeout=TIMEOUT,
params={"state": "open", "severity": "critical,high", "per_page": 1},
)
if resp.status_code == 200 and resp.json():
return SECURITY_MSG
except Exception:
pass
return None
def check_spam_prs(pr_user, token):
"""Return SPAM_MSG if the user has opened many PRs to other awesome-* repos recently."""
if not pr_user or not token:
return None
try:
since = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
headers["Authorization"] = f"token {token}"
resp = requests.get(
"https://api.github.com/search/issues",
headers=headers, timeout=TIMEOUT,
params={"q": f"type:pr author:{pr_user} created:>={since}"},
)
if resp.status_code != 200:
return None
items = resp.json().get("items", [])
this_repo = os.environ.get("GITHUB_REPOSITORY", "Lissy93/awesome-privacy").lower()
count = 0
for item in items:
repo_url = item.get("repository_url", "")
# repository_url looks like https://api.github.com/repos/owner/repo-name
repo_full = "/".join(repo_url.rstrip("/").split("/")[-2:]).lower()
repo_name = repo_url.rstrip("/").split("/")[-1].lower()
if repo_name.startswith("awesome-") and repo_full != this_repo:
count += 1
if count >= SPAM_PR_THRESHOLD:
return SPAM_MSG
except Exception:
pass
return None
_DISCLOSURE_RE = re.compile(
r"i am the author|i'm the author|my project|i created|i develop"
r"|i maintain|i built|my own project|i made|author of|maintainer of",
re.IGNORECASE,
)
def _pr_discloses_authorship(pr_body):
"""Return True if the PR body already discloses the submitter is the author."""
return bool(pr_body and _DISCLOSURE_RE.search(pr_body))
def check_repo_signals(diff, pr_user, token, pr_body=""):
"""Check GitHub repo author match, stars, and activity for added services."""
findings = []
if not token:
return findings
cache = {}
for svc in get_services(diff, "added"):
gh = svc.get("fields", {}).get("github")
owner, repo = parse_github_field(gh)
if not owner:
continue
cache_key = f"{owner}/{repo}"
if cache_key not in cache:
cache[cache_key] = fetch_repo(owner, repo, token)
data = cache[cache_key]
if not data:
continue
repo_owner = data.get("owner", {})
if (
pr_user
and repo_owner.get("type") == "User"
and repo_owner.get("login", "").lower() == pr_user.lower()
and AUTHOR_MSG not in findings
and not _pr_discloses_authorship(pr_body)
):
findings.append(AUTHOR_MSG)
stars = data.get("stargazers_count", 0)
if stars < MIN_STARS and STARS_MSG not in findings:
findings.append(STARS_MSG)
if data.get("fork") and FORK_MSG not in findings:
findings.append(FORK_MSG)
if not data.get("license") and LICENSE_MSG not in findings:
findings.append(LICENSE_MSG)
if data.get("archived") and ARCHIVED_MSG not in findings:
findings.append(ARCHIVED_MSG)
pushed = data.get("pushed_at")
if pushed and ACTIVITY_MSG not in findings:
try:
pushed_dt = datetime.fromisoformat(pushed.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
if (now - pushed_dt).days > INACTIVE_DAYS:
findings.append(ACTIVITY_MSG)
except Exception:
pass
created = data.get("created_at")
if created and MATURITY_MSG not in findings:
try:
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
if (now - created_dt).days < MIN_AGE_DAYS:
findings.append(MATURITY_MSG)
except Exception:
pass
if AI_CODE_MSG not in findings:
finding = check_ai_commits(owner, repo, token)
if finding:
findings.append(finding)
if SECURITY_MSG not in findings:
finding = check_security_alerts(owner, repo, token)
if finding:
findings.append(finding)
return findings
def main():
findings = []
try:
diff = load_diff(DIFF_PATH)
if not diff:
with open(FINDINGS_PATH, "w") as f:
json.dump(findings, f)
sys.exit(0)
head = load_yaml_data()
finding = check_links(diff, head)
if finding:
findings.append(finding)
pr_user = os.environ.get("PR_USER", "")
pr_body = os.environ.get("PR_BODY", "")
token = os.environ.get("GITHUB_TOKEN", "")
findings.extend(check_repo_signals(diff, pr_user, token, pr_body))
finding = check_spam_prs(pr_user, token)
if finding:
findings.append(finding)
except Exception:
pass
with open(FINDINGS_PATH, "w") as f:
json.dump(findings, f)
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,105 @@
"""
Fails if the PR directly edits the auto-generated section of the README.
The generated section is between <!-- awesome-privacy-start --> and <!-- awesome-privacy-end -->.
"""
import argparse
import os
import re
import subprocess
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
README_PATH = ".github/README.md"
README_ABS = os.path.join(PROJECT_ROOT, README_PATH)
# Exit codes
EXIT_PASS = 0
EXIT_FAIL = 1
EXIT_RUNTIME_ERROR = 2
# ANSI color helpers
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
def get_changed_files(base_ref):
result = subprocess.run(
["git", "diff", "--name-only", f"{base_ref}..HEAD"],
capture_output=True, text=True, check=True,
cwd=PROJECT_ROOT,
)
return result.stdout.strip().splitlines()
def get_marker_lines():
"""Find the line numbers of the start/end markers in the README."""
try:
with open(README_ABS, "r") as f:
lines = f.readlines()
except FileNotFoundError:
return None, None
start_line = None
end_line = None
for i, line in enumerate(lines, start=1):
if "<!-- awesome-privacy-start -->" in line:
start_line = i
if "<!-- awesome-privacy-end -->" in line:
end_line = i
return start_line, end_line
def get_changed_line_numbers(base_ref):
"""Parse git diff hunk headers to find which lines were changed in the README."""
result = subprocess.run(
["git", "diff", "-U0", f"{base_ref}..HEAD", "--", README_PATH],
capture_output=True, text=True, check=True,
cwd=PROJECT_ROOT,
)
changed_lines = []
for line in result.stdout.splitlines():
# Match hunk headers like @@ -10,5 +12,7 @@
match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", line)
if match:
start = int(match.group(1))
count = int(match.group(2)) if match.group(2) else 1
for n in range(start, start + count):
changed_lines.append(n)
return changed_lines
def main():
parser = argparse.ArgumentParser(description="Check for direct README edits to generated section")
parser.add_argument("--base-ref", required=True, help="Base git ref to diff against")
args = parser.parse_args()
# Skip if README wasn't changed
changed_files = get_changed_files(args.base_ref)
if README_PATH not in changed_files:
print(green("README not modified, skipping."))
sys.exit(EXIT_PASS)
# Find marker lines
start_line, end_line = get_marker_lines()
if start_line is None or end_line is None:
print("Could not find generated-section markers in README, skipping check.")
sys.exit(EXIT_PASS)
# Check if any changed lines fall within the generated section
changed_lines = get_changed_line_numbers(args.base_ref)
for line_num in changed_lines:
if start_line <= line_num <= end_line:
print(red("Direct edits to the generated section of the README are not allowed."), file=sys.stderr)
print(red("Edit awesome-privacy.yml instead and the README will be regenerated."), file=sys.stderr)
sys.exit(EXIT_FAIL)
print(green("README changes are outside the generated section, OK."))
sys.exit(EXIT_PASS)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,90 @@
"""Decides whether a PR is ready for maintainer review.
Conditions: 2+ approvals from external contributors AND all CI checks passing.
Reads:
pr-meta/reviews.json array of {user, state} from GitHub API
pr-meta/check-runs.json array of {status, conclusion} from GitHub API
pr-meta/already-notified.txt "true" if notification comment already exists
Writes:
pr-meta/action.txt "notify" or "skip"
"""
import json
import os
WORK_DIR = "pr-meta"
MAINTAINER = "Lissy93"
REQUIRED_APPROVALS = 2
PASSING_CONCLUSIONS = {"success", "skipped", "neutral"}
def read_json(filename):
"""Load a JSON file from the work directory, or empty list on error."""
try:
with open(os.path.join(WORK_DIR, filename)) as f:
return json.load(f)
except Exception:
return []
def count_external_approvals(reviews):
"""Count unique non-maintainer users who approved."""
approvers = {
r["user"]
for r in reviews
if r.get("state") == "APPROVED"
and r.get("user", "").lower() != MAINTAINER.lower()
}
return len(approvers)
def all_checks_passing(check_runs):
"""Return True if every check run completed successfully."""
if not check_runs:
return False
return all(
cr.get("status") == "completed"
and cr.get("conclusion") in PASSING_CONCLUSIONS
for cr in check_runs
)
def already_notified():
"""Return True if the notification comment already exists on the PR."""
try:
with open(os.path.join(WORK_DIR, "already-notified.txt")) as f:
return f.read().strip().lower() == "true"
except Exception:
return False
def write_action(action):
os.makedirs(WORK_DIR, exist_ok=True)
with open(os.path.join(WORK_DIR, "action.txt"), "w") as f:
f.write(action)
def main():
if already_notified():
write_action("skip")
return
reviews = read_json("reviews.json")
approvals = count_external_approvals(reviews)
if approvals < REQUIRED_APPROVALS:
write_action("skip")
return
check_runs = read_json("check-runs.json")
if not all_checks_passing(check_runs):
write_action("skip")
return
write_action("notify")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,255 @@
"""Analyzes the diff between base and head versions of awesome-privacy.yml.
Enforces the single-entry rule and outputs a JSON diff to /tmp/pr-diff.json.
"""
import argparse
import json
import os
import subprocess
import sys
import yaml
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
DIFF_OUTPUT_PATH = "/tmp/pr-diff.json"
SUMMARY_OUTPUT_PATH = "/tmp/pr-diff-summary.md"
EXIT_PASS = 0
EXIT_RULE_VIOLATION = 1
EXIT_RUNTIME_ERROR = 2
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s)
def load_base_yaml(base_ref):
"""Load the YAML from the base ref using git show."""
try:
result = subprocess.run(
["git", "show", f"{base_ref}:awesome-privacy.yml"],
capture_output=True, text=True, check=True, cwd=PROJECT_ROOT,
)
return yaml.safe_load(result.stdout)
except subprocess.CalledProcessError:
print(yellow("awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr)
return {"categories": []}
except yaml.YAMLError as e:
print(red(f"Failed to parse base YAML: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
def load_head_yaml():
"""Load the YAML from the current working tree."""
try:
with open(DATA_PATH) as f:
return yaml.safe_load(f)
except (FileNotFoundError, yaml.YAMLError) as e:
print(red(f"Failed to load head YAML: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
def build_index(data, depth):
"""Build a keyed index at the given depth (3=services, 2=sections, 1=categories)."""
index = {}
for cat in data.get("categories", []):
cn = cat.get("name", "")
if depth == 1:
index[cn] = {k: v for k, v in cat.items() if k != "sections"}
continue
for sec in cat.get("sections", []):
sn = sec.get("name", "")
if depth == 2:
index[(cn, sn)] = {k: v for k, v in sec.items() if k != "services"}
continue
for svc in sec.get("services", []):
index[(cn, sn, svc.get("name", ""))] = svc
return index
def diff_index(base_idx, head_idx):
"""Return (added_keys, removed_keys, modified_keys_with_changed_fields)."""
base_keys, head_keys = set(base_idx), set(head_idx)
added = sorted(head_keys - base_keys)
removed = sorted(base_keys - head_keys)
modified = []
for key in sorted(base_keys & head_keys):
if base_idx[key] != head_idx[key]:
all_fields = set(base_idx[key]) | set(head_idx[key])
changed = sorted(f for f in all_fields if base_idx[key].get(f) != head_idx[key].get(f))
modified.append((key, changed))
return added, removed, modified
def write_github_output(name, value):
"""Write a value to $GITHUB_OUTPUT."""
output_file = os.environ.get("GITHUB_OUTPUT")
if output_file:
with open(output_file, "a") as f:
f.write(f"{name}={value}\n")
def find_duplicate_names(data):
"""Find duplicate service names within the same section."""
duplicates = []
for cat in data.get("categories", []):
cn = cat.get("name", "")
for sec in cat.get("sections", []):
sn = sec.get("name", "")
seen = {}
for svc in sec.get("services", []):
name = svc.get("name", "")
if name in seen:
duplicates.append((cn, sn, name))
else:
seen[name] = True
return duplicates
def fmt_path(key):
"""Format a tuple key as a readable path."""
return "".join(key) if isinstance(key, tuple) else key
def format_diff_bullets(diff_result):
"""Build bullet-point lines summarizing all changes. Returns list of strings or empty list."""
bullets = []
for svc in diff_result["services"]["added"]:
bullets.append(f"- Added **{svc['service']}** in {svc['category']}{svc['section']}")
for svc in diff_result["services"]["removed"]:
bullets.append(f"- Removed **{svc['service']}** from {svc['category']}{svc['section']}")
for svc in diff_result["services"]["modified"]:
fields = ", ".join(f"`{f}`" for f in svc["changed_fields"])
bullets.append(f"- Modified {fields} in {svc['category']}{svc['section']}{svc['service']}")
for change in diff_result["sections"]:
ct = change["change_type"]
path = f"{change['category']}{change['section']}"
if ct == "added_section":
bullets.append(f"- Added section **{change['section']}** in {change['category']}")
elif ct == "removed_section":
bullets.append(f"- Removed section **{change['section']}** from {change['category']}")
else:
fields = ", ".join(f"`{f}`" for f in change.get("changed_fields", []))
bullets.append(f"- Modified section metadata ({fields}) in {path}")
for change in diff_result["categories"]:
if change["change_type"] == "added_category":
bullets.append(f"- Added category **{change['category']}**")
else:
bullets.append(f"- Removed category **{change['category']}**")
for dup in diff_result.get("duplicates", []):
bullets.append(
f"- ⚠️ Duplicate service name **{dup['service']}** "
f"in {dup['category']}{dup['section']}"
)
return bullets
def write_diff_summary(diff_result):
"""Write the bullet-point summary to a file for downstream consumers."""
bullets = format_diff_bullets(diff_result)
if bullets:
with open(SUMMARY_OUTPUT_PATH, "w") as f:
f.write("\n".join(bullets) + "\n")
def write_step_summary(diff_result):
"""Write a bullet-point Markdown summary to $GITHUB_STEP_SUMMARY."""
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_file:
return
bullets = format_diff_bullets(diff_result)
lines = ["## YAML Diff Analysis\n"]
if bullets:
lines.extend(bullets)
else:
lines.append("No changes detected in `awesome-privacy.yml`.")
with open(summary_file, "a") as f:
f.write("\n".join(lines) + "\n")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base-ref", required=True)
args = parser.parse_args()
base = load_base_yaml(args.base_ref)
head = load_head_yaml()
svc_added, svc_removed, svc_modified = diff_index(
build_index(base, 3), build_index(head, 3),
)
sec_added, sec_removed, sec_modified = diff_index(
build_index(base, 2), build_index(head, 2),
)
cat_added, cat_removed, _ = diff_index(
build_index(base, 1), build_index(head, 1),
)
added = [{"category": k[0], "section": k[1], "service": k[2],
"fields": build_index(head, 3)[k]} for k in svc_added]
removed = [{"category": k[0], "section": k[1], "service": k[2]} for k in svc_removed]
modified = [{"category": k[0], "section": k[1], "service": k[2],
"changed_fields": cf} for k, cf in svc_modified]
sections = []
for k in sec_added:
sections.append({"category": k[0], "section": k[1], "change_type": "added_section"})
for k in sec_removed:
sections.append({"category": k[0], "section": k[1], "change_type": "removed_section"})
for k, cf in sec_modified:
sections.append({"category": k[0], "section": k[1],
"change_type": "modified_section_metadata", "changed_fields": cf})
categories = []
for k in cat_added:
categories.append({"category": k, "change_type": "added_category"})
for k in cat_removed:
categories.append({"category": k, "change_type": "removed_category"})
duplicates = find_duplicate_names(head)
dup_entries = [{"category": d[0], "section": d[1], "service": d[2]}
for d in duplicates]
diff_result = {
"services": {"added": added, "removed": removed, "modified": modified},
"sections": sections,
"categories": categories,
"duplicates": dup_entries,
}
with open(DIFF_OUTPUT_PATH, "w") as f:
json.dump(diff_result, f, indent=2)
write_github_output("has_service_changes", str(bool(added or removed or modified)).lower())
write_step_summary(diff_result)
write_diff_summary(diff_result)
added_count = len(added)
if added_count > 1:
print(red(f"Single-entry rule violation: {added_count} service additions found."), file=sys.stderr)
sys.exit(EXIT_RULE_VIOLATION)
added_sections = [s for s in sections if s["change_type"] == "added_section"]
if added_count == 0 and len(added_sections) > 1:
print(red(f"Single-entry rule violation: {len(added_sections)} section additions found."), file=sys.stderr)
sys.exit(EXIT_RULE_VIOLATION)
if duplicates:
names = ", ".join(f"{d[2]} (in {d[0]}{d[1]})" for d in duplicates)
print(red(f"Duplicate service names found: {names}"), file=sys.stderr)
sys.exit(EXIT_RULE_VIOLATION)
total = len(added) + len(removed) + len(modified)
print(green(f"Single-entry rule passed. {total} service "
f"({added_count} added), {len(sections)} section, "
f"{len(categories)} category change(s)."))
sys.exit(EXIT_PASS)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,46 @@
"""
Detects which files changed between the PR base and HEAD.
Sets GitHub Actions output: yaml_changed.
"""
import argparse
import os
import subprocess
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
YAML_FILE = "awesome-privacy.yml"
def write_github_output(name, value):
output_file = os.environ.get("GITHUB_OUTPUT")
if output_file:
with open(output_file, "a") as f:
f.write(f"{name}={value}\n")
def main():
parser = argparse.ArgumentParser(description="Detect changed files in a PR")
parser.add_argument("--base-ref", required=True, help="Base git ref to diff against")
args = parser.parse_args()
result = subprocess.run(
["git", "diff", "--name-only", f"{args.base_ref}..HEAD"],
capture_output=True, text=True, check=True,
cwd=PROJECT_ROOT,
)
changed_files = [f for f in result.stdout.strip().splitlines() if f]
print("Changed files:")
for f in changed_files:
print(f" {f}")
yaml_changed = YAML_FILE in changed_files
write_github_output("yaml_changed", str(yaml_changed).lower())
print(f"yaml_changed={yaml_changed}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,126 @@
"""Aggregates findings from all check jobs into a formatted PR comment."""
import json
import os
import sys
ARTIFACTS_DIR = "/tmp/artifacts"
OUTPUT_DIR = "/tmp/pr-meta"
REPO_URL = "https://github.com/Lissy93/awesome-privacy"
CONTRIBUTING = f"{REPO_URL}/blob/main/.github/CONTRIBUTING.md"
DIFF_SUMMARY_PATH = os.path.join(ARTIFACTS_DIR, "pr-diff-summary.md")
def load_findings(filename):
"""Load a findings JSON array from the artifacts directory, or empty list on error."""
try:
with open(os.path.join(ARTIFACTS_DIR, filename)) as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception:
return []
def collect_findings():
"""Gather all findings in display order: compliance, data, project."""
all_findings = []
all_findings.extend(load_findings("findings-compliance.json"))
all_findings.extend(load_findings("findings-data.json"))
all_findings.extend(load_findings("findings-project.json"))
return all_findings
def load_diff_summary():
"""Load the pre-formatted diff summary, or None if unavailable."""
try:
with open(DIFF_SUMMARY_PATH) as f:
content = f.read().strip()
return content if content else None
except Exception:
return None
def format_comment(findings, user, changes_summary, run_id):
"""Build the markdown comment."""
parts = [
f"<!-- pr-check-bot -->\nHello @{user}\n",
f"Thank you for contributing to Awesome Privacy! We will review your "
f"submission shortly. In the meantime, please ensure all changes are "
f"correct and inline with our [Contributing Requirements]({CONTRIBUTING}).\n",
]
if findings:
bullet_list = "\n".join(f"- {f}" for f in findings)
parts.append(
f"Our automated checks detected some issues:\n\n{bullet_list}\n\n"
f"> [!NOTE]\n"
f"> I am a bot, and sometimes make mistakes in my suggestions. "
f"But a human will review your submission shortly!"
)
else:
parts.append("> ✅ All our automated checks have passed.")
if changes_summary:
parts.append(
f"<details><summary>Summary of Changes:</summary>\n\n"
f"{changes_summary}\n</details>"
)
if run_id:
parts.append(
f'<sup>For full details, please see workflow run '
f'<a href="{REPO_URL}/actions/runs/{run_id}">{run_id}</a></sup>'
)
return "\n\n".join(parts) + "\n"
def write_step_summary(findings):
"""Write a summary to GITHUB_STEP_SUMMARY."""
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_file:
return
lines = ["## PR Check Summary\n"]
if findings:
lines.append(f"⚠️ Found {len(findings)} issue(s):\n")
for f in findings:
lines.append(f"- {f}")
else:
lines.append("✅ All checks passed.\n")
with open(summary_file, "a") as f:
f.write("\n".join(lines) + "\n")
def main():
try:
user = os.environ.get("PR_USER", "contributor")
pr_number = os.environ.get("PR_NUMBER", "")
run_id = os.environ.get("RUN_ID", "")
os.makedirs(OUTPUT_DIR, exist_ok=True)
if pr_number:
with open(os.path.join(OUTPUT_DIR, "number.txt"), "w") as f:
f.write(pr_number)
if run_id:
with open(os.path.join(OUTPUT_DIR, "run-id.txt"), "w") as f:
f.write(run_id)
findings = collect_findings()
with open(os.path.join(OUTPUT_DIR, "findings-count.txt"), "w") as f:
f.write(str(len(findings)))
changes_summary = load_diff_summary()
write_step_summary(findings)
comment = format_comment(findings, user, changes_summary, run_id)
with open(os.path.join(OUTPUT_DIR, "comment.md"), "w") as f:
f.write(comment)
except Exception:
pass
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,169 @@
"""Decides whether to create, update, or skip the PR bot comment.
Reads:
pr-meta/comment.md new comment from format-comment.py
pr-meta/findings-count.txt number of findings (from format-comment.py)
pr-meta/existing-comment.md current bot comment on the PR (from workflow)
Writes:
pr-meta/action.txt "create", "update", or "skip"
pr-meta/final-comment.md the body to post or update with
"""
import os
import re
WORK_DIR = "pr-meta"
def read_file(path):
"""Read a file and return its stripped content, or None if missing/empty."""
try:
with open(path) as f:
content = f.read().strip()
return content if content else None
except Exception:
return None
def read_findings_count(new_body):
"""Return the findings count from findings-count.txt, or by counting bullets."""
raw = read_file(os.path.join(WORK_DIR, "findings-count.txt"))
if raw is not None:
try:
return int(raw)
except ValueError:
pass
# Fallback: count bullet lines before <details> (diff summary has its own bullets)
body_before_details = new_body.split("<details>")[0]
return len(re.findall(r"^- .+$", body_before_details, re.MULTILINE))
def _was_already_passing(existing_body):
"""Check if the most recent state in the comment is already all-clear."""
# If there's a previous "all passing" edit, the last state was passing
if re.search(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?: (?:- )?All checks are (now passing|passing now)", existing_body, re.MULTILINE):
return True
# If there are no edits at all, check the original comment body
if not re.search(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?:", existing_body, re.MULTILINE):
return "All our automated checks have passed" in existing_body
return False
def _previous_failing_count(existing_body):
"""Extract the findings count from the most recent state in the comment."""
# Check edit lines first (most recent state)
matches = re.findall(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?: (?:- )?(\d+) checks? (?:is|are) still failing", existing_body, re.MULTILINE)
if matches:
return int(matches[-1])
# No edits — count bullets in the original comment (before <details>)
if not re.search(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?:", existing_body, re.MULTILINE):
body_before_details = existing_body.split("<details>")[0]
bullets = re.findall(r"^- .+$", body_before_details, re.MULTILINE)
return len(bullets) if bullets else None
return None
def _extract_pr_author(existing_body):
"""Extract the PR author username from the 'Hello @user' greeting."""
match = re.search(r"Hello @([\w-]+)", existing_body)
return match.group(1) if match else None
def build_edit_line(existing_body, findings_count, check_run_id, repo):
"""Build the edit line to append, or None if nothing to do."""
run_tag = f"<!-- run:{check_run_id} -->"
# Idempotency: this run was already processed
if run_tag in existing_body:
return None
# Skip if the state hasn't changed
if findings_count == 0 and _was_already_passing(existing_body):
return None
if findings_count > 0 and _previous_failing_count(existing_body) == findings_count:
return None
# Count previous edits to determine the next number
edits = re.findall(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?:", existing_body, re.MULTILINE)
edit_count = len(edits)
next_edit = edit_count + 1
run_url = f"https://github.com/{repo}/actions/runs/{check_run_id}"
# Build resolved-issue prefix
prev_count = _previous_failing_count(existing_body)
resolved_prefix = ""
if prev_count is not None and prev_count > findings_count:
resolved = prev_count - findings_count
noun = "issue was" if resolved == 1 else "issues were"
resolved_prefix = f"{resolved} {noun} resolved, but "
if findings_count == 0:
edit_label = "**Edit:**" if edit_count == 0 else f"**Edit {next_edit}:**"
line = f"{edit_label} - All checks are now passing \U0001f389, see [here]({run_url}) for details {run_tag}"
# Add thank-you when transitioning from failures to passing
if prev_count is not None and prev_count > 0:
author = _extract_pr_author(existing_body)
if author:
line += (
f"\n\nThank you @{author} for fixing those issues! \U0001f607\n"
f"This PR is now ready for human review, looping in @Lissy93 \U0001fae1"
)
return line
verb = "check is" if findings_count == 1 else "checks are"
edit_label = f"**Edit {next_edit}:**"
return (
f"{edit_label} - {resolved_prefix}{findings_count} {verb} still failing, "
f"see [here]({run_url}) for details {run_tag}"
)
def write_output(action, body=""):
"""Write action.txt and (optionally) final-comment.md."""
os.makedirs(WORK_DIR, exist_ok=True)
with open(os.path.join(WORK_DIR, "action.txt"), "w") as f:
f.write(action)
if body:
with open(os.path.join(WORK_DIR, "final-comment.md"), "w") as f:
f.write(body)
def main():
check_run_id = os.environ.get("CHECK_RUN_ID", "")
repo = os.environ.get("GITHUB_REPOSITORY", "")
new_body = read_file(os.path.join(WORK_DIR, "comment.md"))
if not new_body:
write_output("skip")
return
existing_body = read_file(os.path.join(WORK_DIR, "existing-comment.md"))
# No existing comment — create a new one
if not existing_body:
write_output("create", new_body)
return
# Existing comment — build an edit line to append
if not check_run_id:
write_output("skip")
return
findings_count = read_findings_count(new_body)
edit_line = build_edit_line(existing_body, findings_count, check_run_id, repo)
if not edit_line:
write_output("skip")
return
if "### Updates" in existing_body:
updated = existing_body.rstrip() + "\n" + edit_line
else:
updated = existing_body.rstrip() + "\n\n---\n\n### Updates\n\n" + edit_line
write_output("update", updated)
if __name__ == "__main__":
main()

View file

@ -1,5 +1,3 @@
PyYAML==6.0.1
requests==2.31.0
jsonschema
pyyaml
termcolor
jsonschema==4.23.0
requests==2.32.3

View file

@ -7,38 +7,64 @@
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"name": { "type": "string", "minLength": 1, "maxLength": 50 },
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"services": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"description": { "type": "string" },
"url": { "type": "string" },
"github": { "type": "string", "nullable": true },
"icon": { "type": "string", "nullable": true },
"followWith": { "type": "string", "nullable": true },
"securityAudited": { "type": "boolean", "nullable": true },
"openSource": { "type": "boolean", "nullable": true },
"acceptsCrypto": { "type": "boolean", "nullable": true },
"tosdrId": { "type": "number", "nullable": true },
"iosApp": { "type": "string", "nullable": true },
"androidApp": { "type": "string", "nullable": true },
"discordInvite": { "type": "string", "nullable": true },
"subreddit": { "type": "string", "nullable": true }
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"description": { "type": "string", "minLength": 10, "maxLength": 1500 },
"url": {
"type": "string",
"anyOf": [
{ "pattern": "^https?://" },
{ "maxLength": 0 }
]
},
"github": {
"type": ["string", "null"],
"pattern": "^([a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+|https://github\\.com/.+)$"
},
"icon": {
"type": ["string", "null"],
"pattern": "^https?://.+"
},
"followWith": { "type": ["string", "null"], "minLength": 1, "maxLength": 100 },
"securityAudited": { "type": ["boolean", "null"] },
"openSource": { "type": ["boolean", "null"] },
"acceptsCrypto": { "type": ["boolean", "null"] },
"tosdrId": { "type": ["integer", "null"], "minimum": 1 },
"iosApp": {
"type": ["string", "null"],
"pattern": "^https://apps\\.apple\\.com/"
},
"androidApp": {
"type": ["string", "null"],
"pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$"
},
"discordInvite": {
"type": ["string", "null"],
"pattern": "^(https://discord\\.gg/[a-zA-Z0-9]+|[a-zA-Z0-9]+|)$"
},
"subreddit": {
"type": ["string", "null"],
"pattern": "^[a-zA-Z0-9_]+$",
"minLength": 1,
"maxLength": 50
}
},
"required": ["name", "description", "url"],
"additionalProperties": false
}
},
"intro": { "type": "string", "nullable": true },
"intro": { "type": ["string", "null"], "minLength": 1 },
"notableMentions": {
"oneOf": [
{
@ -46,24 +72,29 @@
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"description": { "type": "string" },
"url": { "type": "string" }
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"description": { "type": "string", "minLength": 1 },
"url": { "type": "string", "pattern": "^https?://" }
},
"required": ["name", "url"],
"additionalProperties": false
}
},
{ "type": "string" }
],
"nullable": true
{ "type": "string", "minLength": 1 },
{ "type": "null" }
]
},
"furtherInfo": { "type": "string", "nullable": true },
"wordOfWarning": { "type": "string", "nullable": true },
"furtherInfo": { "type": ["string", "null"], "minLength": 1 },
"wordOfWarning": { "type": ["string", "null"], "minLength": 1 },
"alternativeTo": {
"type": "array",
"items": { "type": "string" },
"nullable": true
"oneOf": [
{
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 100 },
"minItems": 1
},
{ "type": "null" }
]
}
},
"required": ["name", "services"],

View file

@ -1,85 +1,112 @@
import json
import os
import sys
import logging
import yaml
from termcolor import colored
from jsonschema import Draft7Validator
# Configure Logging
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=LOG_LEVEL)
logger = logging.getLogger(__name__)
# Paths (relative to project root)
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
SCHEMA_PATH = os.path.join(PROJECT_ROOT, "lib/schema.json")
# Exit codes
EXIT_VALID = 0
EXIT_VALIDATION_ERRORS = 1
EXIT_RUNTIME_ERROR = 2
MAX_ERRORS = 20
# ANSI color helpers (disabled when NO_COLOR is set or stderr is not a TTY)
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s)
dim = (lambda s: f"\033[2m{s}\033[0m") if _use_color else (lambda s: s)
# Determine the project root based on the script's location
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
awesome_privacy_path = os.path.join(project_root, 'awesome-privacy.yml')
schema_path = os.path.join(project_root, 'lib/schema.json')
def resolve_path(data, path_parts):
"""Walk the data along path_parts, replacing indices with 'name' values."""
segments = []
current = data
for part in path_parts:
if isinstance(current, dict) and part in current:
current = current[part]
if isinstance(current, dict) and "name" in current:
segments.append(current["name"])
elif not isinstance(part, int):
pass # skip dict keys like 'categories', 'sections', 'services'
elif isinstance(current, list) and isinstance(part, int) and part < len(current):
current = current[part]
if isinstance(current, dict) and "name" in current:
segments.append(current["name"])
else:
segments.append(str(part))
else:
segments.append(str(part))
break
return " > ".join(segments) if segments else "(root)"
# Log method, accepts a message and optional log level
# and prints the output to the terminal in right color
def loggy(message: str, level: str = 'debug'):
if level == "info":
logger.info(colored(message, 'blue'))
elif level == "warning":
logger.warning(colored(message, 'yellow'))
elif level == "error":
logger.error(colored(message, 'red'))
elif level == "success":
logger.info(colored(message, 'green'))
elif level == "debug":
logger.debug(colored(message, 'grey'))
# Loads a given YAML file and returns the data
def load_yaml(yaml_path: str):
loggy(f"Loading YAML from {yaml_path}", "info")
def load_yaml(path):
try:
with open(yaml_path, 'r') as file:
return yaml.safe_load(file)
with open(path, "r") as f:
return yaml.safe_load(f)
except FileNotFoundError:
print(red(f"File not found: {path}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
except yaml.YAMLError as e:
loggy(f"Failed to load YAML: {e}", "error")
sys.exit(1)
print(red(f"Failed to parse YAML: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
# Loads a given JSON Schema file and returns the data
def load_schema(schema_path: str):
loggy(f"Loading JSON Schema from {schema_path}", "info")
def load_schema(path):
try:
with open(schema_path, 'r') as file:
return json.load(file)
with open(path, "r") as f:
return json.load(f)
except FileNotFoundError:
print(red(f"File not found: {path}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
except json.JSONDecodeError as e:
loggy(f"Failed to load JSON Schema: {e}", "error")
sys.exit(1)
print(red(f"Failed to parse JSON schema: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
# Validates the given YAML data against the given JSON Schema
def validate_yaml(data, schema):
loggy("Beginning validation", "info")
def validate(data, schema):
validator = Draft7Validator(schema)
errors = sorted(validator.iter_errors(data), key=lambda e: e.path)
if errors:
for error in errors:
error_location = "->".join(map(str, error.path))
loggy(f"Validation error: {error.message} (at {error_location})", "warning")
return False
return True
errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path))
formatted = []
for error in errors:
location = resolve_path(data, list(error.path))
formatted.append(f"{location}: {error.message}")
return formatted
# Main method
def main():
loggy("Starting...", "info")
yaml_data = load_yaml(awesome_privacy_path)
schema = load_schema(schema_path)
data = load_yaml(DATA_PATH)
schema = load_schema(SCHEMA_PATH)
errors = validate(data, schema)
if validate_yaml(yaml_data, schema):
loggy("Validation successful!", "success")
sys.exit(0)
else:
loggy("Validation failed.", "error")
sys.exit(1)
if errors:
shown = errors[:MAX_ERRORS]
for msg in shown:
print(red("ERROR") + " " + msg, file=sys.stderr)
if len(errors) > MAX_ERRORS:
print(dim(f"...and {len(errors) - MAX_ERRORS} more"), file=sys.stderr)
print(red(f"Validation failed: {len(errors)} error(s)"), file=sys.stderr)
sys.exit(EXIT_VALIDATION_ERRORS)
# Gather stats
categories = data.get("categories", [])
num_categories = len(categories)
num_sections = sum(len(c.get("sections", [])) for c in categories)
num_services = sum(
len(s.get("services", []))
for c in categories
for s in c.get("sections", [])
)
print(green(f"Valid! {num_categories} categories, {num_sections} sections, {num_services} services"))
sys.exit(EXIT_VALID)
if __name__ == "__main__":

14
web/.editorconfig Normal file
View file

@ -0,0 +1,14 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
[*.astro]
indent_style = tab
[*.{ts,js,svelte,scss,json}]
indent_style = space
indent_size = 2

2
web/.gitignore vendored
View file

@ -19,3 +19,5 @@ dist/
# macOS crap
.DS_Store
.vscode/

1
web/.nvmrc Normal file
View file

@ -0,0 +1 @@
24.11.0

17
web/.prettierignore Normal file
View file

@ -0,0 +1,17 @@
dist/
.astro/
node_modules/
.vercel/
yarn.lock
public/
*.md
# Astro files with adjacent JSX elements that prettier-plugin-astro cannot parse
src/components/things/DockerDetailedInfo.astro
src/components/things/GitHubDetailedInfo.astro
src/components/things/IosAppDetailedInfo.astro
src/components/things/ItemGitHubMetrics.astro
src/components/things/PrivacyPolicyDetails.astro
src/components/things/WebsiteDetailedInfo.astro
src/pages/*section*.astro
src/pages/all.astro

22
web/.prettierrc Normal file
View file

@ -0,0 +1,22 @@
{
"useTabs": true,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"plugins": ["prettier-plugin-astro", "prettier-plugin-svelte"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
},
{
"files": ["*.ts", "*.js", "*.svelte", "*.scss"],
"options": {
"useTabs": false,
"tabWidth": 2
}
}
]
}

View file

@ -1,4 +0,0 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

View file

@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View file

@ -1,9 +1,9 @@
npmScopes:
fortawesome:
npmAlwaysAuth: true
npmRegistryServer: "https://npm.fontawesome.com/"
npmRegistryServer: 'https://npm.fontawesome.com/'
npmAuthToken: ECB95473-FBAF-463F-905C-C9ED4C00D519
awesome:
npmAlwaysAuth: true
npmRegistryServer: "https://npm.fontawesome.com/"
npmRegistryServer: 'https://npm.fontawesome.com/'
npmAuthToken: ECB95473-FBAF-463F-905C-C9ED4C00D519

View file

@ -4,6 +4,7 @@ import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte';
import partytown from '@astrojs/partytown';
import sitemap from '@astrojs/sitemap';
import { printSummary } from './src/utils/logger.ts';
// Adapters
import vercelAdapter from '@astrojs/vercel/serverless';
@ -21,17 +22,36 @@ const output = import.meta.env.OUTPUT || 'hybrid';
const site = import.meta.env.SITE_URL || 'https://awesome-privacy.xyz';
// Initialize Astro integrations
const integrations = [svelte(), partytown(), sitemap()];
const buildLogger = {
name: 'build-logger',
hooks: {
'astro:build:done': () => printSummary(),
},
};
const integrations = [svelte(), partytown(), sitemap(), buildLogger];
// Set the appropriate adapter, based on the deploy target
const adapter = {
vercel: vercelAdapter,
netlify: netlifyAdapter,
cloudflare: cloudflareAdapter,
node: nodeAdapter({
mode: 'standalone',
}),
vercel: vercelAdapter,
netlify: netlifyAdapter,
cloudflare: cloudflareAdapter,
node: nodeAdapter({
mode: 'standalone',
}),
}[deployTarget]();
// Export Astro configuration
export default defineConfig({ output, integrations, site, adapter });
export default defineConfig({
output,
integrations,
site,
adapter,
vite: {
css: {
preprocessorOptions: {
scss: { api: 'modern' },
},
},
},
});

80
web/eslint.config.js Normal file
View file

@ -0,0 +1,80 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintPluginAstro from 'eslint-plugin-astro';
import eslintPluginSvelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import eslintConfigPrettier from 'eslint-config-prettier';
import globals from 'globals';
export default [
// Global ignores
{ ignores: ['dist/', '.astro/', 'node_modules/', '.vercel/'] },
// Base JS config
js.configs.recommended,
// TypeScript
...tseslint.configs.recommended,
// Astro
...eslintPluginAstro.configs.recommended,
// Svelte — with TypeScript parser for <script lang="ts">
...eslintPluginSvelte.configs['flat/recommended'].map((config) =>
config.files
? {
...config,
languageOptions: {
...config.languageOptions,
parser: svelteParser,
parserOptions: {
...config.languageOptions?.parserOptions,
parser: tseslint.parser,
},
},
}
: config,
),
// Global settings
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-unused-expressions': 'off',
'no-console': 'off',
'no-case-declarations': 'off',
'no-useless-assignment': 'warn',
},
},
// Allow triple-slash references in env.d.ts (Astro convention)
{
files: ['src/env.d.ts'],
rules: {
'@typescript-eslint/triple-slash-reference': 'off',
},
},
// Lenient Svelte rules — existing code uses these patterns intentionally
{
files: ['**/*.svelte'],
rules: {
'svelte/no-at-html-tags': 'warn',
'svelte/require-each-key': 'warn',
'svelte/no-dom-manipulating': 'warn',
},
},
// Prettier must be last to override conflicting rules
eslintConfigPrettier,
];

View file

@ -1,37 +1,70 @@
{
"name": "web",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro preview",
"build": "astro check && astro build",
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.5.4",
"@astrojs/netlify": "^5.1.2",
"@astrojs/partytown": "^2.0.4",
"@astrojs/sitemap": "^3.1.0",
"@astrojs/svelte": "^5.0.3",
"@astrojs/vercel": "^7.3.2",
"@fortawesome/fontawesome-pro": "^6.5.1",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
"@fortawesome/svelte-fontawesome": "^0.2.2",
"astro": "^4.3.6",
"fuse.js": "^7.0.0",
"js-yaml": "^4.1.0",
"marked": "^12.0.0",
"svelte": "^4.2.11",
"typescript": "^5.3.3"
},
"devDependencies": {
"@astrojs/cloudflare": "^9.0.1",
"@astrojs/node": "^8.2.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.19",
"sass": "^1.70.0"
}
"name": "web",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro preview",
"build": "astro check && astro build",
"astro": "astro",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "astro check",
"test": "vitest run",
"test:watch": "vitest",
"check:all": "astro check && eslint . && prettier --check . && vitest run"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/netlify": "^5.5.4",
"@astrojs/partytown": "^2.1.4",
"@astrojs/sitemap": "3.5.1",
"@astrojs/svelte": "^5.7.3",
"@astrojs/vercel": "^7.8.2",
"@fortawesome/fontawesome-pro": "^6.5.1",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
"@fortawesome/svelte-fontawesome": "^0.2.2",
"astro": "^4.16.19",
"fuse.js": "^7.0.0",
"js-yaml": "^4.1.1",
"marked": "^12.0.2",
"svelte": "^4.2.19",
"typescript": "^5.3.3"
},
"devDependencies": {
"@astrojs/cloudflare": "^11.2.0",
"@astrojs/node": "^8.3.4",
"@eslint/js": "^10.0.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.19",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-astro": "^1.6.0",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^17.3.0",
"prettier": "^3.8.1",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-svelte": "^3.5.0",
"sass": "^1.97.3",
"svelte-eslint-parser": "^1.5.1",
"typescript-eslint": "^8.56.1",
"vitest": "^4.0.18"
},
"resolutions": {
"braces": ">=3.0.3",
"micromatch": ">=4.0.8",
"minimatch": ">=3.1.3",
"prismjs": ">=1.30.0",
"mdast-util-to-hast": ">=13.2.1",
"dset": ">=3.1.4",
"esbuild": ">=0.25.0",
"undici": ">=6.23.0",
"lodash": ">=4.17.23",
"**/js-yaml": ">=4.1.1",
"brace-expansion": ">=2.0.1"
}
}

View file

@ -21,19 +21,18 @@ const { href, title, body } = Astro.props;
</li>
<style lang="scss">
.link-card {
color: var(--foreground);
background: var(--background);
border: 2px solid var(--box-outline);
box-shadow: 6px 6px 0 var(--box-outline);
font-family: "Lekton", sans-serif;
font-weight: 700;
color: var(--foreground);
background: var(--background);
border: 2px solid var(--box-outline);
box-shadow: 6px 6px 0 var(--box-outline);
font-family: 'Lekton', sans-serif;
font-weight: 700;
transition: all ease-in-out 0.1s;
list-style: none;
&:hover {
box-shadow: 8px 8px 0 var(--box-outline);
background: var(--accent);
color: var(--background);
}
a {
box-sizing: border-box;
@ -45,5 +44,4 @@ const { href, title, body } = Astro.props;
margin: 0;
}
}
</style>

View file

@ -1,61 +1,60 @@
---
import FontAwesome from "@components/form/FontAwesome.svelte"
import ThemeSwitcher from "@components/form/ThemeSwitcher.svelte"
import FontAwesome from '@components/form/FontAwesome.svelte';
import ThemeSwitcher from '@components/form/ThemeSwitcher.svelte';
---
<div class="theme-switcher">
<ThemeSwitcher client:load />
<ThemeSwitcher client:load />
</div>
<div class="hero">
<h1>Awesome Privacy</h1>
<p class="intro">
Your guide to finding and comparing privacy-respecting alternatives to popular software and services.
</p>
<div class="github-link-wrap">
<a href="https://github.com/lissy93/awesome-privacy">
<FontAwesome iconName="github" />
View on GitHub
</a>
</div>
<h1>Awesome Privacy</h1>
<p class="intro">
Your guide to finding and comparing privacy-respecting alternatives to
popular software and services.
</p>
<div class="github-link-wrap">
<a href="https://github.com/lissy93/awesome-privacy">
<FontAwesome iconName="github" />
View on GitHub
</a>
</div>
</div>
<nav class="top-right">
<ul>
<li>
<a href="/">Home</a>
</li>
<li>
<a href="/search">Search</a>
</li>
<li>
<a href="/browse">Browse</a>
</li>
<li>
<a href="/about">About</a>
</li>
<li>
<a href="https://github.com/lissy93/awesome-privacy">Source</a>
</li>
<li>
<a href="https://as93.net">More Apps</a>
</li>
</ul>
<ul>
<li>
<a href="/">Home</a>
</li>
<li>
<a href="/search">Search</a>
</li>
<li>
<a href="/browse">Browse</a>
</li>
<li>
<a href="/about">About</a>
</li>
<li>
<a href="https://github.com/lissy93/awesome-privacy">Source</a>
</li>
<li>
<a href="https://as93.net">More Apps</a>
</li>
</ul>
</nav>
<style lang="scss">
.hero {
color: var(--accent-fg);
border-radius: var(--curve-sm);
padding: 2rem 4rem;
display: flex;
flex-direction: column;
gap: 2rem;
@media(max-width: 768px) {
padding: 2rem 1rem;
}
color: var(--accent-fg);
border-radius: var(--curve-sm);
padding: 2rem 4rem;
display: flex;
flex-direction: column;
gap: 2rem;
@media (max-width: 768px) {
padding: 2rem 1rem;
}
}
svg {
position: absolute;
@ -65,98 +64,97 @@ import ThemeSwitcher from "@components/form/ThemeSwitcher.svelte"
width: 220px;
height: auto;
opacity: 0.6;
display: none;
display: none;
}
h1 {
margin: 0;
margin: 0;
font-size: 5rem;
font-weight: 700;
line-height: 1;
text-align: center;
font-family: 'Libre Franklin', sans-serif;
color: var(--accent-3);
-webkit-text-fill-color: var(--accent-3);
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: var(--box-outline);
text-shadow: 3px 3px 0 var(--box-outline);
@media(max-width: 768px) {
font-size: 4rem;
}
font-family: 'Libre Franklin', sans-serif;
color: var(--accent-3);
-webkit-text-fill-color: var(--accent-3);
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: var(--box-outline);
text-shadow: 3px 3px 0 var(--box-outline);
@media (max-width: 768px) {
font-size: 4rem;
}
}
.intro {
text-align: center;
font-size: 1.6rem;
padding: 0.5rem 1rem;
color: var(--accent-fg);
background: var(--accent-3);
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
box-shadow: 6px 6px 0 var(--box-outline);
font-family: 'Lekton', sans-serif;
font-weight: 700;
max-width: 735px;
margin: 0 auto;
}
.github-link-wrap {
font-family: 'Lekton', sans-serif;
max-width: 735px;
text-align: center;
margin: 0 auto;
border: 1px solid var(--box-outline);
box-shadow: 3px 3px 0 var(--box-outline);
background: var(--accent);
border-radius: 18px;
padding: 0.5rem 1rem;
a {
text-decoration: none;
color: var(--accent-fg);
font-size: 1.2rem;
font-family: 'Lekton', sans-serif;
font-weight: bold;
display: flex;
align-items: center;
:global(svg) {
width: 1.5rem;
height: 1.5rem;
color: var(--accent-fg);
margin-right: 0.5rem;
}
}
}
.intro {
text-align: center;
font-size: 1.6rem;
padding: 0.5rem 1rem;
color: var(--accent-fg);
background: var(--accent-3);
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
box-shadow: 6px 6px 0 var(--box-outline);
font-family: "Lekton", sans-serif;
font-weight: 700;
max-width: 735px;
margin: 0 auto;
}
.github-link-wrap {
font-family: "Lekton", sans-serif;
max-width: 735px;
text-align: center;
margin: 0 auto;
border: 1px solid var(--box-outline);
box-shadow: 3px 3px 0 var(--box-outline);
background: var(--accent);
border-radius: 18px;
padding: 0.5rem 1rem;
a {
text-decoration: none;
color: var(--accent-fg);;
font-size: 1.2rem;
font-family: "Lekton", sans-serif;
font-weight: bold;
display: flex;
align-items: center;
:global(svg) {
width: 1.5rem;
height: 1.5rem;
color: var(--accent-fg);
margin-right: 0.5rem;
}
}
}
.theme-switcher {
position: absolute;
right: 1rem;
top: 1rem;
}
.theme-switcher {
position: absolute;
right: 1rem;
top: 1rem;
}
.top-right {
position: absolute;
top: 0;
right: 1rem;
opacity: 0.8;
display: none;
&:hover {
opacity: 1;
}
ul {
list-style: none;
display: flex;
padding: 0;
gap: 0.5rem;
li {
&:not(:last-child) {
border-right: 1px solid var(--accent);
padding-right: 0.5rem;
}
}
li a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
.top-right {
position: absolute;
top: 0;
right: 1rem;
opacity: 0.8;
display: none;
&:hover {
opacity: 1;
}
ul {
list-style: none;
display: flex;
padding: 0;
gap: 0.5rem;
li {
&:not(:last-child) {
border-right: 1px solid var(--accent);
padding-right: 0.5rem;
}
}
li a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
</style>

View file

@ -1,37 +1,30 @@
---
const {
text,
url,
className,
title,
} = Astro.props;
const { text, url, className, title } = Astro.props;
---
<div class={`button ${className || ''}`} title={title}>
<a href={url}>{text}<slot /></a>
<a href={url}>{text}<slot /></a>
</div>
<style lang="scss">
.button {
font-family: "Lekton", sans-serif;
text-align: center;
border: 1px solid var(--box-outline);
box-shadow: 3px 3px 0 var(--box-outline);
background: var(--accent);
border-radius: 18px;
padding: 0.5rem 1rem;
transition: all 0.2s ease-in-out;
&:hover {
box-shadow: 4px 4px 0 var(--box-outline);
}
a {
text-decoration: none;
color: var(--accent-fg);;
font-size: 1.2rem;
font-family: "Lekton", sans-serif;
font-weight: bold;
}
}
.button {
font-family: 'Lekton', sans-serif;
text-align: center;
border: 1px solid var(--box-outline);
box-shadow: 3px 3px 0 var(--box-outline);
background: var(--accent);
border-radius: 18px;
padding: 0.5rem 1rem;
transition: all 0.2s ease-in-out;
&:hover {
box-shadow: 4px 4px 0 var(--box-outline);
}
a {
text-decoration: none;
color: var(--accent-fg);
font-size: 1.2rem;
font-family: 'Lekton', sans-serif;
font-weight: bold;
}
}
</style>

View file

@ -41,25 +41,27 @@
}
</script>
<svelte:window on:click={handleClickOutside}/>
<svelte:window on:click={handleClickOutside} />
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div>
<h2
contenteditable={true}
class:editable={editing}
on:click={() => editing = true}
on:keydown={handleKeydown}
on:blur={() => saveTitle(title)}
tabindex="0"
>{title}</h2>
<h2
contenteditable={true}
class:editable={editing}
on:click={() => (editing = true)}
on:keydown={handleKeydown}
on:blur={() => saveTitle(title)}
tabindex="0"
>
{title}
</h2>
<small>Click the title, to edit your inventory name</small>
<small>Click the title, to edit your inventory name</small>
</div>
<style>
h2 {
font-family: "Lekton", sans-serif;
font-family: 'Lekton', sans-serif;
font-weight: bold;
font-size: 3rem;
margin: 0;

View file

@ -4,7 +4,6 @@
import * as brands from '@fortawesome/free-brands-svg-icons';
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
export const iconMap: Record<string, IconDefinition> = {
// Branding
logo: solidIcons.faEyeSlash,
@ -83,13 +82,8 @@
};
export let iconName: string;
</script>
{#if iconMap[iconName]}
<FontAwesomeIcon
class="fa-icon"
icon={iconMap[iconName]} />
<FontAwesomeIcon class="fa-icon" icon={iconMap[iconName]} />
{/if}

View file

@ -1,73 +1,84 @@
---
interface IconProps {
icon: string;
color?: string;
class?: string;
width?: number;
height?: number;
icon: string;
color?: string;
class?: string;
width?: number;
height?: number;
}
const getSvgPath = (icon: string) => {
switch (icon) {
case 'star':
return {
vb: "0 0 24 24",
path: "M10 15l-5.5 3 1-5.5L0 7.5l5.6-0.5L10 2l2 5 5.5 0.5-4 4 1 5.5z",
};
case 'mastodon':
return {
vb: "0 0 512 512",
path: "M433 179.1c0-97.2-63.7-125.7-63.7-125.7-62.5-28.7-228.6-28.4-290.5 0 0 0-63.7 28.5-63.7 125.7 0 115.7-6.6 259.4 105.6 289.1 40.5 10.7 75.3 13 103.3 11.4 50.8-2.8 79.3-18.1 79.3-18.1l-1.7-36.9s-36.3 11.4-77.1 10.1c-40.4-1.4-83-4.4-89.6-54a102.5 102.5 0 0 1 -.9-13.9c85.6 20.9 158.7 9.1 178.8 6.7 56.1-6.7 105-41.3 111.2-72.9 9.8-49.8 9-121.5 9-121.5zm-75.1 125.2h-46.6v-114.2c0-49.7-64-51.6-64 6.9v62.5h-46.3V197c0-58.5-64-56.6-64-6.9v114.2H90.2c0-122.1-5.2-147.9 18.4-175 25.9-28.9 79.8-30.8 103.8 6.1l11.6 19.5 11.6-19.5c24.1-37.1 78.1-34.8 103.8-6.1 23.7 27.3 18.4 53 18.4 175z",
};
case 'twitter':
return {
vb: "0 0 512 512",
path: "M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z",
};
case 'hub':
return {
vb: "0 0 512 512",
path: "M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z",
};
case 'dev':
return {
vb: "0 0 512 512",
path: "M120.1 208.3c-3.9-2.9-7.8-4.4-11.7-4.4H91v104.5h17.5c3.9 0 7.8-1.5 11.7-4.4 3.9-2.9 5.8-7.3 5.8-13.1v-69.7c0-5.8-2-10.2-5.8-13.1zM404.1 32H43.9C19.7 32 .1 51.6 0 75.8v360.4C.1 460.4 19.7 480 43.9 480h360.2c24.2 0 43.8-19.6 43.9-43.8V75.8c-.1-24.2-19.7-43.8-43.9-43.8zM154.2 291.2c0 18.8-11.6 47.3-48.4 47.3h-46.4V173h47.4c35.4 0 47.4 28.5 47.4 47.3l0 70.9zm100.7-88.7H201.6v38.4h32.6v29.6H201.6v38.4h53.3v29.6h-62.2c-11.2 .3-20.4-8.5-20.7-19.7V193.7c-.3-11.2 8.6-20.4 19.7-20.7h63.2l0 29.5zm103.6 115.3c-13.2 30.8-36.9 24.6-47.4 0l-38.5-144.8h32.6l29.7 113.7 29.6-113.7h32.6l-38.5 144.8z",
};
case 'linkedin':
return {
vb: "0 0 512 512",
path: "M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z",
};
case 'essentials':
return {
vb: "0 0 512 512",
path: "M208 176c0-70.7 57.3-128 128-128s128 57.3 128 128s-57.3 128-128 128c-10.4 0-20.5-1.2-30.1-3.6c-8.1-2-16.7 .4-22.6 6.4L254.1 336H200c-13.3 0-24 10.7-24 24v40H136c-13.3 0-24 10.7-24 24v40H48V385.9L205.2 228.7c5.9-5.9 8.3-14.5 6.4-22.6c-2.3-9.6-3.6-19.7-3.6-30.1zM336 0C238.8 0 160 78.8 160 176c0 9.5 .7 18.8 2.2 27.9L7 359c-4.5 4.5-7 10.6-7 17V488c0 13.3 10.7 24 24 24H136c13.3 0 24-10.7 24-24V448h40c13.3 0 24-10.7 24-24V384h40c6.4 0 12.5-2.5 17-7l27.2-27.2c9.1 1.4 18.4 2.2 27.9 2.2c97.2 0 176-78.8 176-176S433.2 0 336 0zm32 176a32 32 0 1 0 0-64 32 32 0 1 0 0 64z",
};
case 'communication':
return {
vb: "0 0 640 512",
path: "M48 72c0-13.3 10.7-24 24-24H344c13.3 0 24 10.7 24 24V248c0 13.3-10.7 24-24 24H216c-4.7 0-9.4 1.4-13.3 4L144 315.2V296c0-13.3-10.7-24-24-24H72c-13.3 0-24-10.7-24-24V72zM72 0C32.2 0 0 32.2 0 72V248c0 39.8 32.2 72 72 72H96v40c0 8.9 4.9 17 12.7 21.2s17.3 3.7 24.6-1.2l90-60H344c39.8 0 72-32.2 72-72V72c0-39.8-32.2-72-72-72H72zM256 376c0 39.8 32.2 72 72 72h88.7l90 60c7.4 4.9 16.8 5.4 24.6 1.2S544 496.9 544 488V448h24c39.8 0 72-32.2 72-72V200c0-39.8-32.2-72-72-72H448v48H568c13.3 0 24 10.7 24 24V376c0 13.3-10.7 24-24 24H520c-13.3 0-24 10.7-24 24v19.2L437.3 404c-3.9-2.6-8.6-4-13.3-4H328c-13.3 0-24-10.7-24-24V352H256v24z",
};
case 'security-tools':
return {
vp: "0 0 512 512",
path: "M232 60.8V447.4c-66.9-37.8-108.8-94.3-134.1-152.6C71 232.9 63.1 169.5 64.1 126L232 60.8zm48 386.5V60.8L448 126c1 43.5-6.9 106.9-33.8 168.8C388.8 353.1 346.9 409.5 280 447.3zM495.5 113l-1.2-20.5L475.1 85 267.6 4.5 256 0 244.4 4.5 36.9 85 17.8 92.5 16.6 113c-2.9 49.9 4.9 126.3 37.3 200.9c32.7 75.2 91 150 189.4 192.6L256 512l12.7-5.5c98.4-42.6 156.7-117.3 189.4-192.6c32.4-74.7 40.2-151 37.3-200.9z"
};
// Add more icons as needed...
default:
return { vb: "", path: "" }; // Default path or a placeholder icon
}
switch (icon) {
case 'star':
return {
vb: '0 0 24 24',
path: 'M10 15l-5.5 3 1-5.5L0 7.5l5.6-0.5L10 2l2 5 5.5 0.5-4 4 1 5.5z',
};
case 'mastodon':
return {
vb: '0 0 512 512',
path: 'M433 179.1c0-97.2-63.7-125.7-63.7-125.7-62.5-28.7-228.6-28.4-290.5 0 0 0-63.7 28.5-63.7 125.7 0 115.7-6.6 259.4 105.6 289.1 40.5 10.7 75.3 13 103.3 11.4 50.8-2.8 79.3-18.1 79.3-18.1l-1.7-36.9s-36.3 11.4-77.1 10.1c-40.4-1.4-83-4.4-89.6-54a102.5 102.5 0 0 1 -.9-13.9c85.6 20.9 158.7 9.1 178.8 6.7 56.1-6.7 105-41.3 111.2-72.9 9.8-49.8 9-121.5 9-121.5zm-75.1 125.2h-46.6v-114.2c0-49.7-64-51.6-64 6.9v62.5h-46.3V197c0-58.5-64-56.6-64-6.9v114.2H90.2c0-122.1-5.2-147.9 18.4-175 25.9-28.9 79.8-30.8 103.8 6.1l11.6 19.5 11.6-19.5c24.1-37.1 78.1-34.8 103.8-6.1 23.7 27.3 18.4 53 18.4 175z',
};
case 'twitter':
return {
vb: '0 0 512 512',
path: 'M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z',
};
case 'hub':
return {
vb: '0 0 512 512',
path: 'M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z',
};
case 'dev':
return {
vb: '0 0 512 512',
path: 'M120.1 208.3c-3.9-2.9-7.8-4.4-11.7-4.4H91v104.5h17.5c3.9 0 7.8-1.5 11.7-4.4 3.9-2.9 5.8-7.3 5.8-13.1v-69.7c0-5.8-2-10.2-5.8-13.1zM404.1 32H43.9C19.7 32 .1 51.6 0 75.8v360.4C.1 460.4 19.7 480 43.9 480h360.2c24.2 0 43.8-19.6 43.9-43.8V75.8c-.1-24.2-19.7-43.8-43.9-43.8zM154.2 291.2c0 18.8-11.6 47.3-48.4 47.3h-46.4V173h47.4c35.4 0 47.4 28.5 47.4 47.3l0 70.9zm100.7-88.7H201.6v38.4h32.6v29.6H201.6v38.4h53.3v29.6h-62.2c-11.2 .3-20.4-8.5-20.7-19.7V193.7c-.3-11.2 8.6-20.4 19.7-20.7h63.2l0 29.5zm103.6 115.3c-13.2 30.8-36.9 24.6-47.4 0l-38.5-144.8h32.6l29.7 113.7 29.6-113.7h32.6l-38.5 144.8z',
};
case 'linkedin':
return {
vb: '0 0 512 512',
path: 'M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z',
};
case 'essentials':
return {
vb: '0 0 512 512',
path: 'M208 176c0-70.7 57.3-128 128-128s128 57.3 128 128s-57.3 128-128 128c-10.4 0-20.5-1.2-30.1-3.6c-8.1-2-16.7 .4-22.6 6.4L254.1 336H200c-13.3 0-24 10.7-24 24v40H136c-13.3 0-24 10.7-24 24v40H48V385.9L205.2 228.7c5.9-5.9 8.3-14.5 6.4-22.6c-2.3-9.6-3.6-19.7-3.6-30.1zM336 0C238.8 0 160 78.8 160 176c0 9.5 .7 18.8 2.2 27.9L7 359c-4.5 4.5-7 10.6-7 17V488c0 13.3 10.7 24 24 24H136c13.3 0 24-10.7 24-24V448h40c13.3 0 24-10.7 24-24V384h40c6.4 0 12.5-2.5 17-7l27.2-27.2c9.1 1.4 18.4 2.2 27.9 2.2c97.2 0 176-78.8 176-176S433.2 0 336 0zm32 176a32 32 0 1 0 0-64 32 32 0 1 0 0 64z',
};
case 'communication':
return {
vb: '0 0 640 512',
path: 'M48 72c0-13.3 10.7-24 24-24H344c13.3 0 24 10.7 24 24V248c0 13.3-10.7 24-24 24H216c-4.7 0-9.4 1.4-13.3 4L144 315.2V296c0-13.3-10.7-24-24-24H72c-13.3 0-24-10.7-24-24V72zM72 0C32.2 0 0 32.2 0 72V248c0 39.8 32.2 72 72 72H96v40c0 8.9 4.9 17 12.7 21.2s17.3 3.7 24.6-1.2l90-60H344c39.8 0 72-32.2 72-72V72c0-39.8-32.2-72-72-72H72zM256 376c0 39.8 32.2 72 72 72h88.7l90 60c7.4 4.9 16.8 5.4 24.6 1.2S544 496.9 544 488V448h24c39.8 0 72-32.2 72-72V200c0-39.8-32.2-72-72-72H448v48H568c13.3 0 24 10.7 24 24V376c0 13.3-10.7 24-24 24H520c-13.3 0-24 10.7-24 24v19.2L437.3 404c-3.9-2.6-8.6-4-13.3-4H328c-13.3 0-24-10.7-24-24V352H256v24z',
};
case 'security-tools':
return {
vp: '0 0 512 512',
path: 'M232 60.8V447.4c-66.9-37.8-108.8-94.3-134.1-152.6C71 232.9 63.1 169.5 64.1 126L232 60.8zm48 386.5V60.8L448 126c1 43.5-6.9 106.9-33.8 168.8C388.8 353.1 346.9 409.5 280 447.3zM495.5 113l-1.2-20.5L475.1 85 267.6 4.5 256 0 244.4 4.5 36.9 85 17.8 92.5 16.6 113c-2.9 49.9 4.9 126.3 37.3 200.9c32.7 75.2 91 150 189.4 192.6L256 512l12.7-5.5c98.4-42.6 156.7-117.3 189.4-192.6c32.4-74.7 40.2-151 37.3-200.9z',
};
// Add more icons as needed...
default:
return { vb: '', path: '' }; // Default path or a placeholder icon
}
};
// Props are defined in the component's signature
const { icon, color = 'currentcolor', class: className = '', width = 80, height = 50 } = Astro.props as IconProps;
const {
icon,
color = 'currentcolor',
class: className = '',
width = 80,
height = 50,
} = Astro.props as IconProps;
const svgStyle = { fill: color };
const { vb, path } = getSvgPath(icon);
---
<svg class={className} style={svgStyle} xmlns="http://www.w3.org/2000/svg" viewBox={vb} width={width} height={height ?? width}>
<path d={path} />
<svg
class={className}
style={svgStyle}
xmlns="http://www.w3.org/2000/svg"
viewBox={vb}
width={width}
height={height ?? width}
>
<path d={path}></path>
</svg>

View file

@ -1,12 +1,15 @@
---
import Icon from '@components/form/FontAwesome.svelte';
import { site, title as defaultTitle, description as defaultDescription } from '@utils/config';
import {
site,
title as defaultTitle,
description as defaultDescription,
} from '@utils/config';
interface Props {
url?: string;
title?: string;
description?: string;
url?: string;
title?: string;
description?: string;
}
const url = Astro.props.url || site;
@ -18,109 +21,114 @@ const encodedTitle = encodeURIComponent(title);
const encodedDescription = encodeURIComponent(description);
const socialMedias = {
mastodon: {
url: `https://mastodon.social/share?text=${encodeURIComponent(`${title} ${description}`)}&url=${encodedUrl}`,
title: 'Mastodon',
icon: 'mastodon',
color: '#6364FF',
},
twitter: {
url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(`${title} ${description}`)}&url=${url}`,
title: 'Twitter',
icon: 'twitter',
color: '#444343'
},
reddit: {
url: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
title: 'Reddit',
icon: 'reddit',
color: '#FF4500',
},
linkedIn: {
url: `https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}&summary=${encodedDescription}`,
title: 'LinkedIn',
icon: 'linkedin',
color: '#0A66C2'
},
pinterest: {
url: `https://pinterest.com/pin/create/button/?url=${encodedUrl}&description=${encodedTitle}`,
title: 'Pinterest',
icon: 'pinterest',
color: '#BD081C',
},
telegram: {
url: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent(`${title} ${description}`)}`,
title: 'Telegram',
icon: 'telegram',
color: '#26A5E4',
},
whatsapp: {
url: `https://wa.me/?text=${encodedTitle} ${encodedUrl}`,
title: 'WhatsApp',
icon: 'whatsapp',
color: '#25D366',
},
signal: {
url: `https://signal.me/#p/+${encodedUrl}`,
title: 'Signal',
icon: 'signal',
color: '#3A76F0',
},
pocket: {
url: `https://getpocket.com/save?url=${encodedUrl}&title=${encodedTitle}`,
title: 'Pocket',
icon: 'pocket',
color: '#EF3F56',
},
mastodon: {
url: `https://mastodon.social/share?text=${encodeURIComponent(`${title} ${description}`)}&url=${encodedUrl}`,
title: 'Mastodon',
icon: 'mastodon',
color: '#6364FF',
},
twitter: {
url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(`${title} ${description}`)}&url=${url}`,
title: 'Twitter',
icon: 'twitter',
color: '#444343',
},
reddit: {
url: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
title: 'Reddit',
icon: 'reddit',
color: '#FF4500',
},
linkedIn: {
url: `https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}&summary=${encodedDescription}`,
title: 'LinkedIn',
icon: 'linkedin',
color: '#0A66C2',
},
pinterest: {
url: `https://pinterest.com/pin/create/button/?url=${encodedUrl}&description=${encodedTitle}`,
title: 'Pinterest',
icon: 'pinterest',
color: '#BD081C',
},
telegram: {
url: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent(`${title} ${description}`)}`,
title: 'Telegram',
icon: 'telegram',
color: '#26A5E4',
},
whatsapp: {
url: `https://wa.me/?text=${encodedTitle} ${encodedUrl}`,
title: 'WhatsApp',
icon: 'whatsapp',
color: '#25D366',
},
signal: {
url: `https://signal.me/#p/+${encodedUrl}`,
title: 'Signal',
icon: 'signal',
color: '#3A76F0',
},
pocket: {
url: `https://getpocket.com/save?url=${encodedUrl}&title=${encodedTitle}`,
title: 'Pocket',
icon: 'pocket',
color: '#EF3F56',
},
};
---
<ul class="social-share">
{Object.entries(socialMedias).map(([platform, shareUrl]) => (
<li style={`--color: ${shareUrl.color}`}>
<a title={`Share on ${platform}`} href={shareUrl.url} target="_blank" rel="noopener noreferrer">
<Icon iconName={shareUrl.icon} />
</a>
</li>
))}
{
Object.entries(socialMedias).map(([platform, shareUrl]) => (
<li style={`--color: ${shareUrl.color}`}>
<a
title={`Share on ${platform}`}
href={shareUrl.url}
target="_blank"
rel="noopener noreferrer"
>
<Icon iconName={shareUrl.icon} />
</a>
</li>
))
}
</ul>
<style lang="scss">
.social-share {
list-style: none;
padding: 0;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
li {
padding: 0;
margin: 0;
opacity: 0.8;
border: 1px solid var(--box-outline);
box-shadow: 2px 2px 0 var(--box-outline);
border-radius: var(--curve-sm);
transition: all 0.2s ease-in-out;
background: var(--background-form);
a {
display: flex;
color: var(--foreground);
transition: all 0.2s ease-in-out;
padding: 4px;
:global(svg) {
width: 2rem;
height: 2rem;
}
}
&:hover {
box-shadow: 3px 3px 0 var(--box-outline);
border-radius: var(--curve-md);
opacity: 1;
a {
color: var(--color);
}
}
}
}
.social-share {
list-style: none;
padding: 0;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
li {
padding: 0;
margin: 0;
opacity: 0.8;
border: 1px solid var(--box-outline);
box-shadow: 2px 2px 0 var(--box-outline);
border-radius: var(--curve-sm);
transition: all 0.2s ease-in-out;
background: var(--background-form);
a {
display: flex;
color: var(--foreground);
transition: all 0.2s ease-in-out;
padding: 4px;
:global(svg) {
width: 2rem;
height: 2rem;
}
}
&:hover {
box-shadow: 3px 3px 0 var(--box-outline);
border-radius: var(--curve-md);
opacity: 1;
a {
color: var(--color);
}
}
}
}
</style>

View file

@ -28,7 +28,6 @@
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="theme-switcher" on:click={toggleTheme}>
@ -38,7 +37,6 @@
</div>
</div>
<style lang="scss">
.theme-switcher {
cursor: pointer;
@ -52,7 +50,7 @@
transition: background-color 0.3s ease;
border: 2px solid var(--box-outline);
box-shadow: 3px 3px 0 var(--box-outline);
&:hover {
background-color: rgba(255, 255, 255, 0.3);
}
@ -96,5 +94,4 @@
display: flex;
font-size: 1.5rem;
}
</style>

View file

@ -1,23 +1,29 @@
---
const year = new Date().getFullYear();
---
<footer>
<a href="/about">Awesome Privacy</a> is licensed
under <a href="https://github.com/Lissy93/awesome-privacy/blob/main/LICENSE">MIT</a>
© <a href="https://aliciasykes.com">Alicia Sykes</a> 2024 |
Source code available on <a href="https://github.com/Lissy93/awesome-privacy">GitHub</a>
<a href="/about">Awesome Privacy</a> is licensed under <a
href="https://github.com/Lissy93/awesome-privacy/blob/main/LICENSE"
>CC0 1.0 Universal</a
>
© <a href="https://aliciasykes.com">Alicia Sykes</a> 2018 - {
year || 'Present'
} | Source code available on <a
href="https://github.com/Lissy93/awesome-privacy">GitHub</a
>
</footer>
<style lang="scss">
footer {
font-family: "Lekton", sans-serif;
font-weight: bold;
text-align: center;
padding: 0.5rem 0;
margin: 0 auto;
a {
font-family: "Lekton", sans-serif;
color: var(--accent);
}
}
footer {
font-family: 'Lekton', sans-serif;
font-weight: bold;
text-align: center;
padding: 0.5rem 0;
margin: 0 auto;
a {
font-family: 'Lekton', sans-serif;
color: var(--accent);
}
}
</style>

View file

@ -1,7 +1,5 @@
<main>
<slot />
<slot />
</main>
<style lang="scss">
@ -11,8 +9,8 @@
width: 1200px;
max-width: calc(100% - 5rem);
border: 2px solid var(--box-outline);
box-shadow: 6px 6px 0 var(--box-outline);
box-shadow: 6px 6px 0 var(--box-outline);
background: var(--accent-fg);
position: relative;
}
}
</style>

View file

@ -1,106 +1,111 @@
---
import FontAwesome from "@components/form/FontAwesome.svelte"
import ThemeSwitcher from "@components/form/ThemeSwitcher.svelte"
import FontAwesome from '@components/form/FontAwesome.svelte';
import ThemeSwitcher from '@components/form/ThemeSwitcher.svelte';
---
<div class="nav">
<a href="/" class="homepage">
<FontAwesome iconName="logo" />
<h1>Awesome Privacy</h1>
</a>
<nav>
<a href="/browse">Browse</a>
<a href="/search">Search</a>
<a href="/about">About</a>
<a href="https://github.com/lissy93/awesome-privacy">GitHub</a>
<div class="theme-switcher">
<ThemeSwitcher client:load />
</div>
</nav>
<a href="/" class="homepage">
<FontAwesome iconName="logo" />
<h1>Awesome Privacy</h1>
</a>
<nav>
<a href="/browse">Browse</a>
<a href="/search">Search</a>
<a href="/about">About</a>
<a href="https://github.com/lissy93/awesome-privacy">GitHub</a>
<div class="theme-switcher">
<ThemeSwitcher client:load />
</div>
</nav>
</div>
<style lang="scss">
.nav {
background: var(--accent-fg);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
border-bottom: 2px solid var(--accent-3);
.homepage {
text-decoration: none;
display: flex;
align-items: center;
padding: 0 0.5rem;
h1 {
margin: 0;
font-size: 2.4rem;
padding: 0 1rem;
color: var(--foreground);
font-family: "Lekton", sans-serif;
}
:global(svg) {
width: 2.5rem;
height: 2.5rem;
color: var(--accent-3);
transition: all 0.2s ease-in-out;
}
&:hover {
:global(svg) {
color: var(--accent);
transform: scale(1.05);
}
}
}
.nav {
background: var(--accent-fg);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
border-bottom: 2px solid var(--accent-3);
nav {
display: flex;
align-items: center;
height: 3rem;
a {
padding: 1rem;
font-size: 1.2rem;
font-family: "Lekton", sans-serif;
font-weight: bold;
color: var(--foreground);
transition: background 0.3s, transform 0.3s, box-shadow 0.3s;
transition-timing-function: ease-in-out;
&:hover {
background: var(--accent-3);
color: var(--accent-fg);
border-bottom: 2px solid var(--accent-3);
&:nth-child(4n+1) {
background-color: var(--accent);
}
&:nth-child(4n+2) {
background-color: var(--accent-2);
color: var(--foreground);
}
&:nth-child(4n+3) {
background-color: var(--accent-3);
}
&:nth-child(4n+4) {
background-color: var(--accent-4);
color: var(--foreground);
}
}
}
.theme-switcher {
transform: scale(0.7);
margin: 0.2rem auto;
@media(max-width: 768px) {
display: none;
}
}
}
.homepage, nav {
@media(max-width: 768px) {
margin: 0 auto;
a { border: none; }
.homepage {
text-decoration: none;
display: flex;
align-items: center;
padding: 0 0.5rem;
h1 {
margin: 0;
font-size: 2.4rem;
padding: 0 1rem;
color: var(--foreground);
font-family: 'Lekton', sans-serif;
}
}
}
:global(svg) {
width: 2.5rem;
height: 2.5rem;
color: var(--accent-3);
transition: all 0.2s ease-in-out;
}
&:hover {
:global(svg) {
color: var(--accent);
transform: scale(1.05);
}
}
}
nav {
display: flex;
align-items: center;
height: 3rem;
a {
padding: 1rem;
font-size: 1.2rem;
font-family: 'Lekton', sans-serif;
font-weight: bold;
color: var(--foreground);
transition:
background 0.3s,
transform 0.3s,
box-shadow 0.3s;
transition-timing-function: ease-in-out;
&:hover {
background: var(--accent-3);
color: var(--accent-fg);
border-bottom: 2px solid var(--accent-3);
&:nth-child(4n + 1) {
background-color: var(--accent);
}
&:nth-child(4n + 2) {
background-color: var(--accent-2);
color: var(--foreground);
}
&:nth-child(4n + 3) {
background-color: var(--accent-3);
}
&:nth-child(4n + 4) {
background-color: var(--accent-4);
color: var(--foreground);
}
}
}
.theme-switcher {
transform: scale(0.7);
margin: 0.2rem auto;
@media (max-width: 768px) {
display: none;
}
}
}
.homepage,
nav {
@media (max-width: 768px) {
margin: 0 auto;
a {
border: none;
}
}
}
}
</style>

View file

@ -20,33 +20,42 @@
const serviceCrypto = writable(false);
const additionalInfo = writable('');
let codeBlock: any;
let codeBlock: HTMLElement | undefined;
let interactiveActivated = false;
$: yamlText, updateHighlighting();
$: (yamlText, updateHighlighting());
/* eslint-disable svelte/no-dom-manipulating -- hljs requires direct DOM access for syntax highlighting */
function updateHighlighting() {
if (codeBlock) {
codeBlock.textContent = yamlText
codeBlock.textContent = yamlText;
codeBlock.dataset.highlighted && delete codeBlock.dataset.highlighted;
if (window && (window as any).hljs) {
(window as any).hljs.highlightElement(codeBlock);
const hljs = (
window as Window & {
hljs?: { highlightElement: (el: HTMLElement) => void };
}
).hljs;
if (hljs) {
hljs.highlightElement(codeBlock);
interactiveActivated = true;
}
}
}
/* eslint-enable svelte/no-dom-manipulating */
const filterEmptyValues = (obj: Record<string, any>) => {
const filteredObj: Record<string, any> = {};
Object.keys(obj).forEach(key => {
const filterEmptyValues = (obj: Record<string, unknown>) => {
const filteredObj: Record<string, unknown> = {};
Object.keys(obj).forEach((key) => {
if (obj[key] || ['name', 'url', 'icon', 'description'].includes(key)) {
filteredObj[key] = obj[key];
}
});
return filteredObj;
}
$: yamlText = yaml.dump([{
};
$: yamlText = yaml.dump(
[
{
name: $serviceName,
url: $serviceUrl,
icon: $serviceIcon,
@ -60,9 +69,12 @@
openSource: $serviceOpenSource,
securityAudited: $serviceSecurityAudited,
acceptsCrypto: $serviceCrypto,
}].map(obj => filterEmptyValues(obj)));
},
].map((obj) => filterEmptyValues(obj)),
);
$: issueUrl = makeAdditionRequest({
$: issueUrl = makeAdditionRequest(
{
listingCategory: $listingCategory,
serviceName: $serviceName,
serviceUrl: $serviceUrl,
@ -78,7 +90,9 @@
serviceSecurityAudited: $serviceSecurityAudited,
serviceCrypto: $serviceCrypto,
additionalInfo: $additionalInfo,
}, yamlText);
},
yamlText,
);
// Form submission handler
function handleSubmit() {
@ -87,29 +101,39 @@
</script>
<svelte:head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/an-old-hope.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/yaml.min.js"></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/an-old-hope.min.css"
/>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/yaml.min.js"
></script>
</svelte:head>
<p>
Before completing this form, you must ensure that the service you are adding aligns
with the <a href="/about#creteria">Requirements</a> for Awesome Privacy.
Before completing this form, you must ensure that the service you are adding
aligns with the <a href="/about#creteria">Requirements</a> for Awesome
Privacy.
<br />
You'll need a GitHub account in order to submit this form.
</p>
<form on:submit|preventDefault={handleSubmit}>
<h3>Basics</h3>
<p class="sub-title-description">
All fields here are required.
</p>
<p class="sub-title-description">All fields here are required.</p>
<!-- Category Dropdown -->
<div class="form-row">
<label for="listing-category">Category</label>
<select bind:value={$listingCategory} id="listing-category" required autocomplete="off">
<select
bind:value={$listingCategory}
id="listing-category"
required
autocomplete="off"
>
<option value="">--Please choose an option--</option>
<option value="Essentials">Essentials</option>
<option value="Communication">Communication</option>
@ -126,45 +150,78 @@
<option value="Creativity">Creativity</option>
</select>
<p>
Choose the top-level category, which should align with
the <a href="/browse">one of these</a>.
Choose the top-level category, which should align with the <a
href="/browse">one of these</a
>.
</p>
</div>
<!-- Listing Name -->
<div class="form-row">
<label for="service-name">Listing Name</label>
<input type="text" bind:value={$serviceName} id="service-name" required autocomplete="off">
<input
type="text"
bind:value={$serviceName}
id="service-name"
required
autocomplete="off"
/>
<p>Enter the name of the app, software or service</p>
</div>
<!-- Listing URL -->
<div class="form-row">
<label for="service-url">Listing URL</label>
<input type="url" bind:value={$serviceUrl} id="service-url" required autocomplete="off">
<p>Enter the fully-qualified domain name of the homepage for this listing</p>
<input
type="url"
bind:value={$serviceUrl}
id="service-url"
required
autocomplete="off"
/>
<p>
Enter the fully-qualified domain name of the homepage for this listing
</p>
</div>
<!-- Listing Icon -->
<div class="form-row">
<label for="service-icon">Listing Icon</label>
<input type="url" bind:value={$serviceIcon} id="service-icon" required autocomplete="off">
<p>Paste a URL to a square logo for the service. Dimensions must be no less than 64x64, and no more than 512x512 pixels</p>
<input
type="url"
bind:value={$serviceIcon}
id="service-icon"
required
autocomplete="off"
/>
<p>
Paste a URL to a square logo for the service. Dimensions must be no less
than 64x64, and no more than 512x512 pixels
</p>
</div>
<!-- Listing Description -->
<div class="form-row">
<label for="service-description">Listing Description</label>
<textarea bind:value={$serviceDescription} id="service-description" required autocomplete="off"></textarea>
<p>Please provide a description for this listing. Keep it factual and objective. Markdown is supported.</p>
<textarea
bind:value={$serviceDescription}
id="service-description"
required
autocomplete="off"
></textarea>
<p>
Please provide a description for this listing. Keep it factual and
objective. Markdown is supported.
</p>
</div>
<!-- Section 2 -->
<h3>Third-Party Referencing</h3>
<p class="sub-title-description">
In order to create a comprehensive listing, we combine the data inputted above with other sources,
to give additional context and help users make informed decisions.
Metrics from these services are fetched automatically at build-time from our API.
In order to create a comprehensive listing, we combine the data inputted
above with other sources, to give additional context and help users make
informed decisions. Metrics from these services are fetched automatically at
build-time from our API.
<br />
All fields are optional, but the more information you provide, the better!
</p>
@ -172,7 +229,13 @@
<!-- GitHub Repository -->
<div class="form-row">
<label for="service-github">GitHub Repository</label>
<input type="text" bind:value={$serviceGithub} id="service-github" required autocomplete="off">
<input
type="text"
bind:value={$serviceGithub}
id="service-github"
required
autocomplete="off"
/>
<p>
Share a link to where the project's source is located.<br />
Use the format [user]/[repo] e.g, lissy93/dashy
@ -182,18 +245,29 @@
<!-- ToS;DR ID -->
<div class="form-row">
<label for="service-tosdr-id">ToS;DR ID</label>
<input type="number" bind:value={$serviceTosdrId} id="service-tosdr-id" autocomplete="off">
<input
type="number"
bind:value={$serviceTosdrId}
id="service-tosdr-id"
autocomplete="off"
/>
<p>
Has the Privacy policy been documented by <a href="https://tosdr.org/">tosdr.org</a>?
If so, please include the report reference below (this is a 3 or 4-digit numerical ID).
Skip section if not applicable.
Has the Privacy policy been documented by <a href="https://tosdr.org/"
>tosdr.org</a
>? If so, please include the report reference below (this is a 3 or
4-digit numerical ID). Skip section if not applicable.
</p>
</div>
<!-- Apple App Store URL -->
<div class="form-row">
<label for="service-tosdr-id">iOS App</label>
<input type="url" bind:value={$serviceIosApp} id="service-ios-app" autocomplete="off">
<input
type="url"
bind:value={$serviceIosApp}
id="service-ios-app"
autocomplete="off"
/>
<p>
Paste the link to the mobile app on the Apple App Store.<br />
E.g. https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744
@ -203,7 +277,12 @@
<!-- Google Play App Store URL -->
<div class="form-row">
<label for="service-tosdr-id">Android App</label>
<input type="url" bind:value={$serviceAndroidApp} id="service-android-app" autocomplete="off">
<input
type="url"
bind:value={$serviceAndroidApp}
id="service-android-app"
autocomplete="off"
/>
<p>
Paste the link to the mobile app on the Google Play Store.<br />
E.g. https://play.google.com/store/apps/details?id=com.x8bit.bitwarden
@ -213,17 +292,28 @@
<!-- Discord Server Invite Code -->
<div class="form-row">
<label for="service-tosdr-id">Discord Invite</label>
<input type="text" bind:value={$serviceDiscordInvite} id="service-discord-invite" autocomplete="off">
<input
type="text"
bind:value={$serviceDiscordInvite}
id="service-discord-invite"
autocomplete="off"
/>
<p>
Paste the invite code to the Discord server for this service.<br />
E.g. If the invite URL is https://discord.com/invite/4JMAauFZBq the code is 4JMAauFZBq
E.g. If the invite URL is https://discord.com/invite/4JMAauFZBq the code is
4JMAauFZBq
</p>
</div>
<!-- Reddit sub name -->
<div class="form-row">
<label for="service-tosdr-id">Subreddit</label>
<input type="text" bind:value={$serviceSubreddit} id="service-subreddit" autocomplete="off">
<input
type="text"
bind:value={$serviceSubreddit}
id="service-subreddit"
autocomplete="off"
/>
<p>
If the service has a subreddit, please provide the name here.<br />
Don't include `r/` in the name, nor the full URL - just the sub name.
@ -233,70 +323,95 @@
<!-- Section 3 - Checklist and details -->
<h3>Privacy Checklist</h3>
<p class="sub-title-description">
Finally, check the boxes that apply to the service you are submitting,
and then provide any additional information to back this up in the text area below.
Finally, check the boxes that apply to the service you are submitting, and
then provide any additional information to back this up in the text area
below.
</p>
<!-- Open Source Checkbox -->
<!-- Open Source Checkbox -->
<div class="form-row">
<label for="service-open-source">Is Open Source?</label>
<input type="checkbox" bind:checked={$serviceOpenSource} id="service-open-source">
<p>Is this service fully open source? Aka, can it be compiled from source by the user, or self-hosted?</p>
<input
type="checkbox"
bind:checked={$serviceOpenSource}
id="service-open-source"
/>
<p>
Is this service fully open source? Aka, can it be compiled from source by
the user, or self-hosted?
</p>
</div>
<!-- Security Audited Checkbox -->
<div class="form-row">
<label for="service-security-audited">Security Audited?</label>
<input type="checkbox" bind:checked={$serviceSecurityAudited} id="service-security-audited">
<p>Has this service been independently security audited by an accredited auditor?</p>
<input
type="checkbox"
bind:checked={$serviceSecurityAudited}
id="service-security-audited"
/>
<p>
Has this service been independently security audited by an accredited
auditor?
</p>
</div>
<!-- Accepts Crypto Checkbox -->
<div class="form-row">
<label for="service-crypto">Accepts Anon Payment?</label>
<input type="checkbox" bind:checked={$serviceCrypto} id="service-crypto">
<p>If this is a hosted and paid for service, does it accept anonymous payment methods, including crypto (e.g., Monero)?</p>
<input type="checkbox" bind:checked={$serviceCrypto} id="service-crypto" />
<p>
If this is a hosted and paid for service, does it accept anonymous payment
methods, including crypto (e.g., Monero)?
</p>
</div>
<div class="final-info">
<p>
Finally, please provide any supporting material, including:
</p>
<p>Finally, please provide any supporting material, including:</p>
<ul>
<li>
A justification of why this app/service should be included in the list
</li>
<li>Links to any published security audit, if they exist</li>
<li>
Links to any published security audit, if they exist
Links to the services privacy policy, terms of service and other
relevant documents where applicable
</li>
<li>
Links to the services privacy policy, terms of service and other relevant
documents where applicable
Your affiliation with the service. For transparency, you must disclose
if you are associated with them or any similar items in any way
</li>
<li>
Your affiliation with the service.
For transparency, you must disclose if you are associated
with them or any similar items in any way
Links to relevant discussions, past issues/PRs related to this service
</li>
<li>Links to relevant discussions, past issues/PRs related to this service</li>
</ul>
<textarea bind:value={$additionalInfo} id="additional-info" rows="5"></textarea>
<textarea bind:value={$additionalInfo} id="additional-info" rows="5"
></textarea>
</div>
<button type="submit">Submit</button>
<a href={issueUrl} target="_blank" class="open-in-gh">Open in GitHub Issues</a>
<a href={issueUrl} target="_blank" class="open-in-gh">Open in GitHub Issues</a
>
</form>
<div class="output-yaml">
<p>Below is the YAML content, which will be appended to the appropriate section
within <a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml">awesome-privacy.yml</a>
<p>
Below is the YAML content, which will be appended to the appropriate section
within <a
href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml"
>awesome-privacy.yml</a
>
upon approval.
</p>
{#if !interactiveActivated || !codeBlock}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- yamlText is generated from user form input via yaml.dump, not arbitrary HTML -->
<pre><code class="language-yaml">{@html yamlText}</code></pre>
{/if}
<pre><code bind:this={codeBlock} class="language-yaml"></code></pre>
<p>Your submission will need to be reviewed by a maintainer and the community before it can be merged.</p>
<p>
Your submission will need to be reviewed by a maintainer and the community
before it can be merged.
</p>
</div>
<style lang="scss">
@ -332,7 +447,9 @@
}
}
input, textarea, select {
input,
textarea,
select {
width: 100%;
border: 1px solid var(--accent-3);
border-radius: var(--curve-md);
@ -347,15 +464,15 @@
}
input {
height: fit-content;
&[type="number"]::-webkit-outer-spin-button,
&[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
&[type='number']::-webkit-outer-spin-button,
&[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&[type="number"] {
-moz-appearance: textfield;
&[type='number'] {
-moz-appearance: textfield;
}
&[type="checkbox"] {
&[type='checkbox'] {
width: 2rem;
height: 2rem;
background: var(--background-form);
@ -381,7 +498,7 @@
box-shadow: 3px 3px 0 var(--box-outline);
border-radius: var(--curve-lg);
font-size: 1.8rem;
font-family: "Lekton", sans-serif;
font-family: 'Lekton', sans-serif;
margin: 1rem auto;
display: flex;
transition: all 0.2s ease-in-out;

View file

@ -1,170 +1,178 @@
---
import type { AndroidInfo } from '@utils/fetch-android-info';
import { formatDate, timeAgo } from '@utils/dates-n-stuff';
import FontAwesome from "@components/form/FontAwesome.svelte"
import { formatDate } from '@utils/dates-n-stuff';
import FontAwesome from '@components/form/FontAwesome.svelte';
interface Props {
androidData: AndroidInfo;
};
androidData: AndroidInfo;
}
const { androidData } = Astro.props;
function permissionToReadable(permission: string): string {
return (permission
.split('.')
.pop() || '')
.replace(/_/g, ' ')
.toLowerCase()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
return (permission.split('.').pop() || '')
.replace(/_/g, ' ')
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
---
<div class="android-info-wrapper">
<div class="left">
<h4>Update Info</h4>
<ul class="list-table">
<li>
<span class="lbl">App</span>
<span class="val">
<!-- <img width="20" src={`https://reports.exodus-privacy.eu.org/en/reports/${androidData.version_code}/icon`} alt="Android Icon" /> -->
<a href={`https://play.google.com/store/apps/details?id=${androidData.handle}`}>{androidData.app_name}</a>
</span>
</li>
<li>
<span class="lbl">Creation Date</span>
<span class="val">{formatDate(androidData.created)}</span>
</li>
<li>
<span class="lbl">Last Updated</span>
<span class="val">{formatDate(androidData.updated)}</span>
</li>
<li>
<span class="lbl">Current Version</span>
<span class="val">{androidData.version_name}</span>
</li>
{androidData.creator && (
<li>
<span class="lbl">Creator</span>
<span class="val">{androidData.creator}</span>
</li>
)}
{androidData.downloads && (
<li>
<span class="lbl">Downloads</span>
<span class="val">{androidData.downloads}</span>
</li>
)}
</ul>
<div class="left">
<h4>Update Info</h4>
<ul class="list-table">
<li>
<span class="lbl">App</span>
<span class="val">
<!-- <img width="20" src={`https://reports.exodus-privacy.eu.org/en/reports/${androidData.version_code}/icon`} alt="Android Icon" /> -->
<a
href={`https://play.google.com/store/apps/details?id=${androidData.handle}`}
>{androidData.app_name}</a
>
</span>
</li>
<li>
<span class="lbl">Creation Date</span>
<span class="val">{formatDate(androidData.created)}</span>
</li>
<li>
<span class="lbl">Last Updated</span>
<span class="val">{formatDate(androidData.updated)}</span>
</li>
<li>
<span class="lbl">Current Version</span>
<span class="val">{androidData.version_name}</span>
</li>
{
androidData.creator && (
<li>
<span class="lbl">Creator</span>
<span class="val">{androidData.creator}</span>
</li>
)
}
{
androidData.downloads && (
<li>
<span class="lbl">Downloads</span>
<span class="val">{androidData.downloads}</span>
</li>
)
}
</ul>
<h4>Trackers</h4>
{(androidData.trackers || []).length === 0 && (
<p class="all-good">
<FontAwesome iconName="noTrackers" />
No trackers found
</p>
)}
<ul class="list">
{(androidData.trackers || []).map((track) => (
<li title={track.code_signature}>{track.name}</li>
))}
</ul>
</div>
<h4>Trackers</h4>
{
(androidData.trackers || []).length === 0 && (
<p class="all-good">
<FontAwesome iconName="noTrackers" />
No trackers found
</p>
)
}
<ul class="list">
{
(androidData.trackers || []).map((track) => (
<li title={track.code_signature}>{track.name}</li>
))
}
</ul>
</div>
<div class="right">
<h4>Permissions</h4>
{(androidData.permissions || []).length === 0 && (
<p class="all-good">
<FontAwesome iconName="noTrackers" />
No permissions required
</p>
)}
<ul class="list">
{(androidData.permissions || []).map((perm) => (
<li title={perm}>{permissionToReadable(perm)}</li>
))}
</ul>
</div>
<div class="right">
<h4>Permissions</h4>
{
(androidData.permissions || []).length === 0 && (
<p class="all-good">
<FontAwesome iconName="noTrackers" />
No permissions required
</p>
)
}
<ul class="list">
{
(androidData.permissions || []).map((perm) => (
<li title={perm}>{permissionToReadable(perm)}</li>
))
}
</ul>
</div>
</div>
<style lang="scss">
.android-info-wrapper {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
.left, .right {
width: calc(50% - 1rem);
@media screen and (max-width: 768px){
width: 100%;
}
}
}
.android-info-wrapper {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
.left,
.right {
width: calc(50% - 1rem);
@media screen and (max-width: 768px) {
width: 100%;
}
}
}
h3 {
margin: 0;
font-size: 1.6rem;
}
h3 {
margin: 0;
font-size: 1.6rem;
}
h4 {
margin: 1rem 0 0 0;
font-size: 1.2rem;
}
h4 {
margin: 1rem 0 0 0;
font-size: 1.2rem;
}
ul {
padding-left: 0;
list-style: none;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
img {
border-radius: var(--curve-sm);
}
.list-item {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
}
&.list-table {
font-size: 0.9rem;
padding-left: 0;
li {
display: flex;
justify-content: space-between;
padding: 0.1rem 0;
.lbl {
font-weight: 400;
}
.val {
img {
margin-right: 0.5rem;
}
}
&:not(:last-child) {
border-bottom: 1px solid #5f53f440;
}
}
}
&.list {
list-style: circle;
padding-left: 1rem;
}
}
.all-good {
color: var(--success);
display: flex;
align-items: center;
gap: 0.5rem;
:global(svg) {
width: 1.5rem;
}
}
ul {
padding-left: 0;
list-style: none;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
img {
border-radius: var(--curve-sm);
}
.list-item {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
}
&.list-table {
font-size: 0.9rem;
padding-left: 0;
li {
display: flex;
justify-content: space-between;
padding: 0.1rem 0;
.lbl {
font-weight: 400;
}
.val {
img {
margin-right: 0.5rem;
}
}
&:not(:last-child) {
border-bottom: 1px solid #5f53f440;
}
}
}
&.list {
list-style: circle;
padding-left: 1rem;
}
}
.all-good {
color: var(--success);
display: flex;
align-items: center;
gap: 0.5rem;
:global(svg) {
width: 1.5rem;
}
}
</style>

View file

@ -1,19 +1,25 @@
<svelte:head>
<script async lang="javascript">
var remark_config = {
host: 'https://comments.as93.net', site_id: 'awesome-privacy.xyz',
components: ['embed'], show_rss_subsription: true, theme: 'dark',
};
!(function (e, n) {
for (var o = 0; o < e.length; o++) {
var r = n.createElement('script'), d = n.head || n.body;
'noModule' in r ?
(r.type = 'module', r.src = remark_config.host + '/web/' + e[o] + '.mjs')
: ( r.async = !0, r.defer = !0, r.src = remark_config.host + '/web/' + e[o] + '.js'),
d.appendChild(r);
}
})(remark_config.components || ['embed'], document);
var remark_config = {
host: 'https://comments.as93.net',
site_id: 'awesome-privacy.xyz',
components: ['embed'],
show_rss_subsription: true,
theme: 'dark',
};
!(function (e, n) {
for (var o = 0; o < e.length; o++) {
var r = n.createElement('script'),
d = n.head || n.body;
('noModule' in r
? ((r.type = 'module'),
(r.src = remark_config.host + '/web/' + e[o] + '.mjs'))
: ((r.async = !0),
(r.defer = !0),
(r.src = remark_config.host + '/web/' + e[o] + '.js')),
d.appendChild(r));
}
})(remark_config.components || ['embed'], document);
</script>
</svelte:head>

View file

@ -1,18 +1,23 @@
<script lang="ts">
import { onMount } from "svelte";
import { fetchSrcData, makeRemovalRequest, makeEditRequest } from '@utils/data-src-delete-n-edit';
import { onMount } from 'svelte';
import {
fetchSrcData,
makeRemovalRequest,
makeEditRequest,
} from '@utils/data-src-delete-n-edit';
import FontAwesome from '@components/form/FontAwesome.svelte';
export let categoryName: string;
export let sectionName: string;
export let serviceName: string;
let lineNumbers: { start: number, end: number } | null = null;
let lineNumbers: { start: number; end: number } | null = null;
let yamlContent = '';
const getGitHubSrcFile = () => {
if (lineNumbers) {
const baseFile = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
const baseFile =
'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
return `${baseFile}#L${lineNumbers.start}-L${lineNumbers.end}`;
}
return '';
@ -21,56 +26,68 @@
const getIframeSrc = () => {
const host = 'https://github-embed.as93.net';
const target = encodeURIComponent(getGitHubSrcFile());
const opts = 'style=felipec&type=code&showBorder=on&showLineNumbers=on&showFileMeta=on&showFullPath=on&showCopy=on';
const opts =
'style=felipec&type=code&showBorder=on&showLineNumbers=on&showFileMeta=on&showFullPath=on&showCopy=on';
return `${host}/iframe.html?target=${target}&${opts}`;
};
onMount(async () => {
const results = await fetchSrcData(categoryName, sectionName, serviceName);
lineNumbers = results.lineNumbers
lineNumbers = results.lineNumbers;
yamlContent = results.yamlContent;
});
</script>
{#if lineNumbers}
<h4>Edit {serviceName} Data</h4>
<p>
You can view or edit this {serviceName}'s entry in
<a href={getGitHubSrcFile()}> this section </a>
of <code>awesome-privacy.yml</code> in our GitHub repo.
</p>
<h4>Edit {serviceName} Data</h4>
<p>
You can view or edit this {serviceName}'s entry in
<a href={getGitHubSrcFile()}>
this section
</a>
of <code>awesome-privacy.yml</code> in our GitHub repo.
</p>
<h4>Origin Data</h4>
<iframe
frameborder="0"
scrolling="no"
class="yaml-embed"
allow="clipboard-write"
title="awesome-privacy.yml"
src={getIframeSrc()}></iframe>
<h4>Modify Data</h4>
<div class="button-wrap">
<a class="button-link" target="_blank"
href={makeRemovalRequest(categoryName, sectionName, serviceName, yamlContent)}>
<FontAwesome iconName="delete" /> Delete {serviceName}
</a>
<a class="button-link" target="_blank"
href={makeEditRequest(categoryName, sectionName, serviceName, yamlContent)}>
<FontAwesome iconName="edit" /> Submit Edit to {serviceName}
</a>
<a class="button-link" href="/submit">
<FontAwesome iconName="add" /> Add alternative
</a>
</div>
<h4>Origin Data</h4>
<iframe
frameborder="0"
scrolling="no"
class="yaml-embed"
allow="clipboard-write"
title="awesome-privacy.yml"
src={getIframeSrc()}
></iframe>
<h4>Modify Data</h4>
<div class="button-wrap">
<a
class="button-link"
target="_blank"
href={makeRemovalRequest(
categoryName,
sectionName,
serviceName,
yamlContent,
)}
>
<FontAwesome iconName="delete" /> Delete {serviceName}
</a>
<a
class="button-link"
target="_blank"
href={makeEditRequest(
categoryName,
sectionName,
serviceName,
yamlContent,
)}
>
<FontAwesome iconName="edit" /> Submit Edit to {serviceName}
</a>
<a class="button-link" href="/submit">
<FontAwesome iconName="add" /> Add alternative
</a>
</div>
{/if}
<style lang="scss">
h4 {
font-size: 1.4rem;
@ -85,7 +102,7 @@
gap: 1rem;
justify-content: center;
margin: 1rem auto;
@media(max-width: 768px) {
@media (max-width: 768px) {
flex-direction: column;
}
}
@ -101,7 +118,7 @@
min-width: 15rem;
display: inline-block;
text-align: center;
font-family: "Lekton",sans-serif;
font-family: 'Lekton', sans-serif;
font-size: 1.2rem;
:global(svg) {
width: 1rem;
@ -118,5 +135,4 @@
margin: 1rem auto;
box-shadow: 3px 3px 0 var(--accent-3);
}
</style>

View file

@ -1,15 +1,17 @@
<script lang="ts">
import FontAwesome from '@components/form/FontAwesome.svelte';
import { fetchSrcData, makeRemovalRequest } from '@utils/data-src-delete-n-edit';
import {
fetchSrcData,
makeRemovalRequest,
} from '@utils/data-src-delete-n-edit';
import { onMount } from 'svelte';
export let categoryName: string;
export let sectionName: string;
export let serviceName: string;
const apYaml = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
const apYaml =
'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
let yamlContent = '';
let editLink = apYaml;
@ -19,43 +21,51 @@
yamlContent = results.yamlContent;
const lineNumbers = results.lineNumbers || null;
const numberRange = lineNumbers ? `#L${lineNumbers.start}-L${lineNumbers.end}` : '';
const yamlLink = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
const numberRange = lineNumbers
? `#L${lineNumbers.start}-L${lineNumbers.end}`
: '';
const yamlLink =
'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
editLink = `${yamlLink}${numberRange}`;
});
</script>
<div class="actions">
<a title="Edit" target="_blank"
href={editLink}>
<a title="Edit" target="_blank" href={editLink}>
<FontAwesome iconName="edit" />
</a>
<a title="Delete" target="_blank"
href={makeRemovalRequest(categoryName, sectionName, serviceName, yamlContent)}>
<a
title="Delete"
target="_blank"
href={makeRemovalRequest(
categoryName,
sectionName,
serviceName,
yamlContent,
)}
>
<FontAwesome iconName="delete" />
</a>
</div>
<style lang="scss">
.actions {
position: absolute;
right: 3.5rem;
top: 1rem;
width: 2.8rem;
gap: 1rem;
opacity: 0;
display: flex;
transition: all 0.2s ease-in-out;
a {
color: var(--foreground);
width: 1rem;
.actions {
position: absolute;
right: 3.5rem;
top: 1rem;
width: 2.8rem;
gap: 1rem;
opacity: 0;
display: flex;
transition: all 0.2s ease-in-out;
&:hover {
color: var(--accent-3);
opacity: 1;
a {
color: var(--foreground);
width: 1rem;
transition: all 0.2s ease-in-out;
&:hover {
color: var(--accent-3);
opacity: 1;
}
}
}
}
</style>

View file

@ -1,110 +1,111 @@
---
import type { DiscordInfo } from '@utils/fetch-discord-info';
import { formatDate, timeAgo } from '@utils/dates-n-stuff';
import FontAwesome from "@components/form/FontAwesome.svelte"
interface Props {
discordData: DiscordInfo;
};
discordData: DiscordInfo;
}
const { discordData } = Astro.props;
---
<div class="discord-info-wrapper">
<h3>Discord</h3>
<h3>Discord</h3>
<ul class="list-table">
<li>
<span class="lbl">Server Name</span>
<span class="val"
><img src={discordData.icon} width="16" />{discordData.name}</span
>
</li>
<li>
<span class="lbl">Member Count</span>
<span class="val"
>{discordData.memberCount} ({discordData.memberOnlineCount} online)</span
>
</li>
<li>
<span class="lbl">Initial Channel</span>
<span class="val">{discordData.channel}</span>
</li>
<li>
<span class="lbl">Inviter</span>
<span class="val">{discordData.inviter || 'Anon'}</span>
</li>
<li>
<span class="lbl">Join Link</span>
<span class="val"
><a href={`https://discord.com/invite/${discordData.inviteCode}`}
>discord.com/invite/{discordData.inviteCode}</a
></span
>
</li>
</ul>
<ul class="list-table">
<li>
<span class="lbl">Server Name</span>
<span class="val"><img src={discordData.icon} width="16" />{discordData.name}</span>
</li>
<li>
<span class="lbl">Member Count</span>
<span class="val">{discordData.memberCount} ({discordData.memberOnlineCount} online)</span>
</li>
<li>
<span class="lbl">Initial Channel</span>
<span class="val">{discordData.channel}</span>
</li>
<li>
<span class="lbl">Inviter</span>
<span class="val">{discordData.inviter || 'Anon'}</span>
</li>
<li>
<span class="lbl">Join Link</span>
<span class="val"><a href={`https://discord.com/invite/${discordData.inviteCode}`}>discord.com/invite/{discordData.inviteCode}</a></span>
</li>
</ul>
{ discordData.banner && (<img class="banner" width="300" src={discordData.banner} />)}
{
discordData.banner && (
<img class="banner" width="300" src={discordData.banner} />
)
}
</div>
<style lang="scss">
.discord-info-wrapper {
display: flex;
flex-direction: column;
max-width: 400px;
}
.discord-info-wrapper {
display: flex;
flex-direction: column;
max-width: 400px;
}
h3 {
margin: 0;
font-size: 1.6rem;
}
h3 {
margin: 0;
font-size: 1.6rem;
}
h4 {
margin: 1rem 0 0 0;
font-size: 1.2rem;
}
h4 {
margin: 1rem 0 0 0;
font-size: 1.2rem;
}
ul {
padding-left: 0;
list-style: none;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
img {
border-radius: var(--curve-sm);
}
.list-item {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
}
&.list-table {
font-size: 0.9rem;
padding-left: 0;
li {
display: flex;
justify-content: space-between;
padding: 0.1rem 0;
.lbl {
font-weight: 400;
}
.val {
img {
margin-right: 0.5rem;
}
}
&:not(:last-child) {
border-bottom: 1px solid #5f53f440;
}
}
}
}
.banner {
width: 80%;
margin: 1rem auto 0 auto;
display: flex;
border-radius: var(--curve-md);
}
ul {
padding-left: 0;
list-style: none;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
img {
border-radius: var(--curve-sm);
}
.list-item {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
}
&.list-table {
font-size: 0.9rem;
padding-left: 0;
li {
display: flex;
justify-content: space-between;
padding: 0.1rem 0;
.lbl {
font-weight: 400;
}
.val {
img {
margin-right: 0.5rem;
}
}
&:not(:last-child) {
border-bottom: 1px solid #5f53f440;
}
}
}
}
.banner {
width: 80%;
margin: 1rem auto 0 auto;
display: flex;
border-radius: var(--curve-md);
}
</style>

View file

@ -1,37 +1,39 @@
<script lang="ts">
import { slugify } from '@utils/fetch-data';
import { slugify } from "@utils/fetch-data";
let linkId = '';
let done = false;
let error = false;
let linkId = '';
let done = false;
let error = false;
const save = async () => {
const savedServices = JSON.parse(localStorage.getItem('savedServices') || '[]');
const inventoryTitle = localStorage.getItem('userTitle') || 'Anon\'s Inventory';
const uniqueId = Math.random().toString(36).substring(2);
const saveKey = `${uniqueId}_${slugify(inventoryTitle)}`;
const url = 'https://awesome-privacy-share-api.as93.net';
const data = { key: saveKey, services: savedServices };
fetch(url, {
const save = async () => {
const savedServices = JSON.parse(
localStorage.getItem('savedServices') || '[]',
);
const inventoryTitle =
localStorage.getItem('userTitle') || "Anon's Inventory";
const uniqueId = Math.random().toString(36).substring(2);
const saveKey = `${uniqueId}_${slugify(inventoryTitle)}`;
const url = 'https://awesome-privacy-share-api.as93.net';
const data = { key: saveKey, services: savedServices };
fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
.then(response => response.json())
.then(data => {
linkId = data.key;
done = true;
error = false;
navigator.clipboard.writeText(`https://awesome-privacy.xyz/inventory/${linkId}`);
})
.catch(error => {
error = true;
console.error('Error:', error)
});
};
})
.then((response) => response.json())
.then((data) => {
linkId = data.key;
done = true;
error = false;
navigator.clipboard.writeText(
`https://awesome-privacy.xyz/inventory/${linkId}`,
);
})
.catch((error) => {
error = true;
console.error('Error:', error);
});
};
</script>
<div class="share-container">

View file

@ -1,7 +1,7 @@
---
import type { IoSApiResponse } from '@utils/fetch-ios-info';
import { formatDate, timeAgo } from '@utils/dates-n-stuff';
import { formatDate } from '@utils/dates-n-stuff';
import FontAwesome from "@components/form/FontAwesome.svelte"
@ -18,7 +18,7 @@ const makeRatingPercentage = (rating: number) => (rating / 5) * 100;
const roundRatings = (rating: number) => Math.round(rating * 100) / 100;
const putCommaInNumber = (num: number | any) => {
const putCommaInNumber = (num: number | string | undefined) => {
if (!num) return 'Unknown';
return typeof num === 'number' ? num.toLocaleString() : num;
}

View file

@ -1,14 +1,21 @@
---
import FontAwesome from "@components/form/FontAwesome.svelte";
import { error } from "@utils/logger";
const { github } = Astro.props;
// const [user, repo] = github.split("/");
interface GitHubRepoData {
stargazers_count?: number;
forks_count?: number;
open_issues_count?: number;
language?: string;
license?: { spdx_id?: string; name?: string };
}
/**
* For a given `user/repo` fetch repository stats from the GitHub API data
* If API key is available through env var, use it to increase rate limit
* Returns the response data and status code (200 == success)
* @param repo
* @param repo
*/
const fetchGitHubData = async (repo: string) => {
const apiKey = import.meta.env.GITHUB_API_KEY;
@ -17,13 +24,13 @@ const fetchGitHubData = async (repo: string) => {
headers.append("Authorization", `token ${apiKey}`);
}
let data = {};
let statusCode = 0;
let data: GitHubRepoData = {};
let statusCode;
const response = await fetch(`https://api.github.com/repos/${repo}`, {
headers: headers,
}).catch((e) => {
console.error(`Network error: ${e.message}`);
error('GitHub API', `Network error for ${repo}: ${e.message}`);
// Return a placeholder response to handle this gracefully
return null;
});
@ -39,7 +46,7 @@ const fetchGitHubData = async (repo: string) => {
if (response.ok) {
data = await response.json();
} else {
console.error(`HTTP error: Received status code ${response.status}`);
error('GitHub API', `HTTP ${response.status} for ${repo}`);
}
return { data, statusCode };
@ -50,7 +57,7 @@ const fetchGitHubData = async (repo: string) => {
* Given a license object, return SPDX ID, or a formatted name
* @param license
*/
const formatLicense = (license: { spdx_id?: string, name?: string }) => {
const formatLicense = (license?: { spdx_id?: string, name?: string }) => {
if (!license) {
return "Unknown";
}
@ -65,7 +72,8 @@ const formatLicense = (license: { spdx_id?: string, name?: string }) => {
* E.g. If greater than thousand, then return in k format
* @param num
*/
const formatBigNumber = (num: number) => {
const formatBigNumber = (num: number | undefined) => {
if (num == null) return 0;
if (num > 1000) {
return `${(num / 1000).toFixed(1)}k`;
}
@ -73,7 +81,7 @@ const formatBigNumber = (num: number) => {
}
// Initiate GitHub fetch, and make available to the component
const stats = (await fetchGitHubData(github)) as any;
const stats = await fetchGitHubData(github);
const {
stargazers_count, forks_count, open_issues_count, language, license,

View file

@ -1,172 +1,191 @@
---
import type { RedditData } from '@utils/fetch-reddit-info';
import { timestampToDate, timeAgo } from '@utils/dates-n-stuff';
import FontAwesome from "@components/form/FontAwesome.svelte"
import { timestampToDate } from '@utils/dates-n-stuff';
interface Props {
redditData: RedditData;
};
redditData: RedditData;
}
const { redditData } = Astro.props;
---
<div class="reddit-info-wrapper">
<div class="left">
<h3>Reddit</h3>
<p class="website-title">
<img src={redditData.info.icon} width="16" />
{redditData.info.title || redditData.info.name}
</p>
<p class="website-description">{redditData.info.description}</p>
{redditData.info.banner && (<img class="banner" width="300" src={redditData.info.banner} alt="Banner" />)}
<ul class="list-table">
{redditData.info.dateCreated && (
<li>
<span class="lbl">Created at</span>
<span class="val">{timestampToDate(redditData.info.dateCreated * 1000)}</span>
</li>
)}
<li>
<span class="lbl">Members</span>
<span class="val">{redditData.info.subscribers}</span>
</li>
<li>
<span class="lbl">Join</span>
<span class="val"><a href={`https://reddit.com/${redditData.info.name}`}>{redditData.info.name}</a></span>
</li>
</ul>
</div>
<div class="right">
<h4>Posts</h4>
<ul class="posts">
{redditData.posts.map((post) => (
<li title={post.body}>
○ <a href={post.url} target="_blank">{post.title}</a>
<span class="votes">(▲ {post.upVotes} ▼ {post.downVotes})</span>
</li>
))}
</ul>
</div>
<div class="left">
<h3>Reddit</h3>
<p class="website-title">
<img src={redditData.info.icon} width="16" />
{redditData.info.title || redditData.info.name}
</p>
<p class="website-description">{redditData.info.description}</p>
{
redditData.info.banner && (
<img
class="banner"
width="300"
src={redditData.info.banner}
alt="Banner"
/>
)
}
<ul class="list-table">
{
redditData.info.dateCreated && (
<li>
<span class="lbl">Created at</span>
<span class="val">
{timestampToDate(redditData.info.dateCreated * 1000)}
</span>
</li>
)
}
<li>
<span class="lbl">Members</span>
<span class="val">{redditData.info.subscribers}</span>
</li>
<li>
<span class="lbl">Join</span>
<span class="val"
><a href={`https://reddit.com/${redditData.info.name}`}
>{redditData.info.name}</a
></span
>
</li>
</ul>
</div>
<div class="right">
<h4>Posts</h4>
<ul class="posts">
{
redditData.posts.map((post) => (
<li title={post.body}>
○{' '}
<a href={post.url} target="_blank">
{post.title}
</a>
<span class="votes">
(▲ {post.upVotes} ▼ {post.downVotes})
</span>
</li>
))
}
</ul>
</div>
</div>
<style lang="scss">
.reddit-info-wrapper {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
.left, .right {
width: calc(50% - 1rem);
@media screen and (max-width: 768px){
width: 100%;
}
}
}
.reddit-info-wrapper {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
.left,
.right {
width: calc(50% - 1rem);
@media screen and (max-width: 768px) {
width: 100%;
}
}
}
.banner {
margin: 0.5rem auto;
border-radius: var(--curve-md);
width: 100%;
}
.banner {
margin: 0.5rem auto;
border-radius: var(--curve-md);
width: 100%;
}
h3 {
margin: 0 0 1rem 0;
font-size: 1.6rem;
}
h3 {
margin: 0 0 1rem 0;
font-size: 1.6rem;
}
h4 {
margin: 1rem 0 0 0;
font-size: 1.2rem;
}
p {
margin: 0;
display: flex;
align-items: center;
gap: 0.25rem;
:global(svg) {
width: 1rem;
}
img {
border-radius: var(--curve-sm);
}
}
h4 {
margin: 1rem 0 0 0;
font-size: 1.2rem;
}
p {
margin: 0;
display: flex;
align-items: center;
gap: 0.25rem;
:global(svg) {
width: 1rem;
}
img {
border-radius: var(--curve-sm);
}
}
ul {
padding-left: 0;
list-style: none;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
img {
border-radius: var(--curve-sm);
}
.list-item {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
}
&.list-table {
font-size: 0.9rem;
padding-left: 0;
li {
display: flex;
justify-content: space-between;
padding: 0.1rem 0;
.lbl {
font-weight: 400;
}
&:not(:last-child) {
border-bottom: 1px solid #5f53f440;
}
}
}
}
.posts {
list-style: circle;
padding-left: 1rem;
font-size: 0.9rem;
li {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
a {
max-width: 80%;
}
.votes {
font-size: 0.8rem;
opacity: 0.5;
}
}
}
.website-title, .website-description {
font-size: 0.9rem;
opacity: 0.8;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
border-left: 2px solid var(--accent-3);
padding-left: 0.5rem;
}
.website-title {
font-weight: 500;
}
.website-description {
font-style: italic;
-webkit-line-clamp: 3;
}
.explainer {
font-size: 0.8rem;
opacity: 0.8;
font-style: italic;
}
ul {
padding-left: 0;
list-style: none;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
img {
border-radius: var(--curve-sm);
}
.list-item {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
}
&.list-table {
font-size: 0.9rem;
padding-left: 0;
li {
display: flex;
justify-content: space-between;
padding: 0.1rem 0;
.lbl {
font-weight: 400;
}
&:not(:last-child) {
border-bottom: 1px solid #5f53f440;
}
}
}
}
.posts {
list-style: circle;
padding-left: 1rem;
font-size: 0.9rem;
li {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
a {
max-width: 80%;
}
.votes {
font-size: 0.8rem;
opacity: 0.5;
}
}
}
.website-title,
.website-description {
font-size: 0.9rem;
opacity: 0.8;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
border-left: 2px solid var(--accent-3);
padding-left: 0.5rem;
}
.website-title {
font-weight: 500;
}
.website-description {
font-style: italic;
-webkit-line-clamp: 3;
}
.explainer {
font-size: 0.8rem;
opacity: 0.8;
font-style: italic;
}
</style>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import FontAwesome from "@components/form/FontAwesome.svelte";
import { slugify } from "@utils/fetch-data";
import FontAwesome from '@components/form/FontAwesome.svelte';
import { slugify } from '@utils/fetch-data';
export let categoryName: string;
export let sectionName: string;
@ -34,23 +34,24 @@
</script>
<div class="wrapper-or-something">
<button
class={`save-container ${isSaved ? 'saved' : ''} ${showLabel ? 'label-button' : ''}`}
title={`Save ${serviceName}`}
on:click={toggleSave}>
{#if showLabel }
<span>
{isSaved ? 'Saved' : 'Save'}
</span>
{/if}
<FontAwesome iconName="saveListing"/>
</button>
<button
class={`save-container ${isSaved ? 'saved' : ''} ${showLabel ? 'label-button' : ''}`}
title={`Save ${serviceName}`}
on:click={toggleSave}
>
{#if showLabel}
<span>
{isSaved ? 'Saved' : 'Save'}
</span>
{/if}
<FontAwesome iconName="saveListing" />
</button>
{#if showLabel && isSaved }
<div class="done-msg">
You can view all saved items in your <a href="/inventory">Inventory</a>
</div>
{/if}
{#if showLabel && isSaved}
<div class="done-msg">
You can view all saved items in your <a href="/inventory">Inventory</a>
</div>
{/if}
</div>
<style lang="scss">
@ -71,7 +72,7 @@
font-size: 1.2rem;
opacity: 0.8;
color: var(--foreground);
font-family: "Lekton";
font-family: 'Lekton';
}
:global(svg) {
color: var(--foreground);
@ -98,9 +99,8 @@
box-shadow: 3px 3px 0 var(--box-outline);
border: 1px solid var(--box-outline);
background: var(--background-form);
&:hover {
box-shadow: 4px 4px 0 var(--box-outline);
}
}
@ -110,7 +110,7 @@
max-width: 165px;
font-size: 0.8rem;
opacity: 0.6;
@media(max-width: 768px) {
@media (max-width: 768px) {
display: none;
}
}

View file

@ -1,48 +1,54 @@
<script lang="ts">
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import type { Category, Service } from '../../types/Service';
import { slugify } from "@utils/fetch-data";
import ServiceCard from './ServiceCard.svelte';
import type { Category, Service } from '../../types/Service';
import { slugify } from '@utils/fetch-data';
import ServiceCard from './ServiceCard.svelte';
export let allData: Category[];
export let serviceList: string[] | null = null;
export let allData: Category[];
export let serviceList: string[] | null = null;
interface SavedServices {
category: string;
section: string;
service: Service;
}
interface SavedServices {
category: string;
section: string;
service: Service;
}
const savedServices = writable<SavedServices[]>([]);
const savedServices = writable<SavedServices[]>([]);
onMount(async () => {
const results: SavedServices[] = [];
const saved = serviceList || JSON.parse(localStorage.getItem('savedServices') || '[]');
saved.forEach((serviceId: string) => {
const parts = serviceId.split('/');
const categoryName = parts[0];
const sectionName = parts[1];
const serviceName = parts[2];
onMount(async () => {
const results: SavedServices[] = [];
const saved =
serviceList || JSON.parse(localStorage.getItem('savedServices') || '[]');
saved.forEach((serviceId: string) => {
const parts = serviceId.split('/');
const categoryName = parts[0];
const sectionName = parts[1];
const serviceName = parts[2];
const category = allData.find((category) => slugify(category.name) === categoryName);
if (!category) return;
const section = category.sections.find((section) => slugify(section.name) === sectionName);
if (!section) return;
const service = section.services.find((service) => slugify(service.name) === serviceName);
if (!service) return;
results.push({ category: category.name, section: section.name, service});
const category = allData.find(
(category) => slugify(category.name) === categoryName,
);
if (!category) return;
const section = category.sections.find(
(section) => slugify(section.name) === sectionName,
);
if (!section) return;
const service = section.services.find(
(service) => slugify(service.name) === serviceName,
);
if (!service) return;
results.push({ category: category.name, section: section.name, service });
});
savedServices.set(results || []);
});
savedServices.set(results || []);
});
</script>
<div>
{#if $savedServices.length > 0}
<div class="saved-services">
{#each $savedServices as thingy}
{#each $savedServices as thingy (thingy.service.name + thingy.section)}
<ServiceCard
categoryName={thingy.category}
sectionName={thingy.section}
@ -52,10 +58,13 @@ onMount(async () => {
</div>
{:else if !serviceList}
<div class="nothing-yet">
<p>Here you'll find a list of all the software and services you've bookmarked.</p>
<p>
Here you'll find a list of all the software and services you've
bookmarked.
</p>
<small>
All data is stored on-device, in your browser's local storage,
and not sent anywhere unless you choose to share it
All data is stored on-device, in your browser's local storage, and not
sent anywhere unless you choose to share it
</small>
<p class="nope">Nothing saved yet!</p>
</div>

View file

@ -2,16 +2,17 @@
import { onMount } from 'svelte';
import Fuse from 'fuse.js';
import { slugify } from '@utils/fetch-data';
import type { Category, Section, Service, ShortService } from '../../types/Service';
import type { Category } from '../../types/Service';
import { formatLink } from '@utils/parse-markdown';
import { prepareSearchItems, searchOptions } from '@utils/do-searchy-searchy';
import type { SearchItem } from '@utils/do-searchy-searchy';
export let data: Category[];
export let previousSearch: string | undefined = undefined;
let fuse: Fuse<any>;
let fuse: Fuse<SearchItem>;
let searchQuery = '';
let results: any[] = [];
let results: SearchItem[];
// Initialize Fuse.js
onMount(() => {
@ -20,9 +21,9 @@
});
const makeResultLink = (cat?: string, sec?: string, itm?: string) => {
if (!cat) return '/'
if (!sec) return `/${slugify(cat)}`
if (!itm) return `/${slugify(cat)}/${slugify(sec)}`
if (!cat) return '/';
if (!sec) return `/${slugify(cat)}`;
if (!itm) return `/${slugify(cat)}/${slugify(sec)}`;
return `/${slugify(cat)}/${slugify(sec)}/${slugify(itm)}`;
};
@ -40,7 +41,7 @@
const makeTitle = (typ: string, desc: string) => {
if (desc && typ === 'Service') {
return `${desc.slice(0, 60)}...`
return `${desc.slice(0, 60)}...`;
}
return '';
};
@ -59,7 +60,10 @@
// Watch for changes in the search query and update results
$: if (searchQuery) {
results = fuse.search(searchQuery).map(result => result.item).splice(0, 25);
results = fuse
.search(searchQuery)
.map((result) => result.item)
.splice(0, 25);
} else {
results = [];
}
@ -79,35 +83,48 @@
bind:value={searchQuery}
on:keydown={handleKeyDown}
/>
{#if searchQuery.length > 0}
<div class="suggestions">
<ul>
{#each results as result}
<li class="result-row">
<a
href={makeResultLink(result.category, result.sectionName, result.name)}
title={makeTitle(result.type, result.description)}
{#each results as result (result.name + result.category + result.sectionName)}
<li class="result-row">
<a
href={makeResultLink(
result.category,
result.sectionName,
result.name,
)}
title={makeTitle(result.type, result.description)}
>
<span class="name">
{#if result.type === 'Service'}
<img src={makeLogoSrc(result.logo, result.url)} alt={result.name} width="20" height="20" loading="lazy" />
{/if}
{makeResultText(result.category, result.sectionName, result.name)}
{#if result.itemCount}
<i>({result.itemCount})</i>
{/if}
</span>
<span class="path">
{result.category ? `${result.category}` : ''}
{result.sectionName ? `➔ ${result.sectionName}` : ''}
{result.name ? `➔ ${result.name}` : ''}
</span>
</a>
</li>
<span class="name">
{#if result.type === 'Service'}
<img
src={makeLogoSrc(result.logo, result.url)}
alt={result.name}
width="20"
height="20"
loading="lazy"
/>
{/if}
{makeResultText(
result.category,
result.sectionName,
result.name,
)}
{#if result.itemCount}
<i>({result.itemCount})</i>
{/if}
</span>
<span class="path">
{result.category ? `${result.category}` : ''}
{result.sectionName ? `➔ ${result.sectionName}` : ''}
{result.name ? `➔ ${result.name}` : ''}
</span>
</a>
</li>
{/each}
</ul>
</div>
@ -115,103 +132,101 @@
</div>
<style lang="scss">
.search-wrap {
display: flex;
flex-direction: column;
position: relative;
margin: 1rem auto;
max-width: 900px;
margin: 0 auto;
width: 80vw;
label {
margin: 0.5rem 0;
.search-wrap {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
.enter-hint {
font-size:0.8rem;
opacity: 0.7;
flex-direction: column;
position: relative;
margin: 1rem auto;
max-width: 900px;
margin: 0 auto;
width: 80vw;
label {
margin: 0.5rem 0;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
.enter-hint {
font-size: 0.8rem;
opacity: 0.7;
}
}
}
input {
padding: 0.5rem 1rem;
font-size: 1.8rem;
border: 2px solid var(--box-outline);
border-radius: var(--curve-lg);
box-shadow: 3px 3px 0 var(--box-outline);
z-index: 4;
background: var(--accent-fg);
color: var(--foreground);
&:focus {
outline: none;
border-color: var(--accent);
box-shadow: 3px 3px 0 var(--accent);
}
}
.suggestions {
ul {
position: absolute;
background: var(--background-form);
z-index: 3;
width: 100%;
list-style: none;
padding: 0;
margin: 0;
input {
padding: 0.5rem 1rem;
font-size: 1.8rem;
border: 2px solid var(--box-outline);
border-radius: 0 0 var(--curve-lg) var(--curve-lg);
border-radius: var(--curve-lg);
box-shadow: 3px 3px 0 var(--box-outline);
transform: translateY(-0.5rem);
max-height: 500px;
overflow-y: scroll;
background: var(--background-form);
li.result-row {
padding: 0.5rem 1rem;
margin: 0.5rem 0;
a {
color: var(--foreground);
text-decoration: none;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
.name {
z-index: 4;
background: var(--accent-fg);
color: var(--foreground);
&:focus {
outline: none;
border-color: var(--accent);
box-shadow: 3px 3px 0 var(--accent);
}
}
.suggestions {
ul {
position: absolute;
background: var(--background-form);
z-index: 3;
width: 100%;
list-style: none;
padding: 0;
margin: 0;
border: 2px solid var(--box-outline);
border-radius: 0 0 var(--curve-lg) var(--curve-lg);
box-shadow: 3px 3px 0 var(--box-outline);
transform: translateY(-0.5rem);
max-height: 500px;
overflow-y: scroll;
background: var(--background-form);
li.result-row {
padding: 0.5rem 1rem;
margin: 0.5rem 0;
a {
color: var(--foreground);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
i {
color: var(--accent);
font-weight: bold;
font-style: normal;
justify-content: space-between;
flex-wrap: wrap;
.name {
display: flex;
align-items: center;
gap: 0.5rem;
i {
color: var(--accent);
font-weight: bold;
font-style: normal;
}
img {
border-radius: var(--curve-md);
width: 1.25rem;
height: 1.25rem;
font-size: 10px;
color: var(--accent);
overflow: hidden;
background: #f453974d;
padding: 1px;
}
}
img {
border-radius: var(--curve-md);
width: 1.25rem;
height: 1.25rem;
font-size: 10px;
color: var(--accent);
overflow: hidden;
background: #f453974d;
padding: 1px;
.path {
font-size: 0.85rem;
opacity: 0.7;
}
}
.path {
font-size: 0.85rem;
opacity: 0.7;
}
}
&:hover {
background: var(--accent);
.name i {
color: var(--accent-fg);
&:hover {
background: var(--accent);
.name i {
color: var(--accent-fg);
}
}
}
}
}
}
}
</style>

View file

@ -1,6 +1,4 @@
---
import FontAwesome from '@components/form/FontAwesome.svelte';
import { slugify } from '../../utils/fetch-data';
@ -9,144 +7,161 @@ import type { Section } from '../../types/Service';
interface Props {
title: string;
sections: Section[];
bigTitle?: boolean;
bigTitle?: boolean;
}
const { title, sections, bigTitle } = Astro.props;
---
<div class="wrap">
{
bigTitle ? (
<h2>
<FontAwesome iconName={slugify(title)} />
{title}{' '}
</h2>
) : (
<a class="category-title" href={`/${slugify(title)}`}>
<h3>{title}</h3>
</a>
)
}
{ bigTitle ?
<h2><FontAwesome iconName={slugify(title)} />{title} </h2> :
<a class="category-title" href={`/${slugify(title)}`}><h3>{title}</h3></a>
}
{
!bigTitle && (
<span class="section-icon">
<FontAwesome iconName={slugify(title)} />
</span>
)
}
{ !bigTitle && <span class="section-icon"><FontAwesome iconName={slugify(title)} /></span> }
<ul>
{sections.map((section) => (
<li class="section">
<a href={`/${slugify(title)}/${slugify(section.name)}`}>
<span>{section.name}</span>
<span class="service-count">({section.services ? section.services.length : 0})</span>
</a>
</li>
))}
</ul>
<ul>
{
sections.map((section) => (
<li class="section">
<a href={`/${slugify(title)}/${slugify(section.name)}`}>
<span>{section.name}</span>
<span class="service-count">
({section.services ? section.services.length : 0})
</span>
</a>
</li>
))
}
</ul>
</div>
<style lang="scss">
.wrap {
position: relative;
&:hover {
.section-icon :global(svg) {
opacity: 1;
transform: scale(1.2);
}
}
}
.wrap {
position: relative;
&:hover {
.section-icon :global(svg){
opacity: 1;
transform: scale(1.2);
}
}
}
h2 {
font-family: 'Lekton', sans-serif;
font-size: 2rem;
margin: -2rem 0 2rem -2rem;
box-shadow: 6px 6px 0 var(--box-outline);
border: 2px solid var(--box-outline);
background: var(--accent);
color: var(--accent-fg);
width: fit-content;
padding: 0.25rem 0.5rem;
display: flex;
justify-content: center;
gap: 1rem;
:global(svg) {
width: 2rem;
height: 2rem;
color: var(--accent-fg);
}
}
h2 {
font-family: "Lekton", sans-serif;
font-size: 2rem;
margin: -2rem 0 2rem -2rem;
box-shadow: 6px 6px 0 var(--box-outline);
border: 2px solid var(--box-outline);
background: var(--accent);
color: var(--accent-fg);
width: fit-content;
padding: 0.25rem 0.5rem;
display: flex;
justify-content: center;
gap: 1rem;
:global(svg) {
width: 2rem;
height: 2rem;
color: var(--accent-fg);
}
}
.category-title {
text-decoration: none;
color: var(--foreground);
z-index: 2;
position: relative;
h3 {
font-family: 'Lekton', sans-serif;
font-weight: bold;
margin: 0;
font-size: 1.8rem;
position: relative;
&:after {
background: none repeat scroll 0 0 transparent;
bottom: 0;
content: '';
display: block;
height: 3px;
left: 50%;
position: absolute;
background: var(--accent);
transition:
width 0.3s ease 0s,
left 0.3s ease 0s;
width: 0;
}
&:hover:after {
width: 80%;
left: 0;
}
}
}
.category-title {
text-decoration: none;
color: var(--foreground);
z-index: 2;
position: relative;
h3 {
font-family: "Lekton", sans-serif;
font-weight: bold;
margin: 0;
font-size: 1.8rem;
position: relative;
&:after {
background: none repeat scroll 0 0 transparent;
bottom: 0;
content: "";
display: block;
height: 3px;
left: 50%;
position: absolute;
background: var(--accent);
transition: width 0.3s ease 0s, left 0.3s ease 0s;
width: 0;
}
&:hover:after {
width: 80%;
left: 0;
}
}
}
ul {
list-style: circle;
padding-left: 1rem;
li {
margin: 0.5rem 0;
font-size: 1.25rem;
a {
text-decoration: none;
color: var(--foreground);
position: relative;
&:after {
background: none repeat scroll 0 0 transparent;
bottom: 0;
content: '';
display: block;
height: 2px;
left: 50%;
position: absolute;
background: var(--accent-3);
transition:
width 0.15s ease 0s,
left 0.15s ease 0s;
width: 0;
}
&:hover:after {
text-decoration: underline;
width: 80%;
left: 0;
}
}
.service-count {
color: var(--accent-3);
}
}
}
ul {
list-style: circle;
padding-left: 1rem;
li {
margin: 0.5rem 0;
font-size: 1.25rem;
a {
text-decoration: none;
color: var(--foreground);
position: relative;
&:after {
background: none repeat scroll 0 0 transparent;
bottom: 0;
content: "";
display: block;
height: 2px;
left: 50%;
position: absolute;
background: var(--accent-3);
transition: width 0.15s ease 0s, left 0.15s ease 0s;
width: 0;
}
&:hover:after {
text-decoration: underline;
width: 80%;
left: 0;
}
}
.service-count {
color: var(--accent-3);
}
}
}
.section-icon {
position: absolute;
right: 0;
top: 0;
width: fit-content;
:global(svg) {
width: 2rem;
height: 2rem;
opacity: 0.5;
text-shadow: 3px 3px 0 black;
color: var(--accent-3);
transition: all 0.2s ease-in-out;
}
}
.section-icon {
position: absolute;
right: 0;
top: 0;
width: fit-content;
:global(svg) {
width: 2rem;
height: 2rem;
opacity: 0.5;
text-shadow: 3px 3px 0 black;
color: var(--accent-3);
transition: all 0.2s ease-in-out;
}
}
</style>

View file

@ -1,5 +1,4 @@
---
import { formatLink } from '@utils/parse-markdown';
import type { Service } from 'src/types/Service';
import FontAwesome from '@components/form/FontAwesome.svelte';
@ -7,86 +6,91 @@ import SaveListing from '@components/things/SaveListing.svelte';
import { slugify } from '@utils/fetch-data';
interface Props {
service: Service;
categoryName: string;
sectionName: string;
service: Service;
categoryName: string;
sectionName: string;
}
const {
service,
sectionName,
categoryName,
} = Astro.props;
const { service, sectionName, categoryName } = Astro.props;
---
<script>
document.addEventListener('DOMContentLoaded', () => {
const serviceIcons = document.querySelectorAll<HTMLImageElement>('.service-icon');
const broke = '/broken-image.png';
document.addEventListener('DOMContentLoaded', () => {
const serviceIcons =
document.querySelectorAll<HTMLImageElement>('.service-icon');
const broke = '/broken-image.png';
serviceIcons.forEach(function(icon) {
icon.onerror = function() {
const imgElement = this as HTMLImageElement;
const serviceUrl = imgElement.getAttribute('data-service-url');
const newSrcAttribute = (imgElement.src.includes('on.ho') ? broke : `https://icon.horse/icon/${serviceUrl}`);
imgElement.src = imgElement.src !== newSrcAttribute ? newSrcAttribute : broke;
imgElement.onerror = null;
};
});
});
serviceIcons.forEach(function (icon) {
icon.onerror = function () {
const imgElement = this as HTMLImageElement;
const serviceUrl = imgElement.getAttribute('data-service-url');
const newSrcAttribute = imgElement.src.includes('on.ho')
? broke
: `https://icon.horse/icon/${serviceUrl}`;
imgElement.src =
imgElement.src !== newSrcAttribute ? newSrcAttribute : broke;
imgElement.onerror = null;
};
});
});
</script>
<div class="service" id={slugify(service.name)}>
<div class="service-head">
<a class="service-title" href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}>
<h4>{service.name}</h4>
</a>
{service.followWith && <p class="follow-with">({service.followWith})</p> }
</div>
<div class="service-head">
<a
class="service-title"
href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}
>
<h4>{service.name}</h4>
</a>
{service.followWith && <p class="follow-with">({service.followWith})</p>}
</div>
<div class="save-listing">
<SaveListing client:visible
categoryName={categoryName}
sectionName={sectionName}
serviceName={service.name}
/>
</div>
<div class="save-listing">
<SaveListing
client:visible
categoryName={categoryName}
sectionName={sectionName}
serviceName={service.name}
/>
</div>
<div class="service-body">
<img
width="40"
height="40"
loading="lazy"
decoding="async"
class="service-icon"
alt={`${service.name} Icon`}
data-service-url={formatLink(service.url)}
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
/>
<div class="service-body">
<p set:html={service.description}></p>
</div>
</div>
<div class="service-body">
<img
width="40"
height="40"
loading="lazy"
decoding="async"
class="service-icon"
alt={`${service.name} Icon`}
data-service-url={formatLink(service.url)}
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
/>
<div class="service-body">
<p set:html={service.description} />
</div>
</div>
<div class="service-links">
<a class="link" href={service.url}>
<FontAwesome iconName="website"/> <span>{formatLink(service.url)}</span>
</a>
{service.github &&
<a class="link" href={`https://github.com/${service.github}`}>
<FontAwesome iconName="sourceCode"/> GitHub
</a>
}
<a href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}>
<FontAwesome iconName="viewReport" /> View Report ➔
</a>
<div class="service-links">
<a class="link" href={service.url}>
<FontAwesome iconName="website" />
<span>{formatLink(service.url)}</span>
</a>
{
service.github && (
<a class="link" href={`https://github.com/${service.github}`}>
<FontAwesome iconName="sourceCode" /> GitHub
</a>
)
}
<a
href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}
>
<FontAwesome iconName="viewReport" /> View Report ➔
</a>
</div>
</div>
</div>
</div>
<style lang="scss">
@import './service-card.scss';
</style>
<style lang="scss">
@use './service-card.scss';
</style>

View file

@ -17,7 +17,10 @@
<div class="service" id={serviceRef}>
<div class="service-head">
<a class="service-title" href={`/${categorySlug}/${sectionSlug}/${serviceRef}`}>
<a
class="service-title"
href={`/${categorySlug}/${sectionSlug}/${serviceRef}`}
>
<h4>{service.name}</h4>
</a>
{#if service.followWith}
@ -26,11 +29,7 @@
</div>
<div class="save-listing">
<SaveListing
categoryName={categoryName}
sectionName={sectionName}
serviceName={service.name}
/>
<SaveListing {categoryName} {sectionName} serviceName={service.name} />
</div>
<div class="service-body">
@ -45,6 +44,7 @@
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
/>
<div class="service-body">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- description is from curated YAML data, not user input -->
<p>{@html service.description}</p>
</div>
</div>
@ -65,5 +65,5 @@
</div>
<style lang="scss">
@import './service-card.scss';
@use './service-card.scss';
</style>

View file

@ -1,5 +1,4 @@
---
import Button from '@components/form/Button.astro';
import { parseMarkdown, formatLink } from '@utils/parse-markdown';
import type { Service } from 'src/types/Service';
@ -11,326 +10,387 @@ import GitHubMetrics from '@components/things/ItemGitHubMetrics.astro';
import SaveListing from '@components/things/SaveListing.svelte';
interface Props {
services: Service[];
subHeading?: boolean;
buttonLink?: string;
noGitHubMetrics?: boolean;
sectionName: string;
categoryName: string;
services: Service[];
subHeading?: boolean;
buttonLink?: string;
noGitHubMetrics?: boolean;
sectionName: string;
categoryName: string;
}
const {
services,
subHeading,
buttonLink,
noGitHubMetrics,
sectionName,
categoryName,
services,
subHeading,
buttonLink,
noGitHubMetrics,
sectionName,
categoryName,
} = Astro.props;
---
<script>
document.addEventListener('DOMContentLoaded', () => {
const serviceIcons = document.querySelectorAll<HTMLImageElement>('.service-icon');
const broke = '/broken-image.png';
document.addEventListener('DOMContentLoaded', () => {
const serviceIcons =
document.querySelectorAll<HTMLImageElement>('.service-icon');
const broke = '/broken-image.png';
serviceIcons.forEach(function(icon) {
icon.onerror = function() {
const imgElement = this as HTMLImageElement;
const serviceUrl = imgElement.getAttribute('data-service-url');
const newSrcAttribute = (imgElement.src.includes('on.ho') ? broke : `https://icon.horse/icon/${serviceUrl}`);
imgElement.src = imgElement.src !== newSrcAttribute ? newSrcAttribute : broke;
imgElement.onerror = null;
};
});
});
serviceIcons.forEach(function (icon) {
icon.onerror = function () {
const imgElement = this as HTMLImageElement;
const serviceUrl = imgElement.getAttribute('data-service-url');
const newSrcAttribute = imgElement.src.includes('on.ho')
? broke
: `https://icon.horse/icon/${serviceUrl}`;
imgElement.src =
imgElement.src !== newSrcAttribute ? newSrcAttribute : broke;
imgElement.onerror = null;
};
});
});
</script>
<section>
{services && services.length > 0 ? (
<ul>
{services.map((service: Service) => (
<li id={slugify(service.name)}>
<DeleteListing client:load categoryName={categoryName} sectionName={sectionName} serviceName={service.name} />
<div class="save-listing">
<SaveListing client:visible
categoryName={categoryName}
sectionName={sectionName}
serviceName={service.name}
/>
</div>
<div class="service-head">
<img
width="40"
height="40"
loading="lazy"
decoding="async"
class="service-icon"
alt={`${service.name} Icon`}
data-service-url={formatLink(service.url)}
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
/>
{
services && services.length > 0 ? (
<ul>
{services.map((service: Service) => (
<li id={slugify(service.name)}>
<DeleteListing
client:load
categoryName={categoryName}
sectionName={sectionName}
serviceName={service.name}
/>
<div class="save-listing">
<SaveListing
client:visible
categoryName={categoryName}
sectionName={sectionName}
serviceName={service.name}
/>
</div>
<div class="service-head">
<img
width="40"
height="40"
loading="lazy"
decoding="async"
class="service-icon"
alt={`${service.name} Icon`}
data-service-url={formatLink(service.url)}
src={
service.icon ||
`https://icon.horse/icon/${formatLink(service.url)}`
}
/>
<a class="service-title" href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}>
{subHeading ? <h4>{service.name}</h4> : <h3>{service.name}</h3>}
</a>
{service.followWith && <p class="follow-with">({service.followWith})</p> }
<a class="service-link" href={service.url}>{formatLink(service.url)}</a>
</div>
<div class="service-body">
<p set:html={parseMarkdown(service.description)}></p>
<div class="service-stats">
<div class="left">
{ service.securityAudited && (
<span class="meta-item great" title={`${service.name} has been security audited by an accredited auditor, with results published publicly`}>
<FontAwesome iconName="securityAudited" /> Security Audited
</span>
)}
{ service.acceptsCrypto && (
<span class="meta-item great" title={`${service.name} accepts anonymous payment methods`}>
<FontAwesome iconName="cryptoAccepted" /> Crypto Payments Accepted
</span>
)}
{ service.securityAudited === false && (
<span class="meta-item warning" title={`${service.name} has not been audited`}>
<FontAwesome iconName="notSecurityAudited" /> No Security Audit
</span>
)}
{ (service.openSource === false) && (
<span class="warning">
<FontAwesome iconName="closedSource" />
Not Open Source
</span>
)}
{ service.openSource || (service.github && service.openSource !== false) ? (
<span class="meta-item great" title={`${service.name} is open source`}>
<FontAwesome iconName="openSource" /> Open Source
</span>
) : null }
{ service.github && !noGitHubMetrics && <GitHubMetrics github={service.github} /> }
{ service.github && noGitHubMetrics && (
<span class="meta-item" title={`View ${service.name} on GitHub`}>
<a href={`https://github.com/${service.github}`} target="_blank">
<FontAwesome iconName="github" /> {service.github}
</a>
</span>
) }
</div>
<div class="view-service">
<a href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}>View {service.name} Report</a>
</div>
</div>
</div>
</li>
))}
</ul>
) : (
<p class="nothing-yet">
<strong>⚠️ This section is still a work in progress ⚠️</strong><br />
Check back soon, or help us complete it by submiting a pull request on GitHub.
<br />
<span class="quick-submit">Or submit an entry <a href="/submit">here</a></span>
</p>
)}
<a
class="service-title"
href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}
>
{subHeading ? <h4>{service.name}</h4> : <h3>{service.name}</h3>}
</a>
{service.followWith && (
<p class="follow-with">({service.followWith})</p>
)}
<a class="service-link" href={service.url}>
{formatLink(service.url)}
</a>
</div>
<div class="service-body">
<p set:html={parseMarkdown(service.description)} />
<div class="service-stats">
<div class="left">
{service.securityAudited && (
<span
class="meta-item great"
title={`${service.name} has been security audited by an accredited auditor, with results published publicly`}
>
<FontAwesome iconName="securityAudited" /> Security
Audited
</span>
)}
{service.acceptsCrypto && (
<span
class="meta-item great"
title={`${service.name} accepts anonymous payment methods`}
>
<FontAwesome iconName="cryptoAccepted" /> Crypto Payments
Accepted
</span>
)}
{service.securityAudited === false && (
<span
class="meta-item warning"
title={`${service.name} has not been audited`}
>
<FontAwesome iconName="notSecurityAudited" /> No Security
Audit
</span>
)}
{service.openSource === false && (
<span class="warning">
<FontAwesome iconName="closedSource" />
Not Open Source
</span>
)}
{service.openSource ||
(service.github && service.openSource !== false) ? (
<span
class="meta-item great"
title={`${service.name} is open source`}
>
<FontAwesome iconName="openSource" /> Open Source
</span>
) : null}
{service.github && !noGitHubMetrics && (
<GitHubMetrics github={service.github} />
)}
{service.github && noGitHubMetrics && (
<span
class="meta-item"
title={`View ${service.name} on GitHub`}
>
<a
href={`https://github.com/${service.github}`}
target="_blank"
>
<FontAwesome iconName="github" /> {service.github}
</a>
</span>
)}
</div>
<div class="view-service">
<a
href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}
>
View {service.name} Report
</a>
</div>
</div>
</div>
</li>
))}
</ul>
) : (
<p class="nothing-yet">
<>
<strong>⚠️ This section is still a work in progress ⚠️</strong>
<br />
</>
Check back soon, or help us complete it by submiting a pull request on
GitHub.
<br />
<span class="quick-submit">
Or submit an entry <a href="/submit">here</a>
</span>
</p>
)
}
{buttonLink && (
<Button title={`View all ${categoryName}`} className="view-all" text="View More..." url={buttonLink} />
)}
{
buttonLink && (
<Button
title={`View all ${categoryName}`}
className="view-all"
text="View More..."
url={buttonLink}
/>
)
}
</section>
<style lang="scss">
section {
padding: 1rem 0;
position: relative;
&:not(:last-child) {
border-bottom: 2px solid var(--accent-3);
}
}
section {
padding: 1rem 0;
position: relative;
&:not(:last-child) {
border-bottom: 2px solid var(--accent-3);
}
}
.nothing-yet {
font-size: 1.4rem;
opacity: 0.8;
font-style: italic;
text-align: center;
margin-bottom: 3rem;
.quick-submit {
margin-top: 1rem;
font-size: 0.8rem;
opacity: 0.8;
}
}
ul {
list-style: none;
padding: 0;
margin: 0 0 3rem 0;
li {
margin-bottom: 1rem;
position: relative;
.save-listing {
position: absolute;
right: 1rem;
top: 1rem;
}
.service-head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
.service-title {
text-decoration: none;
color: var(--foreground);
h3, h4 {
margin: 0;
font-size: 1.6rem;
}
.nothing-yet {
font-size: 1.4rem;
opacity: 0.8;
font-style: italic;
text-align: center;
margin-bottom: 3rem;
.quick-submit {
margin-top: 1rem;
font-size: 0.8rem;
opacity: 0.8;
}
}
position: relative;
&:after {
background: none repeat scroll 0 0 transparent;
bottom: 0;
content: "";
display: block;
height: 3px;
left: 50%;
position: absolute;
background: var(--accent-3);
transition: width 0.3s ease 0s, left 0.3s ease 0s;
width: 0;
}
&:hover:after {
width: 100%;
left: 0;
}
}
.service-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--curve-sm);
ul {
list-style: none;
padding: 0;
margin: 0 0 3rem 0;
li {
margin-bottom: 1rem;
position: relative;
.save-listing {
position: absolute;
right: 1rem;
top: 1rem;
}
.service-head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
.service-title {
text-decoration: none;
color: var(--foreground);
h3,
h4 {
margin: 0;
font-size: 1.6rem;
}
font-size: 10px;
overflow: hidden;
color: var(--accent);
}
.follow-with {
opacity: 0.7;
font-style: italic;
margin: 0;
}
.service-link {
max-width: 300px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.service-body {
margin: 0.5rem 0 2rem;
opacity: 0.8;
:global(p) {
margin: 0;
font-size: 1.2rem;
:global(a) {
color: var(--foregorund);
}
}
.service-stats {
.left {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.view-service {
transition: all 0.2s ease-in-out;
opacity: 0.95;
a {
padding: 0.25rem 0.6rem;
width: fit-content;
right: 1rem;
font-size: 0.9rem;
background: var(--accent-3);
color: var(--accent-fg);
text-decoration: none;
border-radius: var(--curve-md);
}
&:hover {
opacity: 1;
transform: scale(1.05);
}
}
position: relative;
&:after {
background: none repeat scroll 0 0 transparent;
bottom: 0;
content: '';
display: block;
height: 3px;
left: 50%;
position: absolute;
background: var(--accent-3);
transition:
width 0.3s ease 0s,
left 0.3s ease 0s;
width: 0;
}
&:hover:after {
width: 100%;
left: 0;
}
}
.meta-item, .warning {
display: flex;
align-items: center;
// justify-content: center;
gap: 0.25rem;
// opacity: 0.6;
font-size: 0.9rem;
padding: 0.5rem 0;
:global(svg) {
color: var(--foreground);
width: 1.2rem;
height: 1.2rem;
}
a {
text-decoration: none;
color: var(--foreground);
display: flex;
gap: 0.25rem;
&:hover {
color: var(--accent-3);
:global(svg) {
color: var(--accent-3);
}
}
}
}
.warning {
color: var(--danger);
:global(svg) {
color: var(--danger);
}
}
.great {
color: #0fb953; // var(--success);
:global(svg) {
color: #0fb953; // var(--success);
}
}
}
}
}
.service-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--curve-sm);
section :global(.view-all) {
width: fit-content;
position: absolute;
right: 1rem;
margin-top: -2.5rem;
background: var(--accent-3);
}
font-size: 10px;
overflow: hidden;
color: var(--accent);
}
.follow-with {
opacity: 0.7;
font-style: italic;
margin: 0;
}
.service-link {
max-width: 300px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
li:hover :global(.actions) {
opacity: 0.6;
:global(a):hover {
opacity: 1;
}
}
:global(.actions a):hover {
opacity: 1;
}
.service-body {
margin: 0.5rem 0 2rem;
opacity: 0.8;
:global(p) {
margin: 0;
font-size: 1.2rem;
:global(a) {
color: var(--foregorund);
}
}
.service-stats {
.left {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.view-service {
transition: all 0.2s ease-in-out;
opacity: 0.95;
a {
padding: 0.25rem 0.6rem;
width: fit-content;
right: 1rem;
font-size: 0.9rem;
background: var(--accent-3);
color: var(--accent-fg);
text-decoration: none;
border-radius: var(--curve-md);
}
&:hover {
opacity: 1;
transform: scale(1.05);
}
}
.meta-item,
.warning {
display: flex;
align-items: center;
// justify-content: center;
gap: 0.25rem;
// opacity: 0.6;
font-size: 0.9rem;
padding: 0.5rem 0;
:global(svg) {
color: var(--foreground);
width: 1.2rem;
height: 1.2rem;
}
a {
text-decoration: none;
color: var(--foreground);
display: flex;
gap: 0.25rem;
&:hover {
color: var(--accent-3);
:global(svg) {
color: var(--accent-3);
}
}
}
}
.warning {
color: var(--danger);
:global(svg) {
color: var(--danger);
}
}
.great {
color: #0fb953; // var(--success);
:global(svg) {
color: #0fb953; // var(--success);
}
}
}
}
}
section :global(.view-all) {
width: fit-content;
position: absolute;
right: 1rem;
margin-top: -2.5rem;
background: var(--accent-3);
}
li:hover :global(.actions) {
opacity: 0.6;
:global(a):hover {
opacity: 1;
}
}
:global(.actions a):hover {
opacity: 1;
}
</style>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { writable } from "svelte/store";
import type { Category, Service } from "../../types/Service";
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import type { Category, Service } from '../../types/Service';
import { formatLink } from '@utils/parse-markdown';
import { slugify } from '@utils/fetch-data';
@ -14,21 +14,23 @@
let results = writable<ServiceResult[]>([]);
const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, '');
const normalize = (str: string) =>
str.toLowerCase().replace(/[^a-z0-9]/g, '');
onMount(async () => {
const apiEndpoint = `https://awesome-privacy.as93.workers.dev/${searchTerm}`;
const fetchedServices = await fetch(apiEndpoint)
.then((response) => response.json())
.then((data) => (JSON.parse(data) || []).map((servName: string) => normalize(servName)));
.then((data) =>
(JSON.parse(data) || []).map((servName: string) => normalize(servName)),
);
const tmpResults: ServiceResult[] = [];
categories.forEach((category) => {
(category.sections || []).forEach((section) => {
(section.services || []).forEach((service) => {
if (fetchedServices.includes(normalize(service.name))) {
const path = `/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}`
const path = `/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}`;
tmpResults.push({ ...service, path });
return;
}
@ -45,35 +47,36 @@
{#if $results.length > 1}
<h3>Top Results</h3>
{/if}
<section>
{#each $results as service (service)}
<a class="service-result" href={service.path}>
<div class="service-head">
<img
width="40"
height="40"
loading="lazy"
decoding="async"
class="service-icon"
alt={`${service.name} Icon`}
data-service-url={formatLink(service.url)}
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
/>
<div>
<h4>
{service.name}
{#if service.followWith}
<section>
{#each $results as service (service)}
<a class="service-result" href={service.path}>
<div class="service-head">
<img
width="40"
height="40"
loading="lazy"
decoding="async"
class="service-icon"
alt={`${service.name} Icon`}
data-service-url={formatLink(service.url)}
src={service.icon ||
`https://icon.horse/icon/${formatLink(service.url)}`}
/>
<div>
<h4>
{service.name}
{#if service.followWith}
<p class="follow-with">({service.followWith})</p>
{/if}
</h4>
<a class="service-link" href={service.url}>{formatLink(service.url)}</a>
</div>
{/if}
</h4>
<a class="service-link" href={service.url}
>{formatLink(service.url)}</a
>
</div>
</a>
{/each}
</section>
</div>
</a>
{/each}
</section>
<style lang="scss">
h3 {

View file

@ -29,21 +29,23 @@
h4 {
text-decoration: none;
position: relative;
&:after {
&:after {
background: none repeat scroll 0 0 transparent;
bottom: 0;
content: "";
content: '';
display: block;
height: 3px;
left: 50%;
position: absolute;
background: var(--accent-3);
transition: width 0.2s ease 0s, left 0.2s ease 0s;
transition:
width 0.2s ease 0s,
left 0.2s ease 0s;
width: 0;
}
&:hover:after {
width: 100%;
left: 0;
&:hover:after {
width: 100%;
left: 0;
}
}
}
@ -66,7 +68,7 @@
:global(p) {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
-webkit-box-orient: vertical;
overflow: hidden;
margin: 0.5rem 0;
width: calc(100% - 2rem);

View file

@ -1,5 +1,5 @@
---
import { ViewTransitions } from 'astro:transitions'
import { ViewTransitions } from 'astro:transitions';
import NavBar from '@components/scafold/NavBar.astro';
import Footer from '@components/scafold/Footer.astro';
import config from '../site-config';
@ -7,14 +7,14 @@ import config from '../site-config';
interface Props {
title?: string; // Page title
description?: string; // Overide description tag
keywords?: string; // Overide keywords tag
keywords?: string; // Overide keywords tag
hideNav?: boolean; // Don't show the navbar (just homepage)
author?: string; // Author of the content
customSchemaJson?: any; // Custom schema item
customSchemaJson?: Record<string, unknown>; // Custom schema item
breadcrumbs?: Array<{
name: string;
item: string;
}>
}>;
}
const {
@ -30,92 +30,120 @@ const {
const makeBreadcrumbs = () => {
if (!breadcrumbs) return null;
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumbs.map((breadcrumb, index) => ({
"@type": "ListItem",
"position": index + 1,
"name": breadcrumb.name,
"item": `https://awesome-privacy.xyz/${breadcrumb.item}`
}))
}
}
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbs.map((breadcrumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: breadcrumb.name,
item: `https://awesome-privacy.xyz/${breadcrumb.item}`,
})),
};
};
const makeSearchLd = () => {
return {
"@context": "https://schema.org",
"@type": "WebSite",
"url": "https://awesome-privacy.xyz/",
"potentialAction": [{
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://awesome-privacy.xyz/search?q={search_term_string}"
'@context': 'https://schema.org',
'@type': 'WebSite',
url: 'https://awesome-privacy.xyz/',
potentialAction: [
{
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate:
'https://awesome-privacy.xyz/search?q={search_term_string}',
},
'query-input': 'required name=search_term_string',
},
"query-input": "required name=search_term_string"
}]
}
],
};
};
---
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<ViewTransitions />
<!-- Core info -->
<title>{title}</title>
<meta name="description" content={description}>
<meta name="keywords" content={keywords}>
<meta name="author" content={author}>
<meta name="description" content={description} />
<meta name="keywords" content={keywords} />
<meta name="author" content={author} />
<!-- Page info, viewport, Astro credit -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content={Astro.generator} />
<meta name="robots" content="index, follow">
<meta name="robots" content="index, follow" />
<!-- Icons and colors -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<!-- Social media meta tags (Open Graphh + Twitter) -->
<meta property="og:site_name" content="Awesome Privacy">
<meta property="og:type" content="website">
<meta property="og:url" content="https://awesome-privacy.xyz">
<meta property="og:title" content={title}>
<meta property="og:description" content={description}>
<meta property="og:image" content="https://awesome-privacy.xyz/banner.png">
<meta name="twitter:card" content="summary">
<meta name="twitter:url" content="https://awesome-privacy.xyz">
<meta name="twitter:title" content={title}>
<meta name="twitter:description" content={description}>
<meta name="twitter:image" content="https://awesome-privacy.xyz/banner.png">
<link rel="twitter:image" sizes="180x180" href="https://awesome-privacy.xyz/apple-touch-icon.png">
<meta name="twitter:site" content="@Lissy_Sykes">
<meta name="twitter:creator" content="@Lissy_Sykes">
<meta property="og:site_name" content="Awesome Privacy" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://awesome-privacy.xyz" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta
property="og:image"
content="https://awesome-privacy.xyz/banner.png"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:url" content="https://awesome-privacy.xyz" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta
name="twitter:image"
content="https://awesome-privacy.xyz/banner.png"
/>
<link
rel="twitter:image"
sizes="180x180"
href="https://awesome-privacy.xyz/apple-touch-icon.png"
/>
<meta name="twitter:site" content="@Lissy_Sykes" />
<meta name="twitter:creator" content="@Lissy_Sykes" />
<!-- Non-tracking hit counter -->
<script defer is:inline
<script
defer
is:inline
type="text/partytown"
data-domain="awesome-privacy.xyz"
src="https://no-track.as93.net/js/script.js">
</script>
src="https://no-track.as93.net/js/script.js"></script>
<!-- Schema.org markup for Google -->
{breadcrumbs && (
<script type="application/ld+json" set:html={JSON.stringify(makeBreadcrumbs())} />
)}
{customSchemaJson && (
<script type="application/ld+json" set:html={JSON.stringify(customSchemaJson)} />
)}
<script type="application/ld+json" set:html={JSON.stringify(makeSearchLd)} />
{
breadcrumbs && (
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(makeBreadcrumbs())}
/>
)
}
{
customSchemaJson && (
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(customSchemaJson)}
/>
)
}
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(makeSearchLd)}
/>
</head>
<body>
{!hideNav && <NavBar /> }
{!hideNav && <NavBar />}
<slot />
<Footer />
</body>
@ -124,11 +152,9 @@ const makeSearchLd = () => {
<style is:global>
@import '../styles/values.css';
@import '../styles/typography.css';
</style>
</style>
<style is:global>
html {
::selection {
background: var(--accent);
@ -142,5 +168,4 @@ const makeSearchLd = () => {
color: var(--foreground);
background: var(--background);
}
</style>

View file

@ -1,98 +1,91 @@
---
import Layout from '@layouts/Layout.astro';
import Buton from '@components/form/Button.astro';
---
<Layout title="404 | Awesome Privacy">
<article class="oh-crap">
<h2>404</h2>
<p class="what-happened">Page not found 😢</p>
<p class="why-happened">
It seems this page doesn't exist (yet). We're sorry about that.
</p>
<span class="back-you-go-then"><Buton url="/">Go back home</Buton></span>
<article class="oh-crap">
<h2>404</h2>
<p class="what-happened">Page not found 😢</p>
<p class="why-happened">
It seems this page doesn't exist (yet). We're sorry about that.
</p>
<span class="back-you-go-then"><Buton url="/">Go back home</Buton></span>
<nav class="other-places">
Looking for somewhere else?
<ul>
<li>
<a href="/search">Search</a>
</li>
<li>
<a href="/browse">Browse</a>
</li>
<li>
<a href="/about">About</a>
</li>
<li>
<a href="https://github.com/lissy93/awesome-privacy">Source</a>
</li>
<li>
<a href="https://as93.net">More Apps</a>
</li>
</ul>
</nav>
</article>
<nav class="other-places">
Looking for somewhere else?
<ul>
<li>
<a href="/search">Search</a>
</li>
<li>
<a href="/browse">Browse</a>
</li>
<li>
<a href="/about">About</a>
</li>
<li>
<a href="https://github.com/lissy93/awesome-privacy">Source</a>
</li>
<li>
<a href="https://as93.net">More Apps</a>
</li>
</ul>
</nav>
</article>
</Layout>
<style lang="scss">
.oh-crap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 6rem);
h2 {
font-size: 12rem;
margin: 0;
}
.what-happened {
font-size: 4rem;
margin: 0;
opacity: 0.8;
}
.why-happened {
font-size: 1.5rem;
text-align: center;
margin-bottom: 2rem;
opacity: 0.6;
}
.back-you-go-then :global(a) {
font-size: 2rem;
}
.oh-crap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 6rem);
h2 {
font-size: 12rem;
margin: 0;
}
.what-happened {
font-size: 4rem;
margin: 0;
opacity: 0.8;
}
.why-happened {
font-size: 1.5rem;
text-align: center;
margin-bottom: 2rem;
opacity: 0.6;
}
.back-you-go-then :global(a) {
font-size: 2rem;
}
.other-places {
opacity: 0.8;
margin: 2rem auto 1rem auto;
text-align: center;
&:hover {
opacity: 1;
}
ul {
list-style: none;
display: flex;
padding: 0;
gap: 0.5rem;
li {
&:not(:last-child) {
border-right: 1px solid var(--accent);
padding-right: 0.5rem;
}
}
li a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
.other-places {
opacity: 0.8;
margin: 2rem auto 1rem auto;
text-align: center;
&:hover {
opacity: 1;
}
ul {
list-style: none;
display: flex;
padding: 0;
gap: 0.5rem;
li {
&:not(:last-child) {
border-right: 1px solid var(--accent);
padding-right: 0.5rem;
}
}
li a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
---
import Layout from '@layouts/Layout.astro';
import SectionList from '@components/things/SectionList.astro';
import Main from '@components/scafold/MainCard.astro';
@ -29,129 +28,137 @@ export async function getStaticPaths() {
});
});
return pages.map(({ category, title, sections }) => {
return {
params: { category },
props: { title, sections },
};
});
return pages.map(({ category, title, sections }) => {
return {
params: { category },
props: { title, sections },
};
});
}
const makeCarasolData = () => {
return {
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": [
sections.map((section, index) => ({
"@type": "ListItem",
"position": index + 1,
"url": `https://awesomeprivacy.com/${slugify(title)}/${slugify(section.name)}`,
"item": {
"@type": "Service",
"name": section.name,
"url": `https://awesomeprivacy.com/${slugify(title)}/${slugify(section.name)}`,
}
})),
]
}
return {
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: [
sections.map((section, index) => ({
'@type': 'ListItem',
position: index + 1,
url: `https://awesomeprivacy.com/${slugify(title)}/${slugify(section.name)}`,
item: {
'@type': 'Service',
name: section.name,
url: `https://awesomeprivacy.com/${slugify(title)}/${slugify(section.name)}`,
},
})),
],
};
};
const makeBreadcrumbs = () => {
return [
{ name: 'Home', item: '/' },
{ name: title, item: slugify(title) },
];
return [
{ name: 'Home', item: '/' },
{ name: title, item: slugify(title) },
];
};
const makePaginationLinks = () => {
const index = categories.findIndex(category => category.name === title);
const previousCategory = index > 0 ? categories[index - 1].name : null;
const nextCategory = index < categories.length - 1 ? categories[index + 1].name : null;
return { previous: previousCategory, next: nextCategory };
const index = categories.findIndex((category) => category.name === title);
const previousCategory = index > 0 ? categories[index - 1].name : null;
const nextCategory =
index < categories.length - 1 ? categories[index + 1].name : null;
return { previous: previousCategory, next: nextCategory };
};
const { previous, next } = makePaginationLinks();
---
<Layout title={`${title} | Awesome Privacy`} breadcrumbs={makeBreadcrumbs()} customSchemaJson={makeCarasolData()}>
<Layout
title={`${title} | Awesome Privacy`}
breadcrumbs={makeBreadcrumbs()}
customSchemaJson={makeCarasolData()}
>
<Main>
<div class="breadcrumbs">
<span>
<a href="/">Awesome Privacy</a>
➔ <a href={`/${slugify(title)}`}>{title}</a>
</span>
</div>
<div class="breadcrumbs">
<span>
<a href="/">Awesome Privacy</a>
➔ <a href={`/${slugify(title)}`}>{title}</a>
</span>
</div>
<SectionList title={title} sections={sections} bigTitle={true} />
Or, <a href={`/all#${slugify(title)}`}>Browse All {title}</a>
Or, <a href={`/all#${slugify(title)}`}>Browse All {title}</a>
<div class="pagination-navigation">
{ previous ? (
<Button url={`/${slugify(previous)}`}>
<span>← Previous</span>
<p>{previous}</p>
</Button>
) : <p class="nothing"></p>}
{ next && (
<Button url={`/${slugify(next)}`}>
<span>Next →</span>
<p>{next}</p>
</Button>
)}
</div>
{
previous ? (
<Button url={`/${slugify(previous)}`}>
<span>← Previous</span>
<p>{previous}</p>
</Button>
) : (
<p class="nothing" />
)
}
{
next && (
<Button url={`/${slugify(next)}`}>
<span>Next →</span>
<p>{next}</p>
</Button>
)
}
</div>
</Main>
</Layout>
<style lang="scss">
.pagination-navigation {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
:global(.button) {
min-width: 120px;
width: fit-content;
padding: 0.25rem 1rem;
text-align: right;
&:first-child { text-align: left; }
p {
margin: 0;
font-weight: normal;
font-size: 1rem;
}
span {
font-size: 0.8rem;
}
}
.nothing {
width: 120px;
}
.go-to-category {
color: var(--foreground);
font-size: 0.8rem;
opacity: 0.5;
}
}
.pagination-navigation {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
:global(.button) {
min-width: 120px;
width: fit-content;
padding: 0.25rem 1rem;
text-align: right;
&:first-child {
text-align: left;
}
p {
margin: 0;
font-weight: normal;
font-size: 1rem;
}
span {
font-size: 0.8rem;
}
}
.nothing {
width: 120px;
}
.go-to-category {
color: var(--foreground);
font-size: 0.8rem;
opacity: 0.5;
}
}
.breadcrumbs {
opacity: 0.5;
font-size: 0.8rem;
position: absolute;
right: 1rem;
top: 1rem;
a {
color: var(--foreground);
transition: all 0.15s ease-in-out;
.breadcrumbs {
opacity: 0.5;
font-size: 0.8rem;
position: absolute;
right: 1rem;
top: 1rem;
a {
color: var(--foreground);
transition: all 0.15s ease-in-out;
&:hover {
color: var(--accent);
}
}
@media(max-width: 768px) {
display: none;
}
}
&:hover {
color: var(--accent);
}
}
@media (max-width: 768px) {
display: none;
}
}
</style>

View file

@ -1,359 +1,478 @@
---
import Layout from '@layouts/Layout.astro';
import Main from '@components/scafold/MainCard.astro';
import Icon from '@components/form/Icon.astro';
import SocialShare from '@components/form/Social.astro';
import { parseMarkdown } from '@utils/parse-markdown';
import { authorProjects, authorSocials, aboutOurData, projectRequirements, appDescription } from '../site-config';
import {
authorProjects,
authorSocials,
aboutOurData,
projectRequirements,
appDescription,
} from '../site-config';
const contributorsResource = async () => {
const url = 'https://api.github.com/repos/lissy93/personal-security-checklist/contributors?per_page=100';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch contributors');
}
interface GitHubContributor {
login: string;
avatar_url: string;
html_url: string;
}
interface Sponsor {
login: string;
avatarUrl: string;
name?: string;
}
const githubHeaders: Record<string, string> = {
'User-Agent': 'awesome-privacy',
};
const apiKey = import.meta.env.GITHUB_API_KEY;
if (apiKey) {
githubHeaders['Authorization'] = `Bearer ${apiKey}`;
}
const contributorsResource = async (): Promise<GitHubContributor[] | null> => {
const url =
'https://api.github.com/repos/lissy93/personal-security-checklist/contributors?per_page=100';
const response = await fetch(url, { headers: githubHeaders });
if (!response.ok) return null;
return await response.json();
};
const sponsorsResource = async () => {
const sponsorsResource = async (): Promise<Sponsor[] | null> => {
const url = 'https://github-sponsors.as93.workers.dev/lissy93';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch sponsors');
}
if (!response.ok) return null;
return await response.json();
};
const contributors = await contributorsResource();
const sponsors = await sponsorsResource();
const licenseContent = async () => {
const url = 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/HEAD/LICENSE';
const url =
'https://raw.githubusercontent.com/Lissy93/awesome-privacy/HEAD/LICENSE';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch license');
}
return await response.text();
};
---
<Layout title="About | Awesome Privacy" description={appDescription}>
<div class="about-page">
<Main>
<h2 id="objective">Objective</h2>
<p>
Large data-hungry corporations dominate the digital world but with little,
or no respect for your privacy.
We believe that privacy is a fundamental human right and that it should be protected.
<br /><br />
Migrating to open-source applications and those with a strong emphasis on
privacy & security will help safegaurd you from corporations,
governments, and hackers who log, store or sell your sensetive personal data.
<br /><br />
Awesome Privacy is a directory of alternative software which respects your privacy.
<br /><br />
</p>
<hr />
<Main>
<h2 id="objective">Objective</h2>
<p>
Large data-hungry corporations dominate the digital world but with
little, or no respect for your privacy. We believe that privacy is a
fundamental human right and that it should be protected.
<br /><br />
Migrating to open-source applications and those with a strong emphasis on
privacy & security will help safegaurd you from corporations, governments,
and hackers who log, store or sell your sensetive personal data.
<br /><br />
Awesome Privacy is a directory of alternative software which respects your
privacy.
<br /><br />
</p>
<hr />
<h2 id="creteria">Software Requirements</h2>
<div class="software-requirements"><p set:html={parseMarkdown(projectRequirements)}></p></div>
<hr />
<h2 id="creteria">Software Requirements</h2>
<div class="software-requirements">
<p set:html={parseMarkdown(projectRequirements)} />
</div>
<hr />
<h2 id="our-data">Our Data</h2>
<div class="about-the-data"><p set:html={parseMarkdown(aboutOurData)}></p></div>
<hr />
<h2 id="our-data">Our Data</h2>
<div class="about-the-data">
<p set:html={parseMarkdown(aboutOurData)} />
</div>
<hr />
<h2 id="contributing">Contributing</h2>
<p>
Awesome Privacy (including all data and code) is fully open source,
maintained by a core group of volenteers, with a lot of help from the community.
<br /><br />
We welcome suggestions, additions, edits and removals to the list.<br />
It's thanks to contributors like you that this project is possible 💜
<br /><br />
To get started, head over to our <a href="https://github.com/lissy93/awesome-privacy">GitHub repository</a>.
From here, you'll be able to edit <a href="#"><code>awesome-privacy.yml</code></a>
where all data is stored, and then submit your changes as a pull request.
<br /><br />
If you're new to open source, you can find some resources to get you started
at <a href="https://git-in.to">git-in.to</a>, but feel free to reach out if you need any help 😊
</p>
<hr />
<h2 id="contributing">Contributing</h2>
<p>
Awesome Privacy (including all data and code) is fully open source,
maintained by a core group of volenteers, with a lot of help from the
community.
<br /><br />
We welcome suggestions, additions, edits and removals to the list.<br />
It's thanks to contributors like you that this project is possible 💜
<br /><br />
To get started, head over to our <a
href="https://github.com/lissy93/awesome-privacy">GitHub repository</a
>. From here, you'll be able to edit <a href="#"
><code>awesome-privacy.yml</code></a
>
where all data is stored, and then submit your changes as a pull request.
<br /><br />
If you're new to open source, you can find some resources to get you started
at <a href="https://git-in.to">git-in.to</a>, but feel free to reach out
if you need any help 😊
</p>
<hr />
<h2 id="acknowledgements">Acknowledgements</h2>
<h3 id="sponsors">Sponsors</h3>
<p>Huge thanks to the following sponsors, for their ongoing support 💖</p>
<div class="user-list">
{sponsorsResource().then((sponsors) => {
return sponsors.map((sponsor: any) => (
<a class="user" href={`https://github.com/${sponsor.login}`} target="_blank" rel="noreferrer">
<img src={sponsor.avatarUrl} alt={sponsor.login} />
<p>{sponsor.name || sponsor.login}</p>
</a>
));
})}
</div>
<h2 id="acknowledgements">Acknowledgements</h2>
<h3 id="sponsors">Sponsors</h3>
<p>Huge thanks to the following sponsors, for their ongoing support 💖</p>
{
sponsors ? (
<div class="user-list">
{sponsors.map((sponsor) => (
<a
class="user"
href={`https://github.com/${sponsor.login}`}
target="_blank"
rel="noreferrer"
>
<img src={sponsor.avatarUrl} alt={sponsor.login} />
<p>{sponsor.name || sponsor.login}</p>
</a>
))}
</div>
) : (
<img
class="fallback-img"
src="https://readme-contribs.as93.net/sponsors/lissy93?perRow=12&shape=squircle&textColor=ffffff&limit=96"
alt="Sponsors"
/>
)
}
<h3 id="contributors">Contributors</h3>
<p>
This project exists thanks to all the people who've helped build and maintain it.<br />
Special thanks to the below, top-100 contributors 🌟
</p>
<div class="user-list">
{contributorsResource().then((sponsors) => {
return sponsors.map((sponsor: any) => (
<a class="user" href={sponsor.html_url} target="_blank" rel="noreferrer">
<img src={sponsor.avatar_url} alt={sponsor.login} />
<p>{sponsor.login}</p>
</a>
));
})}
</div>
<h3 id="contributors">Contributors</h3>
<p>
This project exists thanks to all the people who've helped build and
maintain it.<br />
Special thanks to the below, top-100 contributors 🌟
</p>
{
contributors ? (
<div class="user-list">
{contributors.map((contributor) => (
<a
class="user"
href={contributor.html_url}
target="_blank"
rel="noreferrer"
>
<img src={contributor.avatar_url} alt={contributor.login} />
<p>{contributor.login}</p>
</a>
))}
</div>
) : (
<img
class="fallback-img"
src="https://readme-contribs.as93.net/contributors/lissy93/awesome-privacy?perRow=12&shape=squircle&textColor=ffffff&limit=96"
alt="Contributors"
/>
)
}
<hr />
<hr />
<h2 id="support-us">Help us out</h2>
<p>
Awesome Privacy is a free, open source and community-maintained resource.<br />
There's a few ways you can support us:
<ul class="help-list">
<li>Visiting, forking, or starring or our <a href="https://github.com/lissy93/awesome-privacy">GitHub repository</a></li>
<li>Help us keep our info up-to-date, but submitting an addition, removal or ammendment</li>
<li>Leave feedback on services that you've used, to help others make a more informed decission</li>
<li>Consider <a href="https://github.com/sponsors/Lissy93">sponsoring us on GitHub</a>, if you're able 💖</li>
<li>Share Awesome Privacy with your network</li>
</ul>
<SocialShare />
</p>
<h2 id="support-us">Help us out</h2>
<p>
Awesome Privacy is a free, open source and community-maintained
resource.<br />
There's a few ways you can support us:
<ul class="help-list">
<li>
Visiting, forking, or starring or our <a
href="https://github.com/lissy93/awesome-privacy"
>GitHub repository</a
>
</li>
<li>
Help us keep our info up-to-date, but submitting an addition,
removal or ammendment
</li>
<li>
Leave feedback on services that you've used, to help others make a
more informed decission
</li>
<li>
Consider <a href="https://github.com/sponsors/Lissy93"
>sponsoring us on GitHub</a
>, if you're able 💖
</li>
<li>Share Awesome Privacy with your network</li>
</ul>
<SocialShare />
</p>
<hr />
<hr />
<h2 id="author">Author</h2>
<article class="author">
<h2 id="author">Author</h2>
<article class="author">
<p>
This project was originally started by
me, <a href="https://aliciasykes.com">Alicia Sykes</a>
This project was originally started by me, <a
href="https://aliciasykes.com">Alicia Sykes</a
>
(with a lot of help from the community)
</p>
<br />
<p class="about">
I build apps which aim to help people escape big tech, secure their data,
and protect their privacy.
I build apps which aim to help people escape big tech, secure their
data, and protect their privacy.
</p>
<br />
<div class="pic-and-socials">
<a href="https://aliciasykes.com">
<img class="pic" width="180" height="240" alt="Alicia Sykes" src="https://i.ibb.co/fq10qhL/DSC-0597.jpg" />
<img
class="pic"
width="180"
height="240"
alt="Alicia Sykes"
src="https://cdn.as93.net/profile-pictures/dsc_0597"
/>
</a>
<div class="socials">
{
authorSocials.map((social: { link: string; color: string; icon: string; }) => (
<a href={social.link} style={`--color: ${social.color}`} target="_blank">
<Icon icon={social.icon} width={24} height={24} />
</a>
))
authorSocials.map(
(social: { link: string; color: string; icon: string }) => (
<a
href={social.link}
style={`--color: ${social.color}`}
target="_blank"
>
<Icon icon={social.icon} width={24} height={24} />
</a>
),
)
}
</div>
</div>
<p class="more-about">
I have a particular interest in self-hosting, Linux, security and OSINT.<br />
I have a particular interest in self-hosting, Linux, security and
OSINT.<br />
So if this type of stuff interests you, check out these other projects:
</p>
<ul>
{
authorProjects.map((project: { title: string; icon: string; link: string; description: string; }) => (
<li>
<img width="20" height="20" alt={project.title} src={project.icon} />
<a href={project.link} target="_blank" rel="noreferrer">
{project.title}
</a> - {project.description}
</li>
))
authorProjects.map(
(project: {
title: string;
icon: string;
link: string;
description: string;
}) => (
<li>
<img
width="20"
height="20"
alt={project.title}
src={project.icon}
/>
<a href={project.link} target="_blank" rel="noreferrer">
{project.title}
</a>{' '}
- {project.description}
</li>
),
)
}
<li>
...and loads more, over at <a href="https://apps.aliciasykes.com/">apps.aliciasykes.com</a>
...and loads more, over at <a href="https://apps.aliciasykes.com/"
>apps.aliciasykes.com</a
>
and on <a href="https://github.com/lissy93">github.com/lissy93</a>
</li>
</ul>
<br />
<p>
If you'd like to support the ongoing work on Awesome Privacy,
and other similar projects,
consider <a href="https://github.com/sponsors/lissy93">sponsoring me</a> on GitHub 💖
If you'd like to support the ongoing work on Awesome Privacy, and
other similar projects, consider <a
href="https://github.com/sponsors/lissy93">sponsoring me</a
> on GitHub 💖
</p>
</article>
</article>
<hr />
<hr />
<h2 id="license">License</h2>
<p>
All content on Awesome Privacy is freely available, within the public domain,
licensed under Creative Commons Zero v1.0 Universal.
The code for the website is licensed under MIT.
</p>
<p>
{licenseContent().then((license) => {
return (<pre class="license-content">{license}</pre>)
})}
</p>
</Main>
<h2 id="license">License</h2>
<p>
All content on Awesome Privacy is freely available, within the public
domain, licensed under Creative Commons Zero v1.0 Universal. The code
for the website is licensed under MIT.
</p>
<p>
{
licenseContent().then((license) => {
return <pre class="license-content">{license}</pre>;
})
}
</p>
</Main>
</div>
</Layout>
<style lang="scss">
h2 {
font-size: 2rem;
margin: 2rem 0 1rem 0;
}
p {
font-size: 1.2rem;
}
hr {
margin: 2rem 0;
border: 0;
border-top: 2px solid var(--accent);
&:nth-child(6) {
border-color: var(--accent-3);
h2 {
font-size: 2rem;
margin: 1rem 0 0.5rem 0;
}
&:nth-child(9) {
border-color: var(--accent-2);
}
}
h3 {
font-size: 1.6rem;
}
.software-requirements, .about-the-data {
:global(p) {
font-size: 1.2rem;
margin: 0 0 0.5rem 0;
}
:global(ul) {
padding-left: 0.5rem;
list-style: none;
font-size: 1.2rem;
:global(li ul) {
padding: 0 0 0.5rem 1rem;
list-style: circle;
}
}
:global(h3) {
font-size: 1.6rem;
margin-bottom: 0;
}
:global(strong) {
font-weight: 500;
}
:global(small) {
font-size: 0.8rem;
opacity: 0.7;
:global(a) {
color: var(--accent-3);
}
}
:global(code) {
font-family: 'Courier New', Courier, monospace;
background: #acabb782;
padding: 0.2rem 0.4rem;
border-radius: var(--curve-sm);
font-size: 0.9rem;
}
}
.user-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin: 2rem 0;
.user {
width: 6rem;
overflow: hidden;
text-align: center;
img {
width: 5rem;
height: 5rem;
border-radius: var(--curve-md);
}
p {
font-size: 1rem;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
.license-content {
max-height: 500px;
overflow: scroll;
background: var(--background-form);
width: fit-content;
border-radius: var(--curve-sm);
padding: 0.5rem;
font-size: 0.7rem;
font-family: mono;
max-width: 100vw;
white-space: pre-line;
}
.help-list {
padding-left: 1rem;
list-style: circle;
font-size: 1.1rem;
}
.author {
p {
margin: 1rem 0 0 0;
font-size: 1.2rem;
}
ul {
padding-left: 1.5rem;
hr {
margin: 2rem 0;
border: 0;
border-top: 2px solid var(--accent);
&:nth-child(6) {
border-color: var(--accent-3);
}
&:nth-child(9) {
border-color: var(--accent-2);
}
}
h3 {
font-size: 1.6rem;
}
.software-requirements,
.about-the-data {
:global(p) {
font-size: 1.2rem;
margin: 0 0 0.5rem 0;
}
:global(ul) {
padding-left: 0.5rem;
list-style: none;
font-size: 1.2rem;
:global(li ul) {
padding: 0 0 0.5rem 1rem;
list-style: circle;
}
}
:global(h3) {
font-size: 1.6rem;
margin-bottom: 0;
}
:global(strong) {
font-weight: 500;
}
:global(small) {
font-size: 0.8rem;
opacity: 0.7;
:global(a) {
color: var(--accent-3);
}
}
:global(code) {
font-family: 'Courier New', Courier, monospace;
background: #acabb782;
padding: 0.2rem 0.4rem;
border-radius: var(--curve-sm);
font-size: 0.9rem;
}
}
.user-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin: 2rem 0;
.user {
width: 6rem;
overflow: hidden;
text-align: center;
padding: 0.5rem;
border-radius: var(--curve-sm);
transition: background 0.2s ease-in-out;
&:hover {
background: var(--background-form);
}
img {
width: 5rem;
height: 5rem;
border-radius: var(--curve-md);
}
p {
margin: 0.5rem auto 0 auto;
font-size: 1rem;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
.fallback-img {
max-width: 100%;
margin: 2rem 0;
border-radius: var(--curve-sm);
}
.license-content {
max-height: 500px;
overflow: scroll;
background: var(--background-form);
width: fit-content;
border-radius: var(--curve-sm);
padding: 0.5rem;
font-size: 0.7rem;
font-family: 'Lekton', monospace;
max-width: 100vw;
white-space: pre-line;
}
.help-list {
padding-left: 1rem;
list-style: circle;
margin-bottom: 0;
li {
font-size: 1.1rem;
font-size: 1.1rem;
}
.author {
p {
margin: 1rem 0 0 0;
}
}
.about {
font-size: 1.4rem;
font-style: italic;
font-weight: 300;
color: var(--accent-3);
text-align: center;
max-width: 550px;
margin: 0 auto;
}
small {
font-size: 1rem;
margin: 1rem 0;
}
.pic-and-socials {
float:right;
img {
margin: 0.5rem 1rem;
border-radius: var(--curve-md);
border: 2px solid var(--box-outline);
box-shadow: 4px 4px 0 var(--box-outline);
ul {
padding-left: 1.5rem;
list-style: circle;
margin-bottom: 0;
li {
font-size: 1.1rem;
}
}
.socials {
display: flex;
margin: 0.5rem 1rem;
justify-content: space-between;
:global(a svg) {
color: var(--foreground);
transition: color 0.2s ease-in-out;
&:hover {
color: var(--color);
.about {
font-size: 1.4rem;
font-style: italic;
font-weight: 300;
color: var(--accent-3);
text-align: center;
max-width: 550px;
margin: 0 auto;
}
small {
font-size: 1rem;
margin: 1rem 0;
}
.pic-and-socials {
float: right;
img {
margin: 0.5rem 1rem;
border-radius: var(--curve-md);
border: 2px solid var(--box-outline);
box-shadow: 4px 4px 0 var(--box-outline);
}
.socials {
display: flex;
margin: 0.5rem 1rem;
justify-content: space-between;
:global(a svg) {
color: var(--foreground);
transition: color 0.2s ease-in-out;
&:hover {
color: var(--color);
}
}
}
}
}
}
</style>

View file

@ -3,19 +3,22 @@ import yaml from 'js-yaml';
import type { AwesomePrivacy } from '../../types/Service';
const awesomePrivacyYamlPath = 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
const awesomePrivacyYamlPath =
'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
export const GET: APIRoute = async () => {
const yamlContent = await fetch(awesomePrivacyYamlPath)
.then(response => response.text())
.catch(error => {
return JSON.stringify({ error: "Failed to fetch YAML file", details: error });
});
.then((response) => response.text())
.catch((error) => {
return JSON.stringify({
error: 'Failed to fetch YAML file',
details: error,
});
});
const yamlObject = yaml.load(yamlContent) as AwesomePrivacy;
return new Response(
JSON.stringify(yamlObject), { headers: { 'content-type': 'application/json' } }
)
}
return new Response(JSON.stringify(yamlObject), {
headers: { 'content-type': 'application/json' },
});
};

View file

@ -3,43 +3,45 @@ import Layout from '@layouts/Layout.astro';
import Button from '@components/form/Button.astro';
import MainCard from '@components/scafold/MainCard.astro';
---
<Layout title="API Docs | Awesome Privacy">
<MainCard>
<div id="swagger-ui"></div>
<div class="go">
<Button url="https://api.awesome-privacy.xyz" text="Try it Out" />
</div>
</MainCard>
<MainCard>
<div id="swagger-ui"></div>
<div class="go">
<Button url="https://api.awesome-privacy.xyz" text="Try it Out" />
</div>
</MainCard>
</Layout>
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.1.3/swagger-ui.css">
<script is:inline src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.1.3/swagger-ui-bundle.js"></script>
<link
rel="stylesheet"
type="text/css"
href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.1.3/swagger-ui.css"
/>
<script
is:inline
src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.1.3/swagger-ui-bundle.js"
></script>
<script type="module" is:inline>
window.onload = () => {
SwaggerUIBundle({
url: 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/api/open-api-spec.yml',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
]
});
}
window.onload = () => {
SwaggerUIBundle({
url: 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/api/open-api-spec.yml',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset,
],
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
});
};
</script>
<style lang="scss">
.go {
display: flex;
justify-content: center;
margin-top: 1rem;
}
.go {
display: flex;
justify-content: center;
margin-top: 1rem;
}
</style>

View file

@ -14,35 +14,47 @@ interface LineNumberData {
[service: string]: {
lineNumbers: LineNumberRange | null;
yaml: string;
}
};
};
};
}
const awesomePrivacyYamlPath = 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
const awesomePrivacyYamlPath =
'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
/**
* Given a service object and an array of string lines from the raw YAML
* Find the starting and ending line number for that service
*/
const calculateServiceRange = (service: Service, category: Category, yamlLines: string[]): LineNumberRange | null => {
const calculateServiceRange = (
service: Service,
category: Category,
yamlLines: string[],
): LineNumberRange | null => {
const lookFor = `- name: ${service.name}`;
const categoryStart = yamlLines.findIndex(line => line.includes(category.name));
const start = yamlLines.slice(categoryStart).findIndex(line => line.includes(lookFor)) + categoryStart + 1;
const categoryStart = yamlLines.findIndex((line) =>
line.includes(category.name),
);
const start =
yamlLines.slice(categoryStart).findIndex((line) => line.includes(lookFor)) +
categoryStart +
1;
if (start === -1) return null;
const detectEnd = (line: string) => {
return line.trim().length === 0
|| line.startsWith(' - ')
|| line.includes('- name:')
|| line.includes('notableMentions:')
|| line.includes('furtherInfo:')
|| line.includes('wordOfWarning:')
}
return (
line.trim().length === 0 ||
line.startsWith(' - ') ||
line.includes('- name:') ||
line.includes('notableMentions:') ||
line.includes('furtherInfo:') ||
line.includes('wordOfWarning:')
);
};
const remainingLines = yamlLines.slice(start);
const end = start + remainingLines.findIndex(detectEnd);
return { start, end };
}
};
/**
* Given a service object, convert it into a correctly formatted YAML string
@ -55,7 +67,10 @@ const convertJsonIntoYaml = (service: Service): string => {
* Given the object representation of the YAML and the array of lines from the raw YAML
* Organize the data into a format that can be returned as JSON
*/
const makeResults = (yamlObject: AwesomePrivacy, yamlLines: string[]): LineNumberData => {
const makeResults = (
yamlObject: AwesomePrivacy,
yamlLines: string[],
): LineNumberData => {
const organizedData: LineNumberData = {};
(yamlObject.categories || []).forEach((category) => {
organizedData[category.name] = {};
@ -70,16 +85,18 @@ const makeResults = (yamlObject: AwesomePrivacy, yamlLines: string[]): LineNumbe
});
});
return organizedData;
}
};
export const GET: APIRoute = async () => {
// Fetch the raw YAML from the awesome-privacy repository
const yamlContent = await fetch(awesomePrivacyYamlPath)
.then(response => response.text())
.catch(error => {
return JSON.stringify({ error: "Failed to fetch YAML file", details: error });
});
.then((response) => response.text())
.catch((error) => {
return JSON.stringify({
error: 'Failed to fetch YAML file',
details: error,
});
});
// Array of lines from the raw YAML
const yamlLines: string[] = yamlContent.split('\n');
@ -90,7 +107,7 @@ export const GET: APIRoute = async () => {
// Make results
const results = makeResults(yamlObject, yamlLines);
return new Response(
JSON.stringify(results), { headers: { 'content-type': 'application/json' } }
)
}
return new Response(JSON.stringify(results), {
headers: { 'content-type': 'application/json' },
});
};

View file

@ -1,5 +1,4 @@
---
import Layout from '@layouts/Layout.astro';
import SectionList from '@components/things/SectionList.astro';
@ -7,210 +6,221 @@ import { fetchData } from '@utils/fetch-data';
import type { Category } from '../types/Service';
const categories: Category[] = (await fetchData())?.categories;
---
<Layout title="Browse">
<div class="head-wrap">
<h2>Browse</h2>
<span>
<input
id="searchInput"
type="search"
placeholder="Start typing to filter..."
/>
<p id="press-enter-msg" class="press-enter">
Press enter for deep search
</p>
</span>
</div>
<p class="sitemap-link">
Not sure what you're looking for? Take a look through <a href="/all"
>all listings</a
>
</p>
<div class="head-wrap">
<h2>Browse</h2>
<span>
<input id="searchInput" type="search" placeholder="Start typing to filter..." />
<p id="press-enter-msg" class="press-enter">Press enter for deep search</p>
</span>
</div>
<p class="sitemap-link">Not sure what you're looking for? Take a look through <a href="/all">all listings</a></p>
<ul class="categories">
{
categories.map((category) => (
<li class="category">
<SectionList title={category.name} sections={category.sections} />
</li>
))
}
</ul>
<ul class="categories">
{categories.map((category) => (
<li class="category">
<SectionList title={category.name} sections={category.sections} />
</li>
))}
</ul>
<div class="no-results">
<p class="zilch">Nothing found 😢</p>
<p>Try a <a href="/search">deep search</a> instead</p>
</div>
<div class="no-results">
<p class="zilch">Nothing found 😢</p>
<p>Try a <a href="/search">deep search</a> instead</p>
</div>
</Layout>
<script>
// Time for some good ol' fashioned vanilla JS...
// I reccomend you not to look at this code for too long, it's not pretty.
const filterInput = document.querySelector('input');
const categories = document.querySelectorAll<HTMLElement>('.category');
const pressEnterMsg = document.getElementById('press-enter-msg') as HTMLElement | null;
const noResults = document.querySelector('.no-results') as HTMLElement | null;
// Time for some good ol' fashioned vanilla JS...
// I reccomend you not to look at this code for too long, it's not pretty.
const filterInput = document.querySelector('input');
const categories = document.querySelectorAll<HTMLElement>('.category');
const pressEnterMsg = document.getElementById(
'press-enter-msg',
) as HTMLElement | null;
const noResults = document.querySelector('.no-results') as HTMLElement | null;
if (!pressEnterMsg || !noResults) {
throw new Error('No pressEnterMsg or noResults');
};
if (!pressEnterMsg || !noResults) {
throw new Error('No pressEnterMsg or noResults');
}
filterInput?.addEventListener('input', (e) => {
let resultsCount = 0;
const filter = (e.target as HTMLInputElement).value.toLowerCase();
if (filter.length > 0) {
pressEnterMsg.style.visibility = 'visible';
} else {
pressEnterMsg.style.visibility = 'hidden';
}
categories.forEach((category) => {
const titleElement = category.querySelector('.category-title');
const title = titleElement ? titleElement.textContent?.toLowerCase() : '';
const sections = category.querySelectorAll<HTMLElement>('.section');
let count = 0;
sections.forEach((section) => {
const sectionTitle = section.textContent?.toLowerCase();
if (sectionTitle?.includes(filter)) {
section.style.display = 'block';
count++;
resultsCount++;
} else {
section.style.display = 'none';
}
});
if (title && title.includes(filter)) {
category.style.display = 'inline-flex';
resultsCount++;
} else if (count === 0) {
category.style.display = 'none';
}
});
noResults.style.display = resultsCount === 0 ? 'block' : 'none';
});
filterInput?.addEventListener('input', (e) => {
let resultsCount = 0;
const filter = (e.target as HTMLInputElement).value.toLowerCase();
if (filter.length > 0) {
pressEnterMsg.style.visibility = 'visible';
} else {
pressEnterMsg.style.visibility = 'hidden';
}
categories.forEach((category) => {
const titleElement = category.querySelector('.category-title');
const title = titleElement ? titleElement.textContent?.toLowerCase() : '';
const sections = category.querySelectorAll<HTMLElement>('.section');
let count = 0;
sections.forEach((section) => {
const sectionTitle = section.textContent?.toLowerCase();
if (sectionTitle?.includes(filter)) {
section.style.display = 'block';
count++;
resultsCount++;
} else {
section.style.display = 'none';
}
});
if (title && title.includes(filter)) {
category.style.display = 'inline-flex';
resultsCount++;
} else if (count === 0) {
category.style.display = 'none';
}
});
noResults.style.display = resultsCount === 0 ? 'block' : 'none';
});
if (typeof window !== 'undefined') {
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null;
if (searchInput === null) return;
searchInput.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
window.location.href = `/search/${encodeURIComponent(searchInput.value.trim())}`;
}
if (event.key === 'Escape') {
searchInput.value = '';
searchInput.blur();
pressEnterMsg.style.visibility = 'hidden';
categories.forEach((category) => {
category.style.display = 'inline-flex';
const sections = category.querySelectorAll<HTMLElement>('.section');
sections.forEach((section) => {
section.style.display = 'block';
});
});
}
});
});
}
if (typeof window !== 'undefined') {
document.addEventListener('DOMContentLoaded', function () {
const searchInput = document.getElementById(
'searchInput',
) as HTMLInputElement | null;
if (searchInput === null) return;
searchInput.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
window.location.href = `/search/${encodeURIComponent(searchInput.value.trim())}`;
}
if (event.key === 'Escape') {
searchInput.value = '';
searchInput.blur();
pressEnterMsg.style.visibility = 'hidden';
categories.forEach((category) => {
category.style.display = 'inline-flex';
const sections = category.querySelectorAll<HTMLElement>('.section');
sections.forEach((section) => {
section.style.display = 'block';
});
});
}
});
});
}
</script>
<style lang="scss">
.head-wrap {
margin: 1rem auto 0 auto;
width: 85vw;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
.press-enter {
margin: 0.5rem 0 0 0;
opacity: 0.5;
visibility: hidden;
}
input {
padding: 0.5rem;
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
font-size: 1.2rem;
box-shadow: 3px 3px 0 var(--box-outline);
color: var(--accent-3);
background: var(--background-form);
&:focus {
outline: none;
box-shadow: 4px 4px 0 var(--box-outline);
}
}
h2 {
font-family: 'Lekton', sans-serif;
font-weight: bold;
font-size: 3rem;
margin: 0;
color: var(--accent-3);
}
}
.head-wrap {
margin: 1rem auto 0 auto;
width: 85vw;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
.press-enter {
margin: 0.5rem 0 0 0;
opacity: 0.5;
visibility: hidden;
}
input {
padding: 0.5rem;
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
font-size: 1.2rem;
box-shadow: 3px 3px 0 var(--box-outline);
color: var(--accent-3);
background: var(--background-form);
&:focus {
outline: none;
box-shadow: 4px 4px 0 var(--box-outline);
}
}
h2 {
font-family: "Lekton", sans-serif;
font-weight: bold;
font-size: 3rem;
margin: 0;
color: var(--accent-3);
}
}
.sitemap-link {
margin: 0 auto;
width: 85vw;
a {
color: var(--accent-3);
}
}
.sitemap-link {
margin: 0 auto;
width: 85vw;
a {
color: var(--accent-3);
}
}
.no-results {
text-align: center;
width: 85vw;
margin: 2rem auto 4rem auto;
display: none;
.zilch {
font-size: 2rem;
color: var(--accent-3);
opacity: 0.5;
font-weight: bold;
}
p {
margin: 0.25rem;
}
}
.categories {
columns: 6 300px;
column-gap: 1rem;
margin: 0 auto;
width: 85vw;
max-width: 1800px;
padding: 1rem 0 2rem 0;
@media(max-width: 768px) {
padding: 0;
width: 95%;
}
.category {
background: var(--accent-fg);
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
box-shadow: 4px 4px 0 var(--box-outline);
padding: 1rem;
width: 85%;
display: inline-flex;
flex-direction: column;
margin: 1rem;
.category-title {
text-decoration: none;
color: var(--foreground);
}
h3 {
font-family: "Lekton", sans-serif;
font-weight: bold;
margin: 0;
font-size: 1.8rem;
}
.sections {
padding-left: 1rem;
.section {
a {
text-decoration: none;
color: var(--foreground);
}
.service-count {
color: var(--accent-3);
}
}
}
}
}
.no-results {
text-align: center;
width: 85vw;
margin: 2rem auto 4rem auto;
display: none;
.zilch {
font-size: 2rem;
color: var(--accent-3);
opacity: 0.5;
font-weight: bold;
}
p {
margin: 0.25rem;
}
}
.categories {
columns: 6 300px;
column-gap: 1rem;
margin: 0 auto;
width: 85vw;
max-width: 1800px;
padding: 1rem 0 2rem 0;
@media (max-width: 768px) {
padding: 0;
width: 95%;
}
.category {
background: var(--accent-fg);
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
box-shadow: 4px 4px 0 var(--box-outline);
padding: 1rem;
width: 85%;
display: inline-flex;
flex-direction: column;
margin: 1rem;
.category-title {
text-decoration: none;
color: var(--foreground);
}
h3 {
font-family: 'Lekton', sans-serif;
font-weight: bold;
margin: 0;
font-size: 1.8rem;
}
.sections {
padding-left: 1rem;
.section {
a {
text-decoration: none;
color: var(--foreground);
}
.service-count {
color: var(--accent-3);
}
}
}
}
}
</style>

View file

@ -1,5 +1,4 @@
---
import Layout from '@layouts/Layout.astro';
import Hero from '@components/Hero.astro';
import Search from '@components/things/Search.svelte';
@ -10,14 +9,14 @@ import { fetchData } from '@utils/fetch-data';
import Button from '@components/form/Button.astro';
import type { Category } from 'src/types/Service';
const categories = (await fetchData())?.categories || [] as Category[];
const description = 'Privacy is a fundamental human right; '
+ 'without it, we\'re just open books in a world where everyone\'s '
+ 'watching. Let\'s take control back.\n'
+ 'Migrating open-source applications which do not collect, sell or log your data is a great first step.'
+ 'Awesome Privacy is a directory of alternative privacy-respecting software and services.';
const categories = (await fetchData())?.categories || ([] as Category[]);
const description =
'Privacy is a fundamental human right; ' +
"without it, we're just open books in a world where everyone's " +
"watching. Let's take control back.\n" +
'Migrating open-source applications which do not collect, sell or log your data is a great first step.' +
'Awesome Privacy is a directory of alternative privacy-respecting software and services.';
---
<Layout title="Home | Awesome Privacy" hideNav={true} description={description}>
@ -29,11 +28,13 @@ const description = 'Privacy is a fundamental human right; '
<Search client:visible data={categories} />
<h2 class="browse-title"><a href="/browse">Browse</a></h2>
<ul class="categories">
{categories.map((category) => (
<li class="category">
<SectionList title={category.name} sections={category.sections} />
</li>
))}
{
categories.map((category) => (
<li class="category">
<SectionList title={category.name} sections={category.sections} />
</li>
))
}
</ul>
<div class="view-all">
<span>Or, just</span>
@ -48,28 +49,33 @@ const description = 'Privacy is a fundamental human right; '
</h2>
<div class="about-summary">
<p>
Awesome Privacy is a collection of privacy-respecting services and tools.
The aim is to help you escape big tech, and choose software that respects your privacy.
Awesome Privacy is a collection of privacy-respecting services and
tools. The aim is to help you escape big tech, and choose software that
respects your privacy.
</p>
<p>
Why? Because privacy is a fundamental human right; without it, we're just open books
in a world where everyone's watching. Let's take control back.
Why? Because privacy is a fundamental human right; without it, we're
just open books in a world where everyone's watching. Let's take control
back.
</p>
<p>
Noticed something that should be added / removed / amended?
We're a community-driven resource, so welcome contributions of any nature.
All content and code is <a href="https://github.com/lissy93/awesome-privacy">open source</a>.
Noticed something that should be added / removed / amended? We're a
community-driven resource, so welcome contributions of any nature. All
content and code is <a href="https://github.com/lissy93/awesome-privacy"
>open source</a
>.
</p>
<p>
If you've found Awesome Privacy useful, help us out by sharing it with others,
contributing, or consider <a href="https://github.com/sponsors/lissy93">sponsoring me</a> on GitHub.
If you've found Awesome Privacy useful, help us out by sharing it with
others, contributing, or consider <a
href="https://github.com/sponsors/lissy93">sponsoring me</a
> on GitHub.
</p>
</div>
<div class="view-all">
<span>Want to learn more?</span>
<Button url="/about" text="Keep Reading..." />
</div>
</main>
</Layout>
@ -81,8 +87,8 @@ const description = 'Privacy is a fundamental human right; '
max-width: calc(100% - 5rem);
font-size: 20px;
line-height: 1.6;
@media(max-width: 768px) {
padding: 0;
@media (max-width: 768px) {
padding: 0;
}
.view-all {
text-align: center;
@ -97,41 +103,43 @@ const description = 'Privacy is a fundamental human right; '
h2 {
font-size: 3rem;
color: var(--accent-3);
font-family: "Lekton", sans-serif;
font-family: 'Lekton', sans-serif;
text-align: center;
margin: 3rem 0 1rem 0;
a {
text-decoration: none;
color: var(--accent-3);
font-family: "Lekton", sans-serif;
font-family: 'Lekton', sans-serif;
position: relative;
&:after {
&:after {
background: none repeat scroll 0 0 transparent;
bottom: 0;
content: "";
content: '';
display: block;
height: 3px;
left: 50%;
position: absolute;
background: var(--accent);
transition: width 0.3s ease 0s, left 0.3s ease 0s;
transition:
width 0.3s ease 0s,
left 0.3s ease 0s;
width: 0;
}
&:hover:after {
width: 100%;
left: 0;
&:hover:after {
width: 100%;
left: 0;
}
}
}
.about-summary {
background: var(--accent-fg);
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
box-shadow: 4px 4px 0 var(--box-outline);
padding: 1rem;
width: 85%;
margin: 0 auto;
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
box-shadow: 4px 4px 0 var(--box-outline);
padding: 1rem;
width: 85%;
margin: 0 auto;
}
.categories {
@ -148,7 +156,7 @@ const description = 'Privacy is a fundamental human right; '
display: inline-flex;
flex-direction: column;
margin: 1rem;
@media(max-width: 768px) {
@media (max-width: 768px) {
margin: 1rem 0;
width: 90%;
}
@ -157,7 +165,7 @@ const description = 'Privacy is a fundamental human right; '
color: var(--foreground);
}
h3 {
font-family: "Lekton", sans-serif;
font-family: 'Lekton', sans-serif;
font-weight: bold;
margin: 0;
font-size: 1.8rem;
@ -176,6 +184,4 @@ const description = 'Privacy is a fundamental human right; '
}
}
}
</style>

View file

@ -1,15 +1,12 @@
---
import Layout from '@layouts/Layout.astro';
import SavedServices from '@components/things/SavedServices.svelte';
import GetSharableLink from '@components/things/GetSharableLink.svelte';
import { fetchData } from '@utils/fetch-data';
import Button from '@components/form/Button.astro';
import EditableTitle from '@components/form/EditableTitle.svelte';
import type { Category } from '../../types/Service';
const categories = (await fetchData())?.categories || [] as Category[];
const categories = (await fetchData())?.categories || ([] as Category[]);
export const prerender = false;
@ -17,85 +14,92 @@ const inventoryId = Astro.params.inventoryId || 'Inventory';
let cheekyLilError = '';
function makeTitle(input: string): string {
return (input.includes('_') ? input : `mystry_${input}`)
.split('_')[1]
.replace(/-/g, ' ')
.replace(/\b\w/g, (match) => match.toUpperCase());
return (input.includes('_') ? input : `mystry_${input}`)
.split('_')[1]
.replace(/-/g, ' ')
.replace(/\b\w/g, (match) => match.toUpperCase());
}
const serviceList = await fetch(`https://awesome-privacy-share-api.as93.net/${inventoryId}`).then((res) => res.json()) || [];
const serviceList =
(await fetch(
`https://awesome-privacy-share-api.as93.net/${inventoryId}`,
).then((res) => res.json())) || [];
if (serviceList.error) {
cheekyLilError = serviceList.error;
cheekyLilError = serviceList.error;
}
---
<Layout title="Saved Services">
<main>
<h2>{makeTitle(inventoryId)}</h2>
{cheekyLilError && (
<div class="error">
<p class="oh-deary-me">An error occoured</p>
<p class="what-the-fuck-happened">{cheekyLilError}</p>
<p class="what-next">
We're sorry about that.<br />
Try going <a href="/">back home</a>,
or <a href="https://github.com/Lissy93/awesome-privacy/issues/new/choose">raising a ticket</a> on
GitHub.
</p>
</div>
)}
<SavedServices allData={categories} serviceList={serviceList} client:load />
<div class="buttons">
<p>Not found what you're looking for?</p>
<Button url="/all">Browse Services</Button>
</div>
</main>
<main>
<h2>{makeTitle(inventoryId)}</h2>
{
cheekyLilError && (
<div class="error">
<p class="oh-deary-me">An error occoured</p>
<p class="what-the-fuck-happened">{cheekyLilError}</p>
<p class="what-next">
We're sorry about that.
<br />
Try going <a href="/">back home</a>, or{' '}
<a href="https://github.com/Lissy93/awesome-privacy/issues/new/choose">
raising a ticket
</a>{' '}
on GitHub.
</p>
</div>
)
}
<SavedServices allData={categories} serviceList={serviceList} client:load />
<div class="buttons">
<p>Not found what you're looking for?</p>
<Button url="/all">Browse Services</Button>
</div>
</main>
</Layout>
<style lang="scss">
main {
margin: 0 auto 2rem auto;
padding: 1rem;
width: 1200px;
max-width: calc(100% - 5rem);
display: flex;
justify-content: space-between;
flex-direction: column;
min-height: calc(100vh - 12rem);
font-size: 1.25rem;
h2 {
font-family: "Lekton", sans-serif;
font-weight: bold;
font-size: 3rem;
margin: 0;
color: var(--accent-3);
}
.buttons {
margin: 1rem auto;
display: flex;
flex-direction: column;
}
.error {
text-align: center;
font-size: 1.4rem;
.oh-deary-me {
font-size: 1.8rem;
margin: 0.2rem auto;
}
.what-the-fuck-happened {
color: var(--danger);
margin: 0.2rem auto;
}
.what-next {
font-size: 1rem;
margin-top: 3rem;
opacity: 0.6;
a {
color: var(--foreground);
}
}
}
}
main {
margin: 0 auto 2rem auto;
padding: 1rem;
width: 1200px;
max-width: calc(100% - 5rem);
display: flex;
justify-content: space-between;
flex-direction: column;
min-height: calc(100vh - 12rem);
font-size: 1.25rem;
h2 {
font-family: 'Lekton', sans-serif;
font-weight: bold;
font-size: 3rem;
margin: 0;
color: var(--accent-3);
}
.buttons {
margin: 1rem auto;
display: flex;
flex-direction: column;
}
.error {
text-align: center;
font-size: 1.4rem;
.oh-deary-me {
font-size: 1.8rem;
margin: 0.2rem auto;
}
.what-the-fuck-happened {
color: var(--danger);
margin: 0.2rem auto;
}
.what-next {
font-size: 1rem;
margin-top: 3rem;
opacity: 0.6;
a {
color: var(--foreground);
}
}
}
}
</style>

View file

@ -1,5 +1,4 @@
---
import Layout from '@layouts/Layout.astro';
import SavedServices from '@components/things/SavedServices.svelte';
import GetSharableLink from '@components/things/GetSharableLink.svelte';
@ -9,53 +8,52 @@ import Button from '@components/form/Button.astro';
import EditableTitle from '@components/form/EditableTitle.svelte';
import type { Category } from '../../types/Service';
const categories = (await fetchData())?.categories || [] as Category[];
const categories = (await fetchData())?.categories || ([] as Category[]);
---
<Layout title="Saved Services">
<main>
<div class="top-row">
<!-- <h2>Inventory</h2> -->
<EditableTitle client:load />
<GetSharableLink client:load />
</div>
<SavedServices allData={categories} client:load />
<div class="buttons">
<p>Not found what you're looking for?</p>
<Button url="/all">Browse Services</Button>
</div>
</main>
<main>
<div class="top-row">
<!-- <h2>Inventory</h2> -->
<EditableTitle client:load />
<GetSharableLink client:load />
</div>
<SavedServices allData={categories} client:load />
<div class="buttons">
<p>Not found what you're looking for?</p>
<Button url="/all">Browse Services</Button>
</div>
</main>
</Layout>
<style lang="scss">
main {
margin: 0 auto 2rem auto;
padding: 1rem;
width: 1200px;
max-width: calc(100% - 5rem);
display: flex;
justify-content: space-between;
flex-direction: column;
min-height: calc(100vh - 12rem);
font-size: 1.25rem;
.top-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
h2 {
font-family: "Lekton", sans-serif;
font-weight: bold;
font-size: 3rem;
margin: 0;
color: var(--accent-3);
}
.buttons {
margin: 1rem auto;
display: flex;
flex-direction: column;
}
}
main {
margin: 0 auto 2rem auto;
padding: 1rem;
width: 1200px;
max-width: calc(100% - 5rem);
display: flex;
justify-content: space-between;
flex-direction: column;
min-height: calc(100vh - 12rem);
font-size: 1.25rem;
.top-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
h2 {
font-family: 'Lekton', sans-serif;
font-weight: bold;
font-size: 3rem;
margin: 0;
color: var(--accent-3);
}
.buttons {
margin: 1rem auto;
display: flex;
flex-direction: column;
}
}
</style>

View file

@ -1,18 +1,17 @@
---
import Fuse from 'fuse.js';
import Fuse from 'fuse.js';
import Layout from '@layouts/Layout.astro';
import { fetchData, slugify } from '@utils/fetch-data';
import { prepareSearchItems, searchOptions } from '@utils/do-searchy-searchy';
import type { SearchItem } from '@utils/do-searchy-searchy';
import Search from '@components/things/Search.svelte';
import SmartSuggestions from '@components/things/SmartSuggestions.svelte';
import FontAwesome from '@components/form/FontAwesome.svelte';
import type { Service } from '../../types/Service';
export const prerender = false;
let fuse: Fuse<any>;
let fuse: Fuse<SearchItem>;
const categories = (await fetchData())?.categories;
@ -22,171 +21,187 @@ fuse = new Fuse(items, searchOptions);
const searchTerm = Astro.params.searchTerm;
const searchResults = fuse.search(searchTerm || '').map(result => result.item);
const searchResults = fuse
.search(searchTerm || '')
.map((result) => result.item);
const services = searchResults.filter(result => result.type === 'Service');
const services = searchResults.filter((result) => result.type === 'Service');
interface GroupedSection {
sectionName: string;
items: SearchItem[];
}
interface GroupedCategory {
categoryName: string;
sections: Record<string, GroupedSection>;
}
const putResultsIntoGroups = () => {
const grouped = services.reduce((acc, item) => {
const { category: categoryName, sectionName, ...service } = item;
const grouped: Record<string, GroupedCategory> = {};
if (!acc[categoryName]) {
acc[categoryName] = { categoryName, sections: {} };
}
for (const item of services) {
const categoryName = item.category;
const sectionName = item.sectionName || '';
if (!acc[categoryName].sections[sectionName]) {
acc[categoryName].sections[sectionName] = { sectionName, items: [] };
}
if (!grouped[categoryName]) {
grouped[categoryName] = { categoryName, sections: {} };
}
acc[categoryName].sections[sectionName].items.push(service);
if (!grouped[categoryName].sections[sectionName]) {
grouped[categoryName].sections[sectionName] = { sectionName, items: [] };
}
return acc;
}, {});
grouped[categoryName].sections[sectionName].items.push(item);
}
// Convert the grouped object into the desired array structure.
// And fuck it, let's use `any`
return Object.values(grouped).map((category: any) => ({
categoryName: category.categoryName,
sections: Object.values(category.sections)
}));
return Object.values(grouped).map((category) => ({
categoryName: category.categoryName,
sections: Object.values(category.sections),
}));
};
const beer = putResultsIntoGroups();
---
<Layout title="Search | Awesome Privacy">
<section>
<h1>Search</h1>
<Search client:visible data={categories} previousSearch={searchTerm} />
</section>
<SmartSuggestions client:visible searchTerm={searchTerm || ''} categories={categories} />
<section class="result-count">
<h3>Deep Search</h3>
<p>Showing {services.length} results for "{searchTerm}" sorted by relevence</p>
</section>
<div class="results">
{
beer.map((category: any) => (
<div class="category">
<a class="category-title" href={`/${slugify(category.categoryName)}`}>
<h3>{category.categoryName}</h3>
</a>
<span class="section-icon"><FontAwesome iconName={slugify(category.categoryName)} /></span>
<ul class="section-list">
{category.sections.map((section: any) => (
<li class="section-item">
<h4>{section.sectionName}</h4>
<ul class="service-list">
{section.items.map((item: Service) => (
<li>
<a href={item.url}>{item.name}</a>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
))
}
</div>
<section>
<h1>Search</h1>
<Search client:visible data={categories} previousSearch={searchTerm} />
</section>
<SmartSuggestions
client:visible
searchTerm={searchTerm || ''}
categories={categories}
/>
<section class="result-count">
<h3>Deep Search</h3>
<p>
Showing {services.length} results for "{searchTerm}" sorted by relevence
</p>
</section>
<div class="results">
{
beer.map((category) => (
<div class="category">
<a class="category-title" href={`/${slugify(category.categoryName)}`}>
<h3>{category.categoryName}</h3>
</a>
<span class="section-icon">
<FontAwesome iconName={slugify(category.categoryName)} />
</span>
<ul class="section-list">
{category.sections.map((section) => (
<li class="section-item">
<h4>{section.sectionName}</h4>
<ul class="service-list">
{section.items.map((item) => (
<li>
<a href={item.url}>{item.name}</a>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
))
}
</div>
</Layout>
<style lang="scss">
section {
display: flex;
flex-direction: column;
padding: 1rem;
margin: 2rem auto;
padding: 1rem;
width: calc(100% - 2rem);
max-width: 1100px;
section {
display: flex;
flex-direction: column;
padding: 1rem;
margin: 2rem auto;
padding: 1rem;
width: calc(100% - 2rem);
max-width: 1100px;
h1 {
font-size: 3rem;
color: var(--accent-3);
font-family: 'Lekton', sans-serif;
text-align: center;
margin: 1rem 0;
}
}
h1 {
font-size: 3rem;
color: var(--accent-3);
font-family: "Lekton", sans-serif;
text-align: center;
margin: 1rem 0;
}
}
.result-count {
padding: 0;
width: 80vw;
max-width: 900px;
margin: 1rem auto 0 auto;
h3 {
margin: 0;
color: var(--accent-3);
font-size: 1.6rem;
}
p {
margin: 0;
opacity: 0.6;
}
}
.result-count {
padding: 0;
width: 80vw;
max-width: 900px;
margin: 1rem auto 0 auto;
h3 {
margin: 0;
color: var(--accent-3);
font-size: 1.6rem;
}
p {
margin: 0;
opacity: 0.6;
}
}
.results {
margin: 0 auto 2rem auto;
padding: 1rem;
width: calc(100% - 2rem);
max-width: 1100px;
columns: 6 300px;
column-gap: 1rem;
.category {
padding: 1rem;
box-shadow: 4px 4px 0 var(--box-outline);
background: var(--accent-fg);
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
display: inline-flex;
flex-direction: column;
width: 85%;
margin: 1rem;
position: relative;
.category-title {
text-decoration: none;
color: var(--foreground);
z-index: 2;
position: relative;
h3 {
font-family: "Lekton", sans-serif;
font-weight: bold;
margin: 0;
font-size: 1.8rem;
}
}
.section-icon {
position: absolute;
right: 0.5rem;
top: 0.5rem;
width: fit-content;
:global(svg) {
width: 2rem;
height: 2rem;
opacity: 0.5;
text-shadow: 3px 3px 0 black;
color: var(--accent-3);
transition: all 0.2s ease-in-out;
}
}
.section-list {
list-style: none;
padding: 0;
margin: 0;
h4 {
margin: 1rem 0 0 0;
}
.service-list {
list-style: none;
padding: 0;
margin: 0;
}
}
}
}
.results {
margin: 0 auto 2rem auto;
padding: 1rem;
width: calc(100% - 2rem);
max-width: 1100px;
columns: 6 300px;
column-gap: 1rem;
.category {
padding: 1rem;
box-shadow: 4px 4px 0 var(--box-outline);
background: var(--accent-fg);
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
display: inline-flex;
flex-direction: column;
width: 85%;
margin: 1rem;
position: relative;
.category-title {
text-decoration: none;
color: var(--foreground);
z-index: 2;
position: relative;
h3 {
font-family: 'Lekton', sans-serif;
font-weight: bold;
margin: 0;
font-size: 1.8rem;
}
}
.section-icon {
position: absolute;
right: 0.5rem;
top: 0.5rem;
width: fit-content;
:global(svg) {
width: 2rem;
height: 2rem;
opacity: 0.5;
text-shadow: 3px 3px 0 black;
color: var(--accent-3);
transition: all 0.2s ease-in-out;
}
}
.section-list {
list-style: none;
padding: 0;
margin: 0;
h4 {
margin: 1rem 0 0 0;
}
.service-list {
list-style: none;
padding: 0;
margin: 0;
}
}
}
}
</style>

View file

@ -1,57 +1,55 @@
---
import Layout from '@layouts/Layout.astro';
import { fetchData } from '@utils/fetch-data';
import Search from '@components/things/Search.svelte';
const categories = (await fetchData())?.categories;
---
<Layout title="Search | Awesome Privacy">
<section>
<h1>Search</h1>
<Search client:visible data={categories} />
<p class="sitemap-link">
Browse <a href="/all">all listings</a>, or see all pages in the <a href="/sitemap">Sitemap</a>
</p>
</section>
<section>
<h1>Search</h1>
<Search client:visible data={categories} />
<p class="sitemap-link">
Browse <a href="/all">all listings</a>, or see all pages in the <a
href="/sitemap">Sitemap</a
>
</p>
</section>
</Layout>
<style lang="scss">
section {
display: flex;
flex-direction: column;
padding: 1rem;
box-shadow: 4px 4px 0 var(--box-outline);
background: var(--accent-fg);
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
margin: 2rem auto;
padding: 1rem;
width: calc(100% - 5rem);
max-width: 1100px;
min-height: 80vh;
section {
display: flex;
flex-direction: column;
padding: 1rem;
box-shadow: 4px 4px 0 var(--box-outline);
background: var(--accent-fg);
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
margin: 2rem auto;
padding: 1rem;
width: calc(100% - 5rem);
max-width: 1100px;
min-height: 80vh;
h1 {
font-size: 3rem;
color: var(--accent-3);
font-family: "Lekton", sans-serif;
text-align: center;
margin: 1rem 0;
}
.sitemap-link {
opacity: 0.6;
text-align: center;
font-size: 0.8rem;
margin: 2rem auto;
transition: all 0.2s ease-in-out;
&:hover {
opacity: 0.8;
}
}
}
h1 {
font-size: 3rem;
color: var(--accent-3);
font-family: 'Lekton', sans-serif;
text-align: center;
margin: 1rem 0;
}
.sitemap-link {
opacity: 0.6;
text-align: center;
font-size: 0.8rem;
margin: 2rem auto;
transition: all 0.2s ease-in-out;
&:hover {
opacity: 0.8;
}
}
}
</style>

View file

@ -1,234 +1,244 @@
---
import Layout from '@layouts/Layout.astro';
import type { AwesomePrivacy } from '../types/Service';
import { fetchData, slugify } from '@utils/fetch-data';
const categories = (await fetchData() as AwesomePrivacy)?.categories || [];
const categories = ((await fetchData()) as AwesomePrivacy)?.categories || [];
---
<Layout title="All Links | Awesome Privacy">
<main>
<h2>Sitemap</h2>
<p>
Below is a full listing of all pages on this site.<br>
<small>As reflected in our <a href="/sitemap-index.xml">sitemap.xml</a></small>
</p>
<span class="search-controlls">
<input id="searchInput" type="search" placeholder="Start typing to filter..." />
<p id="press-enter-msg" class="press-enter">Press enter for deep search</p>
</span>
<ul>
<li>
<a href="/">Home</a>
</li>
<li>
<a href="/search">Search</a>
</li>
<li>
<a href="/all">View All</a>
</li>
<li>
<a href="/inventory">My Inventory</a>
</li>
<li>
<a href="/submit">Submit Listing</a>
</li>
<li>
<a href="/api">API</a>
</li>
<li>
<a href="/browse">Categories</a>
<ul>
{categories.map((category) => (
<li>
<a href={`/${slugify(category.name)}`}>{category.name}</a>
<ul>
{category.sections.map((section) => (
<li>
<a href={`/${slugify(category.name)}/${slugify(section.name)}`}>{section.name}</a>
<ul>
{(section.services || []).map((service) => (
<li title={service.description || ''}>
<a href={`/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}`}>
{service.name}
</a>
</li>
))}
</ul>
</li>
))}
</ul>
</li>
))}
</ul>
</li>
<li>
<a href="/about">About</a>
<ul>
<li><a href="/about#objective">Objective</a></li>
<li><a href="/about#criteria">Listing Criteria</a></li>
<li><a href="/about#contributing">Contributing</a></li>
<li>
<a href="/about#acknowledgements">Acknowledgements</a>
<ul>
<li><a href="/about#contributors">Contributors</a></li>
<li><a href="/about#sponsors">Sponsors</a></li>
</ul>
</li>
<li><a href="/about#author">Author</a></li>
<li><a href="/about#license">License</a></li>
</ul>
</li>
<li>
<a href="/sitemap">Sitemap</a>
</li>
</ul>
</main>
<main>
<h2>Sitemap</h2>
<p>
Below is a full listing of all pages on this site.<br />
<small
>As reflected in our <a href="/sitemap-index.xml">sitemap.xml</a></small
>
</p>
<span class="search-controlls">
<input
id="searchInput"
type="search"
placeholder="Start typing to filter..."
/>
<p id="press-enter-msg" class="press-enter">
Press enter for deep search
</p>
</span>
<ul>
<li>
<a href="/">Home</a>
</li>
<li>
<a href="/search">Search</a>
</li>
<li>
<a href="/all">View All</a>
</li>
<li>
<a href="/inventory">My Inventory</a>
</li>
<li>
<a href="/submit">Submit Listing</a>
</li>
<li>
<a href="/api">API</a>
</li>
<li>
<a href="/browse">Categories</a>
<ul>
{
categories.map((category) => (
<li>
<a href={`/${slugify(category.name)}`}>{category.name}</a>
<ul>
{category.sections.map((section) => (
<li>
<a
href={`/${slugify(category.name)}/${slugify(section.name)}`}
>
{section.name}
</a>
<ul>
{(section.services || []).map((service) => (
<li title={service.description || ''}>
<a
href={`/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}`}
>
{service.name}
</a>
</li>
))}
</ul>
</li>
))}
</ul>
</li>
))
}
</ul>
</li>
<li>
<a href="/about">About</a>
<ul>
<li><a href="/about#objective">Objective</a></li>
<li><a href="/about#criteria">Listing Criteria</a></li>
<li><a href="/about#contributing">Contributing</a></li>
<li>
<a href="/about#acknowledgements">Acknowledgements</a>
<ul>
<li><a href="/about#contributors">Contributors</a></li>
<li><a href="/about#sponsors">Sponsors</a></li>
</ul>
</li>
<li><a href="/about#author">Author</a></li>
<li><a href="/about#license">License</a></li>
</ul>
</li>
<li>
<a href="/sitemap">Sitemap</a>
</li>
</ul>
</main>
</Layout>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Select elements for events (search field, each result, and message str)
const filterInput = document.querySelector<HTMLInputElement>('input');
const pages = document.querySelectorAll<HTMLElement>('li');
const pressEnterMsg = document.getElementById('press-enter-msg');
if (!pressEnterMsg) throw new Error('pressEnterMsg element not found');
document.addEventListener('DOMContentLoaded', () => {
// Select elements for events (search field, each result, and message str)
const filterInput = document.querySelector<HTMLInputElement>('input');
const pages = document.querySelectorAll<HTMLElement>('li');
const pressEnterMsg = document.getElementById('press-enter-msg');
if (!pressEnterMsg) throw new Error('pressEnterMsg element not found');
// Instant search/filter functionality
filterInput?.addEventListener('input', (e: Event) => {
const filter = (e.target as HTMLInputElement).value.toLowerCase();
pressEnterMsg.style.visibility = filter.length > 0 ? 'visible' : 'hidden';
Array.from(pages).reduce((count, page) => {
const match = page.textContent?.toLowerCase().includes(filter) || false;
page.style.display = match ? 'block' : 'none';
return count + (match ? 1 : 0);
}, 0);
});
// Handle keypress events, so Esc clears search, and Entr runs deep search
document.addEventListener('keydown', (event: KeyboardEvent) => {
if (!filterInput) return;
switch (event.key) {
case 'Enter':
event.preventDefault();
const searchTerm = encodeURIComponent(filterInput.value);
window.location.href = `/search/${searchTerm}`;
break;
case 'Escape':
filterInput.value = '';
filterInput.blur();
pressEnterMsg.style.visibility = 'hidden';
pages.forEach(page => page.style.display = 'block');
break;
}
});
});
// Instant search/filter functionality
filterInput?.addEventListener('input', (e: Event) => {
const filter = (e.target as HTMLInputElement).value.toLowerCase();
pressEnterMsg.style.visibility = filter.length > 0 ? 'visible' : 'hidden';
Array.from(pages).reduce((count, page) => {
const match = page.textContent?.toLowerCase().includes(filter) || false;
page.style.display = match ? 'block' : 'none';
return count + (match ? 1 : 0);
}, 0);
});
// Handle keypress events, so Esc clears search, and Entr runs deep search
document.addEventListener('keydown', (event: KeyboardEvent) => {
if (!filterInput) return;
switch (event.key) {
case 'Enter':
event.preventDefault();
const searchTerm = encodeURIComponent(filterInput.value);
window.location.href = `/search/${searchTerm}`;
break;
case 'Escape':
filterInput.value = '';
filterInput.blur();
pressEnterMsg.style.visibility = 'hidden';
pages.forEach((page) => (page.style.display = 'block'));
break;
}
});
});
</script>
<style lang="scss">
main {
padding: 1rem;
width: 1000px;
max-width: calc(100% - 5rem);
margin: 4rem auto 5rem auto;
padding: 0 2rem;
border: 2px solid var(--box-outline);
box-shadow: 6px 6px 0 var(--box-outline);
background: var(--accent-fg);
position: relative;
p {
font-size: 1.3rem;
margin: 0;
small {
font-size: 0.8rem;
opacity: 0.5;
transition: all 0.2s ease-in-out;
&:hover {
opacity: 0.8;
}
}
}
ul {
padding-left: 1.2rem;
list-style: none;
border-left: 3px solid #5f53f482;
border-radius: 4px;
li {
font-weight: 600;
ul li {
font-weight: 500;
ul li {
font-weight: 400;
ul li {
font-weight: 300;
}
}
}
&::before {
content: '━ ';
color: #5f53f482;
margin-left: -1.2rem;
}
a {
transition: all ease-in-out 0.2s;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
main {
padding: 1rem;
width: 1000px;
max-width: calc(100% - 5rem);
margin: 4rem auto 5rem auto;
padding: 0 2rem;
border: 2px solid var(--box-outline);
box-shadow: 6px 6px 0 var(--box-outline);
background: var(--accent-fg);
position: relative;
p {
font-size: 1.3rem;
margin: 0;
small {
font-size: 0.8rem;
opacity: 0.5;
transition: all 0.2s ease-in-out;
&:hover { opacity: 0.8; }
}
}
ul {
padding-left: 1.2rem;
list-style: none;
border-left: 3px solid #5f53f482;
border-radius: 4px;;
li {
font-weight: 600;
ul li {
font-weight: 500;
ul li {
font-weight: 400;
ul li {
font-weight: 300;
}
}
}
&::before {
content: "━ ";
color: #5f53f482;
margin-left: -1.2rem;
}
a {
transition: all ease-in-out 0.2s;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
h2 {
margin: 0;
gap: 1rem;
font-size: 2rem;
margin: -2rem 0 2rem -4rem;
box-shadow: 6px 6px 0 var(--box-outline);
background: var(--accent);
color: var(--accent-fg);
width: fit-content;
padding: 0.25rem 0.5rem;
@media (max-width: 768px) {
margin: -2rem auto 1rem auto;
}
}
.search-controlls {
float: right;
top: 1rem;
right: 1rem;
position: absolute;
@media (max-width: 768px) {
position: relative;
float: none;
top: 0;
right: 0;
}
.press-enter {
margin: 0.5rem 0 0 0;
opacity: 0.5;
visibility: hidden;
font-size: 1rem;
}
input {
padding: 0.25rem;
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
font-size: 1rem;
box-shadow: 3px 3px 0 var(--box-outline);
color: var(--accent-3);
background: var(--background-form);
&:focus {
outline: none;
box-shadow: 4px 4px 0 var(--box-outline);
}
}
}
h2 {
margin: 0;
gap: 1rem;
font-size: 2rem;
margin: -2rem 0 2rem -4rem;
box-shadow: 6px 6px 0 var(--box-outline);
background: var(--accent);
color: var(--accent-fg);
width: fit-content;
padding: 0.25rem 0.5rem;
@media (max-width: 768px) {
margin: -2rem auto 1rem auto;
}
}
.search-controlls {
float: right;
top: 1rem;
right: 1rem;
position: absolute;
@media (max-width: 768px) {
position: relative;
float: none;
top: 0;
right: 0;
}
.press-enter {
margin: 0.5rem 0 0 0;
opacity: 0.5;
visibility: hidden;
font-size: 1rem;
}
input {
padding: 0.25rem;
border: 2px solid var(--box-outline);
border-radius: var(--curve-sm);
font-size: 1rem;
box-shadow: 3px 3px 0 var(--box-outline);
color: var(--accent-3);
background: var(--background-form);
&:focus {
outline: none;
box-shadow: 4px 4px 0 var(--box-outline);
}
}
}
</style>

View file

@ -2,142 +2,187 @@
import Layout from '@layouts/Layout.astro';
import AddNewService from '@components/things/AddNewService.svelte';
import { fetchGitHubStats } from '@utils/fetch-repo-info'
import { fetchGitHubStats } from '@utils/fetch-repo-info';
import { formatDate } from '@utils/dates-n-stuff';
const commits = (await fetchGitHubStats('lissy93/awesome-privacy') || {}).commits;
const commits = ((await fetchGitHubStats('lissy93/awesome-privacy')) || {})
.commits;
---
<Layout title="Awesome Privacy">
<section>
<h2>About our Data</h2>
<p class="about-data">
All data on Awesome Privacy is community maintained via Git,
this keeps everything transparent, and means anyone can submit edits.
You can learn more about how our data is managed on our <a href="/about#our-data">about page</a>.
<br /><br />
You can make ammendments/additions/removals simply by editing the
<a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml">awesome-privacy.yml</a> file.
<br />
Before you proceed, please first read our <a href="https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md">Contributing Docs</a>
<br /><br />
Awesome Privacy is a community-maintained resource, it's thanks to
contributors like you, that it's able to grow and stay up to date 💜
</p>
</section>
<section>
<h2>Submit an Addition</h2>
<AddNewService client:load />
</section>
<section>
<h2>About our Data</h2>
<p class="about-data">
All data on Awesome Privacy is community maintained via Git, this keeps
everything transparent, and means anyone can submit edits. You can learn
more about how our data is managed on our <a href="/about#our-data"
>about page</a
>.
<br /><br />
You can make ammendments/additions/removals simply by editing the
<a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml"
>awesome-privacy.yml</a
> file.
<br />
Before you proceed, please first read our <a
href="https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
>Contributing Docs</a
>
<br /><br />
Awesome Privacy is a community-maintained resource, it's thanks to contributors
like you, that it's able to grow and stay up to date 💜
</p>
</section>
<section>
<h2>Submit an Addition</h2>
<AddNewService client:load />
</section>
<section>
<h2>Submit a Removal Request</h2>
<p>
You can submit a removal request by browsing to a given service's page,
and clicking the "Request Removal" button.
This will open a form where you can justify your reasoning, to get it
deleted from the <a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml">awesome-privacy.yml</a> file.
</p>
</section>
<section>
<h2>Submit a Removal Request</h2>
<p>
You can submit a removal request by browsing to a given service's page,
and clicking the "Request Removal" button. This will open a form where you
can justify your reasoning, to get it deleted from the <a
href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml"
>awesome-privacy.yml</a
> file.
</p>
</section>
<section>
<h2>Edit a Listing</h2>
<p>
Edits are welcome! All data is located in
<a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml">awesome-privacy.yml</a>.
<br>
To modify an entry, navigate to it's page, scroll to the bottom, and click "Edit".
This will take you to directly to the relevant lines in the file, where you can make your changes.
</p>
</section>
<section>
<h2>Edit a Listing</h2>
<p>
Edits are welcome! All data is located in
<a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml"
>awesome-privacy.yml</a
>.
<br />
To modify an entry, navigate to it's page, scroll to the bottom, and click "Edit".
This will take you to directly to the relevant lines in the file, where you
can make your changes.
</p>
</section>
<section>
<h2>Checklist</h2>
<ul>
<li>You must read the <a href="https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md">Contributing</a> guidelines before proceeding</li>
<li>All listing must meed our <a href="/about#creteria">Criteria</a> to be considered privacy-respecting</li>
<li>Double check that your changes haven't already been proposed</li>
<li>If you're associated with a service included, you must declare your affiliation</li>
<li>Before commiting changes, ensure the YAML syntax is valid and it complies with our schema</li>
<li>Please complete the issue or PR description template in full, do not remove any fields</li>
<li>All submissions must be made via <a href="https://github.com/Lissy93/awesome-privacy">our GitHub</a>, do not email/PM maintainers</li>
</ul>
</section>
<section>
<h2>Checklist</h2>
<ul>
<li>
You must read the <a
href="https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
>Contributing</a
> guidelines before proceeding
</li>
<li>
All listing must meed our <a href="/about#creteria">Criteria</a> to be considered
privacy-respecting
</li>
<li>Double check that your changes haven't already been proposed</li>
<li>
If you're associated with a service included, you must declare your
affiliation
</li>
<li>
Before commiting changes, ensure the YAML syntax is valid and it
complies with our schema
</li>
<li>
Please complete the issue or PR description template in full, do not
remove any fields
</li>
<li>
All submissions must be made via <a
href="https://github.com/Lissy93/awesome-privacy">our GitHub</a
>, do not email/PM maintainers
</li>
</ul>
</section>
{commits && commits.length > 0 && (
<section>
<h2>Recent Changes</h2>
<p class="about-data">
You can view a full ledger of all updates made
at <a href="https://github.com/lissy93/awesome-privacy">github.com/lissy93/awesome-privacy</a>
</p>
<ul class="commit-log">
{commits.map((commit) => (
<li title={commit.sha}>
{commit.message}<br />
<img width="14" src={commit.authorAvatar} />
<small>
By <a href={`https://github.com/${commit.authorUsername}`}>{commit.authorName || commit.authorUsername}</a>
on <a href={`https://github.com/Lissy93/awesome-privacy/commit/${commit.sha}`}>{formatDate(commit.authorDate)}</a>
</small>
</li>
))}
</ul>
</section>
)}
{
commits && commits.length > 0 && (
<section>
<h2>Recent Changes</h2>
<p class="about-data">
You can view a full ledger of all updates made at{' '}
<a href="https://github.com/lissy93/awesome-privacy">
github.com/lissy93/awesome-privacy
</a>
</p>
<ul class="commit-log">
{commits.map((commit) => (
<li title={commit.sha}>
{commit.message}
<br />
<img width="14" src={commit.authorAvatar} />
<small>
By{' '}
<a href={`https://github.com/${commit.authorUsername}`}>
{commit.authorName || commit.authorUsername}
</a>
on{' '}
<a
href={`https://github.com/Lissy93/awesome-privacy/commit/${commit.sha}`}
>
{formatDate(commit.authorDate)}
</a>
</small>
</li>
))}
</ul>
</section>
)
}
</Layout>
<style lang="scss">
section {
margin: 2rem auto;
padding: 1rem;
width: 1000px;
max-width: calc(100% - 5rem);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0 2rem;
border: 2px solid var(--box-outline);
box-shadow: 6px 6px 0 var(--box-outline);
background: var(--accent-fg);
border-radius: var(--curve-sm);
@media (max-width: 768px) {
max-width: 95%;
padding: 0.5rem;
margin: 0 auto;
}
p {
margin-top: 0;
}
h2 {
margin-bottom: 0.5rem;
}
ul {
margin-top: 0;
padding-left: 1rem;
list-style: circle;
}
.about-data {
font-size: 1.2rem;
}
section {
margin: 2rem auto;
padding: 1rem;
width: 1000px;
max-width: calc(100% - 5rem);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0 2rem;
border: 2px solid var(--box-outline);
box-shadow: 6px 6px 0 var(--box-outline);
background: var(--accent-fg);
border-radius: var(--curve-sm);
@media(max-width: 768px) {
max-width: 95%;
padding: 0.5rem;
margin: 0 auto;
}
p {
margin-top: 0;
}
h2 {
margin-bottom: 0.5rem;
}
ul {
margin-top: 0;
padding-left: 1rem;
list-style: circle;
}
.about-data {
font-size: 1.2rem;
}
.commit-log {
column-width: 350px;
li {
small {
font-size: 0.8rem;
opacity: 0.7;
a { text-transform: capitalize;}
}
img {
border-radius: var(--curve-md);
margin-right: 0.5rem;
}
}
}
}
.commit-log {
column-width: 350px;
li {
small {
font-size: 0.8rem;
opacity: 0.7;
a {
text-transform: capitalize;
}
}
img {
border-radius: var(--curve-md);
margin-right: 0.5rem;
}
}
}
}
</style>

View file

@ -14,37 +14,59 @@ export const authorProjects = [
{
title: 'Web-Check',
description: 'OSINT tool for analysing any website',
icon: 'https://web-check.as93.net/web-check.png',
icon: 'https://cdn.as93.net/logo/web-check/w256',
link: 'https://github.com/lissy93/web-check',
},
{
title: 'Dashy',
description: 'Dashboard app, for organising your self-hosted services',
icon: 'https://dashy.to/img/dashy.png',
icon: 'https://cdn.as93.net/logo/dashy/w256',
link: 'https://github.com/lissy93/dashy',
},
{
title: 'Domain Locker',
description:
'All-in-one tool, for keeping track of your domain name portfolio',
icon: 'https://cdn.as93.net/logo/domain-locker/w256',
link: 'https://github.com/lissy93/domain-locker',
},
{
title: 'Pixelflare',
description: 'Ultra high-performance privacy-respecting image CDN',
icon: 'https://cdn.as93.net/logo/pixelflare/w256',
link: 'https://github.com/Lissy93/pixelflare',
},
{
title: 'Networking Toolbox',
description:
'100+ offline-first networking lookups, calculators and conversions',
icon: 'https://cdn.as93.net/logo/networking-toolbox/w256',
link: 'https://github.com/Lissy93/networking-toolbox',
},
{
title: 'Portainer-Templates',
description: 'Compiled repository of 1-click Docker apps for self-hosting',
icon: 'https://portainer-templates.as93.net/favicon.png',
icon: 'https://cdn.as93.net/logo/portainer-templates/w256',
link: 'https://github.com/lissy93/portainer-templates',
},
{
title: 'AdGuardian',
description: 'CLI tool for monitoring your networks traffic and AdGuard DNS stats',
icon: 'https://adguardian.as93.net/favicon.png',
description:
'CLI tool for monitoring your networks traffic and AdGuard DNS stats',
icon: 'https://cdn.as93.net/logo/adguardian/w256',
link: 'https://github.com/lissy93/adguardian-term',
},
{
title: 'Bug-Bounties',
description: 'Database of websites which accept responsible vulnerability disclosure',
icon: 'https://bug-bounties.as93.net/favicon.png',
description:
'Database of websites which accept responsible vulnerability disclosure',
icon: 'https://cdn.as93.net/logo/bug-bounties',
link: 'https://github.com/lissy93/bug-bounties',
},
{
title: 'Git-In',
description: 'Tools and resources to help beginners get into open source',
icon: 'https://www.git-in.to/favicon.png',
icon: 'https://cdn.as93.net/logo/git-in/w256',
link: 'https://github.com/lissy93/git-in',
},
];
@ -82,9 +104,8 @@ export const authorSocials = [
},
];
export const aboutOurData = `
All data is stored in
All data is stored in
[\`awesome-privacy.yml\`](https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml).
This file is then pulled into the website at build-time, and also used to generate
@ -132,7 +153,7 @@ Use our public instance, at: \`https://api.awesome-privacy.xyz\` or [self-host y
`;
export const projectRequirements = `
For software to be included in this list, it must meet the following requirements:
For software to be included in this list, it must meet the following requirements:
- **Privacy Respecting**
- The project must respect users privacy, not collect more data than necessary, and store info securely
@ -148,7 +169,7 @@ For software to be included in this list, it must meet the following requirement
- Ideally it should be possible for the user to build and run/deploy the software themselves from source
- **Actively Maintained**
- The developers should address dependency updates and security patches in a timely manner
- Ideally the source should have been updated within the last 12 months
- Ideally the source should have been updated within the last 12 months
- **Transparent**
- It should be clear who is behind the project, what their motives are, and what (if any) the funding model is
- For hosted solutions, the privacy policy should clearly state what data is collected, how it's used and how long it's stored
@ -167,18 +188,20 @@ by the community, and the drawbacks / anti-features must be clearly listed along
Usually these entries go within the "Notable Mentions" section instead._
`;
export const appDescription = 'Privacy is a fundamental human right; '
+ 'without it, we\'re just open books in a world where everyone\'s '
+ 'watching. Let\'s take control back.\n'
+ 'Migrating open-source applications which do not collect, sell or log your data is a great first step.'
+ 'Awesome Privacy is a directory of alternative privacy-respecting software and services.';
export const appDescription =
'Privacy is a fundamental human right; ' +
"without it, we're just open books in a world where everyone's " +
"watching. Let's take control back.\n" +
'Migrating open-source applications which do not collect, sell or log your data is a great first step.' +
'Awesome Privacy is a directory of alternative privacy-respecting software and services.';
export default {
title: 'Awesome Privacy | The Ultimate List of Private Apps',
description: 'Your guide to finding privacy-respecting alternatives to popular software and services.',
keywords: 'security, privacy, awesome privacy, data collection, free software, open source, privacy tools, privacy respecting software',
author: 'Alicia Sykes',
description:
'Your guide to finding privacy-respecting alternatives to popular software and services.',
keywords:
'security, privacy, awesome privacy, data collection, free software, open source, privacy tools, privacy respecting software',
author: 'Alicia Sykes',
authorProjects,
authorSocials,
aboutOurData,

View file

@ -3,91 +3,123 @@
/* Rubik Font Faces */
@font-face {
font-family: 'Rubik';
font-style: normal;
font-weight: 400;
src: local('Rubik'), url('/fonts/Rubik/Rubik-Regular.ttf') format('truetype');
font-family: 'Rubik';
font-style: normal;
font-weight: 400;
src:
local('Rubik'),
url('/fonts/Rubik/Rubik-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Rubik';
font-style: italic;
font-weight: 400;
src: local('Rubik Italic'), url('/fonts/Rubik/Rubik-Italic.ttf') format('truetype');
font-family: 'Rubik';
font-style: italic;
font-weight: 400;
src:
local('Rubik Italic'),
url('/fonts/Rubik/Rubik-Italic.ttf') format('truetype');
}
@font-face {
font-family: 'Rubik';
font-style: normal;
font-weight: 500;
src: local('Rubik Medium'), url('/fonts/Rubik/Rubik-Medium.ttf') format('truetype');
font-family: 'Rubik';
font-style: normal;
font-weight: 500;
src:
local('Rubik Medium'),
url('/fonts/Rubik/Rubik-Medium.ttf') format('truetype');
}
@font-face {
font-family: 'Rubik';
font-style: italic;
font-weight: 500;
src: local('Rubik Medium Italic'), url('/fonts/Rubik/Rubik-MediumItalic.ttf') format('truetype');
font-family: 'Rubik';
font-style: italic;
font-weight: 500;
src:
local('Rubik Medium Italic'),
url('/fonts/Rubik/Rubik-MediumItalic.ttf') format('truetype');
}
@font-face {
font-family: 'Rubik';
font-style: normal;
font-weight: 600;
src: local('Rubik SemiBold'), url('/fonts/Rubik/Rubik-SemiBold.ttf') format('truetype');
font-family: 'Rubik';
font-style: normal;
font-weight: 600;
src:
local('Rubik SemiBold'),
url('/fonts/Rubik/Rubik-SemiBold.ttf') format('truetype');
}
@font-face {
font-family: 'Rubik';
font-style: italic;
font-weight: 600;
src: local('Rubik SemiBold Italic'), url('/fonts/Rubik/Rubik-SemiBoldItalic.ttf') format('truetype');
font-family: 'Rubik';
font-style: italic;
font-weight: 600;
src:
local('Rubik SemiBold Italic'),
url('/fonts/Rubik/Rubik-SemiBoldItalic.ttf') format('truetype');
}
/* Libre Franklin Font Faces */
@font-face {
font-family: 'Libre Franklin';
font-style: normal;
font-weight: 500;
src: local('Libre Franklin Bold'), url('/fonts/Libre_Franklin/LibreFranklin-Bold.ttf') format('truetype');
font-family: 'Libre Franklin';
font-style: normal;
font-weight: 500;
src:
local('Libre Franklin Bold'),
url('/fonts/Libre_Franklin/LibreFranklin-Bold.ttf') format('truetype');
}
/* Lekton Font Faces */
@font-face {
font-family: 'Lekton';
font-style: normal;
font-weight: 700;
src: local('Lekton Bold'), url('/fonts/Lekton/Lekton-Bold.ttf') format('truetype');
font-family: 'Lekton';
font-style: normal;
font-weight: 700;
src:
local('Lekton Bold'),
url('/fonts/Lekton/Lekton-Bold.ttf') format('truetype');
}
html {
font-family: system-ui, sans-serif;
font-family: system-ui, sans-serif;
}
code {
font-family:
Menlo,
Monaco,
Lucida Console,
Liberation Mono,
DejaVu Sans Mono,
Bitstream Vera Sans Mono,
Courier New,
monospace;
font-family:
Menlo,
Monaco,
Lucida Console,
Liberation Mono,
DejaVu Sans Mono,
Bitstream Vera Sans Mono,
Courier New,
monospace;
}
.heading, h1 {
font-family: "Libre Franklin", sans-serif;
font-optical-sizing: auto;
font-weight: 800;
font-style: normal;
.heading,
h1 {
font-family: 'Libre Franklin', sans-serif;
font-optical-sizing: auto;
font-weight: 800;
font-style: normal;
}
.subtitle, h2, h3, h4, h5, h6 {
font-family: "Lekton", sans-serif;
font-weight: 700;
font-style: normal;
.subtitle,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Lekton', sans-serif;
font-weight: 700;
font-style: normal;
}
html, body, p, a, ul, ol, li, blockquote, pre, strong, i {
font-family: "Rubik", sans-serif;
html,
body,
p,
a,
ul,
ol,
li,
blockquote,
pre,
strong,
i {
font-family: 'Rubik', sans-serif;
}
a {
color: var(--accent);
color: var(--accent);
}

View file

@ -1,53 +1,51 @@
html {
--accent: #f45397;
--accent-fg: #1e1f21;
html {
--accent: #f45397;
--accent-fg: #1e1f21;
--accent-2: #ffdf60;
--accent-3: #5f53f4;
--accent-4: #28dffd;
--accent-2: #ffdf60;
--accent-3: #5f53f4;
--accent-4: #28dffd;
--foreground: #fff;
--foreground: #fff;
--curve-sm: 4px;
--curve-md: 6px;
--curve-lg: 12px;
--curve-sm: 4px;
--curve-md: 6px;
--curve-lg: 12px;
--danger: #ff0048;
--success: #00ff64;
--danger: #ff0048;
--success: #00ff64;
--transparent-accent: #5f53f482;
--transparent-accent: #5f53f482;
--background: #151517;
--bg-gradient-comp-1: #151517;
--bg-gradient-comp-2: #151517;
--background-form: #19191c;
--background: #151517;
--bg-gradient-comp-1: #151517;
--bg-gradient-comp-2: #151517;
--background-form: #19191c;
--box-outline: #000;
--box-outline: #000;
&[data-theme='light'] {
--accent: #f45397;
--accent-fg: #fff;
&[data-theme='light'] {
--accent: #f45397;
--accent-fg: #fff;
--accent-2: #ffdf60;
--accent-3: #5f53f4;
--accent-4: #28dffd;
--foreground: #13151a;
--curve-sm: 4px;
--curve-md: 6px;
--curve-lg: 12px;
--danger: #ff0048;
--success: #00ff64;
--transparent-accent: #5f53f482;
--background: #feecff;
--bg-gradient-comp-1: #feecff;
--bg-gradient-comp-2: #e1e4fb;
--background-form: #fff;
}
--accent-2: #ffdf60;
--accent-3: #5f53f4;
--accent-4: #28dffd;
--foreground: #13151a;
--curve-sm: 4px;
--curve-md: 6px;
--curve-lg: 12px;
--danger: #ff0048;
--success: #00ff64;
--transparent-accent: #5f53f482;
--background: #feecff;
--bg-gradient-comp-1: #feecff;
--bg-gradient-comp-2: #e1e4fb;
--background-form: #fff;
}
}

View file

@ -1,5 +1,3 @@
export interface ShortService {
name: string;
description: string;
@ -42,7 +40,6 @@ export interface Category {
sections: Section[];
}
export interface AwesomePrivacy {
categories: Array<{
name: string;

View file

@ -1,11 +1,13 @@
function cleanUrl(inputString: string) {
return inputString.replace(/['";]+/g, '').trim();
}
export const site = cleanUrl(
import.meta.env.SITE_URL || 'https://awesome-privacy.xyz',
);
export const site = cleanUrl(import.meta.env.SITE_URL || 'https://awesome-privacy.xyz');
export const title =
'Awesome Privacy | Compare privacy-respecting alternatives to popular software & services';
export const title = 'Awesome Privacy | Compare privacy-respecting alternatives to popular software & services';
export const description = 'Your guide to escaping big tech, protecting your privacy, and reclaiming your digital life.';
export const description =
'Your guide to escaping big tech, protecting your privacy, and reclaiming your digital life.';

View file

@ -1,60 +1,82 @@
import { slugify } from '@utils/fetch-data';
export const makeRemovalRequest = (categoryName: string, sectionName: string, serviceName: string, yaml?: string) => {
export const makeRemovalRequest = (
categoryName: string,
sectionName: string,
serviceName: string,
yaml?: string,
) => {
const title = `[REMOVAL] ${serviceName}`;
const under = `**${serviceName}** (source: [${categoryName}${sectionName}${serviceName}`
+ `](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`;
const removalData = `&title=${encodeURIComponent(title)}&removal-data=`
+ `${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`;
const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new'
const baseOptions = '?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+'
+ 'Review&projects=&template=removal.yml'
const under =
`**${serviceName}** (source: [${categoryName}${sectionName}${serviceName}` +
`](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`;
const removalData =
`&title=${encodeURIComponent(title)}&removal-data=` +
`${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`;
const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new';
const baseOptions =
'?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+' +
'Review&projects=&template=removal.yml';
return `${issueCreate}${baseOptions}${removalData}`;
};
export const makeEditRequest = (categoryName: string, sectionName: string, serviceName: string, yaml?: string) => {
export const makeEditRequest = (
categoryName: string,
sectionName: string,
serviceName: string,
yaml?: string,
) => {
const title = `[AMENDMENT] ${serviceName}`;
const under = `**${serviceName}** (source: [${categoryName}${sectionName}${serviceName}`
+ `](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`;
const removalData = `&title=${encodeURIComponent(title)}&amendment-data=`
+ `${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`;
const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new'
const baseOptions = '?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+'
+ 'Review&projects=&template=amendment.yml'
const under =
`**${serviceName}** (source: [${categoryName}${sectionName}${serviceName}` +
`](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`;
const removalData =
`&title=${encodeURIComponent(title)}&amendment-data=` +
`${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`;
const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new';
const baseOptions =
'?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+' +
'Review&projects=&template=amendment.yml';
return `${issueCreate}${baseOptions}${removalData}`;
};
export const makeAdditionRequest = (formData: {
listingCategory: string;
serviceName: string;
serviceUrl: string;
serviceIcon: string;
serviceDescription: string;
serviceGithub: string;
serviceTosdrId: string;
serviceIosApp: string,
serviceAndroidApp: string,
serviceDiscordInvite: string,
serviceSubreddit: string,
serviceOpenSource: boolean;
serviceSecurityAudited: boolean;
serviceCrypto: boolean;
additionalInfo: string;
}, yamlText?: string) => {
const userInfo = formData.additionalInfo.split('\n').map(line => `> ${line}`).join('\n');
const additionalInfoText: string = `\n${userInfo}`
+ `\n\n**YAML Content for Addition**\n\n\`\`\`yaml\n${yamlText || '# nothing yet'}\n\`\`\`\n`
+ `\n\n<sup>This ticket was submitted via `
+ `<a href="https://awesome-privacy.xyz/submit">awesome-privacy.xyz/submit</a></sup>`;
export const makeAdditionRequest = (
formData: {
listingCategory: string;
serviceName: string;
serviceUrl: string;
serviceIcon: string;
serviceDescription: string;
serviceGithub: string;
serviceTosdrId: string;
serviceIosApp: string;
serviceAndroidApp: string;
serviceDiscordInvite: string;
serviceSubreddit: string;
serviceOpenSource: boolean;
serviceSecurityAudited: boolean;
serviceCrypto: boolean;
additionalInfo: string;
},
yamlText?: string,
) => {
const userInfo = formData.additionalInfo
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
const additionalInfoText: string =
`\n${userInfo}` +
`\n\n**YAML Content for Addition**\n\n\`\`\`yaml\n${yamlText || '# nothing yet'}\n\`\`\`\n` +
`\n\n<sup>This ticket was submitted via ` +
`<a href="https://awesome-privacy.xyz/submit">awesome-privacy.xyz/submit</a></sup>`;
const issueTitle = `[ADDITION] ${formData.serviceName} (Complete)`;
const queryParams = new URLSearchParams({
'assignees': 'lissy93,liss-bot',
'labels': '',
'projects': '',
'template': 'complete-addition.yml',
'title': issueTitle,
assignees: 'lissy93,liss-bot',
labels: '',
projects: '',
template: 'complete-addition.yml',
title: issueTitle,
'listing-category': formData.listingCategory,
'service-name': formData.serviceName,
'service-url': formData.serviceUrl,
@ -63,38 +85,59 @@ export const makeAdditionRequest = (formData: {
'service-github': formData.serviceGithub,
'service-tosdr-id': formData.serviceTosdrId,
'service-opensource': formData.serviceOpenSource ? 'true' : 'false',
'service-security-audited': formData.serviceSecurityAudited ? 'true' : 'false',
'service-security-audited': formData.serviceSecurityAudited
? 'true'
: 'false',
'service-crypto': formData.serviceCrypto ? 'true' : 'false',
'additional-info': additionalInfoText,
});
const issueCreateUrl = 'https://github.com/Lissy93/awesome-privacy/issues/new';
const issueCreateUrl =
'https://github.com/Lissy93/awesome-privacy/issues/new';
return `${issueCreateUrl}?${queryParams.toString()}`;
};
export const makeSourceYamlLink = async (categoryName: string, sectionName: string, serviceName: string) => {
export const makeSourceYamlLink = async (
categoryName: string,
sectionName: string,
serviceName: string,
) => {
const sourceData = await fetchSrcData(categoryName, sectionName, serviceName);
const lineNumbers = sourceData.lineNumbers || null;
const numberRange = lineNumbers ? `L${lineNumbers.start}-L${lineNumbers.end}` : '';
const yamlLink = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
const numberRange = lineNumbers
? `L${lineNumbers.start}-L${lineNumbers.end}`
: '';
const yamlLink =
'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
return `${yamlLink}${numberRange}`;
};
export const fetchSrcData = async (categoryName: string, sectionName: string, serviceName: string) => {
const lineNumberData = await fetch('/api/line-numbers.json')
.then((res) => res.json());
export const fetchSrcData = async (
categoryName: string,
sectionName: string,
serviceName: string,
) => {
const lineNumberData = await fetch('/api/line-numbers.json').then((res) =>
res.json(),
);
if ( lineNumberData
&& lineNumberData[categoryName]
&& lineNumberData[categoryName][sectionName]
&& lineNumberData[categoryName][sectionName][serviceName]
if (
lineNumberData &&
lineNumberData[categoryName] &&
lineNumberData[categoryName][sectionName] &&
lineNumberData[categoryName][sectionName][serviceName]
) {
return {
lineNumbers: lineNumberData[categoryName][sectionName][serviceName].lineNumbers,
lineNumbers:
lineNumberData[categoryName][sectionName][serviceName].lineNumbers,
yamlContent: lineNumberData[categoryName][sectionName][serviceName].yaml,
};
} else {
console.error('No line number data found for', categoryName, sectionName, serviceName);
console.error(
'No line number data found for',
categoryName,
sectionName,
serviceName,
);
return { lineNumbers: [], yamlContent: '' };
}
};

View file

@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { formatDate, timestampToDate } from './dates-n-stuff';
describe('formatDate', () => {
it('formats an ISO date string to en-GB short format', () => {
const result = formatDate('2024-01-15');
expect(result).toBe('15 Jan 24');
});
it('formats a different date correctly', () => {
const result = formatDate('2023-12-25');
expect(result).toBe('25 Dec 23');
});
it('handles full ISO datetime string', () => {
const result = formatDate('2024-06-01T12:00:00Z');
expect(result).toBe('01 Jun 24');
});
});
describe('timestampToDate', () => {
it('converts a Unix timestamp (ms) to en-GB short format', () => {
// 2024-01-15T00:00:00Z = 1705276800000
const result = timestampToDate(1705276800000);
expect(result).toBe('15 Jan 24');
});
it('converts epoch 0 to 01 Jan 70', () => {
const result = timestampToDate(0);
expect(result).toBe('01 Jan 70');
});
});

View file

@ -2,21 +2,22 @@ export const formatDate = (date: string): string => {
return new Date(date).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: '2-digit'
year: '2-digit',
});
}
};
export const timestampToDate = (timestamp: number): string => {
return new Date(timestamp).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: '2-digit'
year: '2-digit',
});
}
};
export const timeAgo = (dateStr: string): string => {
const seconds = Math.floor((new Date().getTime() - new Date(dateStr).getTime()) / 1000);
const seconds = Math.floor(
(new Date().getTime() - new Date(dateStr).getTime()) / 1000,
);
const intervals = {
year: 31536000,
month: 2592000,

View file

@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import { prepareSearchItems } from './do-searchy-searchy';
import type { SearchItem } from './do-searchy-searchy';
import type { Category } from '../types/Service';
const makeCategory = (overrides: Partial<Category> = {}): Category =>
({
name: 'Test Category',
sections: [],
...overrides,
}) as Category;
describe('prepareSearchItems', () => {
it('returns an empty array for no categories', () => {
expect(prepareSearchItems([])).toEqual([]);
});
it('creates a category item', () => {
const categories = [makeCategory({ name: 'Privacy Tools' })];
const items = prepareSearchItems(categories);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
type: 'Category',
category: 'Privacy Tools',
itemCount: 0,
});
});
it('creates section items with category context', () => {
const categories = [
makeCategory({
name: 'Comms',
sections: [
{ name: 'Messaging', intro: 'Secure messaging apps', services: [] },
],
}),
] as Category[];
const items = prepareSearchItems(categories);
const section = items.find((i: SearchItem) => i.type === 'Section');
expect(section).toMatchObject({
type: 'Section',
sectionName: 'Messaging',
description: 'Secure messaging apps',
category: 'Comms',
itemCount: 0,
});
});
it('creates service items with section and category context', () => {
const categories = [
makeCategory({
name: 'Comms',
sections: [
{
name: 'Messaging',
services: [
{
name: 'Signal',
description: 'Encrypted messenger',
url: 'https://signal.org',
github: 'signalapp/Signal-Android',
icon: 'signal.png',
},
],
},
],
}),
] as Category[];
const items = prepareSearchItems(categories);
const service = items.find((i: SearchItem) => i.type === 'Service');
expect(service).toMatchObject({
type: 'Service',
name: 'Signal',
description: 'Encrypted messenger',
url: 'https://signal.org',
github: 'signalapp/Signal-Android',
category: 'Comms',
sectionName: 'Messaging',
logo: 'signal.png',
});
});
it('counts services across sections for category itemCount', () => {
const categories = [
makeCategory({
name: 'Tools',
sections: [
{
name: 'A',
services: [
{ name: 's1', description: '', url: '' },
{ name: 's2', description: '', url: '' },
],
},
{
name: 'B',
services: [{ name: 's3', description: '', url: '' }],
},
],
}),
] as Category[];
const items = prepareSearchItems(categories);
const cat = items.find((i: SearchItem) => i.type === 'Category');
expect(cat?.itemCount).toBe(3);
});
});

View file

@ -1,19 +1,31 @@
import type { Category } from '../types/Service';
export const prepareSearchItems = (categories: Category[]) => {
const items: any = [];
export interface SearchItem {
type: 'Category' | 'Section' | 'Service';
category: string;
itemCount?: number;
sectionName?: string;
description?: string;
name?: string;
url?: string;
github?: string;
logo?: string;
}
export const prepareSearchItems = (categories: Category[]): SearchItem[] => {
const items: SearchItem[] = [];
// Add each category
categories.forEach(category => {
categories.forEach((category) => {
items.push({
type: 'Category',
category: category.name,
itemCount: (category.sections || []).reduce((acc, section) => {
return acc + (section.services || []).length;
}, 0),
return acc + (section.services || []).length;
}, 0),
});
// Add section with category context
category.sections.forEach(section => {
category.sections.forEach((section) => {
items.push({
type: 'Section',
sectionName: section.name,
@ -21,9 +33,9 @@ export const prepareSearchItems = (categories: Category[]) => {
category: category.name,
itemCount: (section.services || []).length,
});
// Add service with section and category context
(section.services || []).forEach(service => {
(section.services || []).forEach((service) => {
items.push({
type: 'Service',
name: service.name,
@ -53,6 +65,6 @@ export const searchOptions = {
{ name: 'description', weight: 0.1 },
{ name: 'intro', weight: 0.1 },
{ name: 'furtherInfo', weight: 0.1 },
{ name: 'wordOfWarning', weight: 0.1 },
{ name: 'wordOfWarning', weight: 0.1 },
],
};

View file

@ -1,14 +1,22 @@
import { error } from './logger';
const doubleCheckPackageName = (packageStr: string) => {
return packageStr.includes('id=') ? packageStr.split('id=')[1] : packageStr;
}
};
export const fetchAndroidInfo = async (androidPackage: string): Promise<AndroidInfo | null> => {
export const fetchAndroidInfo = async (
androidPackage: string,
): Promise<AndroidInfo | null> => {
const endpoint = `https://android-app-privacy.as93.net/${doubleCheckPackageName(androidPackage)}`;
try {
return await fetch(endpoint).then((res) => res.json());
} catch (error) {
console.error('Error fetching android data:', error);
const res = await fetch(endpoint);
if (!res.ok) {
error('Android', `HTTP ${res.status} for ${androidPackage} (${endpoint})`);
return null;
}
return await res.json();
} catch (err) {
error('Android', `Network error for ${androidPackage}: ${err}`);
return null;
}
};
@ -43,5 +51,3 @@ export interface AndroidInfo {
trackers: Tracker[];
permissions: string[];
}

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { slugify } from './fetch-data';
describe('slugify', () => {
it('lowercases and replaces spaces with hyphens', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('replaces & with "and"', () => {
expect(slugify('Privacy & Security')).toBe('privacy-and-security');
});
it('replaces + with "and"', () => {
expect(slugify('Tools + Tips')).toBe('tools-and-tips');
});
it('removes question marks', () => {
expect(slugify('What is Privacy?')).toBe('what-is-privacy');
});
it('handles multiple spaces', () => {
expect(slugify('a b c')).toBe('a--b---c');
});
it('returns empty string for empty input', () => {
expect(slugify('')).toBe('');
});
it('returns empty string for undefined-like input', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(slugify(undefined as any)).toBe('');
});
it('handles combined special characters', () => {
expect(slugify('Q&A + FAQ?')).toBe('qanda-and-faq');
});
});

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