├── .changeset ├── README.md └── config.json ├── .github ├── FUNDING.yml ├── actions │ └── pnpm-install │ │ └── action.yml └── workflows │ ├── build.yml │ ├── ci.yml │ ├── cr.yml │ ├── e2e.yml │ ├── release.yml │ └── unit-testing.yml ├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── demos └── website │ ├── .env.example │ ├── .gitignore │ ├── README.md │ ├── astro.config.ts │ ├── package.json │ ├── public │ └── og.png │ ├── src │ ├── assets │ │ ├── flickr.svg │ │ └── noise.png │ ├── components │ │ ├── ArtImage.astro │ │ ├── ExampleOutput.astro │ │ ├── Icon.astro │ │ ├── NpmInstall.astro │ │ ├── Section.astro │ │ ├── clerk │ │ │ └── GetUserList.astro │ │ ├── flickr │ │ │ ├── PeopleGetPhotos.astro │ │ │ ├── PhotosetsGetList.astro │ │ │ ├── PhotosetsGetListWithPhotos.astro │ │ │ └── PhotosetsGetPhotos.astro │ │ └── plausible │ │ │ └── Plausible.astro │ ├── constants.ts │ ├── content.config.ts │ ├── global.css │ ├── layouts │ │ └── Layout.astro │ └── pages │ │ └── index.astro │ └── tsconfig.json ├── e2e ├── clerk.spec.ts ├── flickr.spec.ts ├── plausible.spec.ts ├── smoke.spec.ts └── tsconfig.json ├── eslint.config.mjs ├── package.json ├── packages ├── clerk │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ ├── types.test.ts │ │ │ └── utils.test.ts │ │ ├── clerk-loader.ts │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── openapi.ts │ │ ├── schema.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── flickr │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── loaders │ │ │ ├── people-getPhotos.ts │ │ │ ├── photosets-getList-withPhotos.ts │ │ │ ├── photosets-getList.ts │ │ │ └── photosets-getPhotos.ts │ │ ├── schema.ts │ │ ├── types │ │ │ ├── flickr.ts │ │ │ └── loader.ts │ │ └── utils │ │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── normalize.test.ts.snap │ │ │ ├── fixtures │ │ │ │ ├── people-getPhotos.json │ │ │ │ ├── photosets-getList.json │ │ │ │ └── photosets-getPhotos.json │ │ │ └── normalize.test.ts │ │ │ ├── errors.ts │ │ │ ├── get-user-id.ts │ │ │ ├── ky.ts │ │ │ ├── normalize.ts │ │ │ └── paginate.ts │ ├── tsconfig.json │ └── tsdown.config.ts └── plausible │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── __tests__ │ │ ├── fixtures │ │ │ └── responses.ts │ │ ├── schema.test.ts │ │ └── types.test-d.ts │ ├── index.ts │ ├── ky.ts │ ├── plausible-loader.ts │ ├── schema.ts │ └── types.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── playwright.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.4/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { "repo": "LekoArts/astro-loaders" } 6 | ], 7 | "commit": false, 8 | "fixed": [], 9 | "linked": [], 10 | "access": "public", 11 | "baseBranch": "main", 12 | "updateInternalDependencies": "patch", 13 | "ignore": ["website"] 14 | } 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [LekoArts] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: lekoarts 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/actions/pnpm-install/action.yml: -------------------------------------------------------------------------------- 1 | # Based on https://gist.github.com/belgattitude/838b2eba30c324f1f0033a797bab2e31 2 | 3 | name: PNPM install 4 | description: Run pnpm install with cache enabled 5 | 6 | inputs: 7 | enable-corepack: 8 | description: Enable corepack 9 | required: false 10 | default: 'true' 11 | node-version: 12 | description: Node version to use 13 | required: false 14 | default: '22' 15 | 16 | runs: 17 | using: composite 18 | 19 | steps: 20 | - name: Install Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ inputs.node-version }} 24 | check-latest: true 25 | - name: ⚙️ Enable Corepack 26 | if: ${{ inputs.enable-corepack == 'true' }} 27 | shell: bash 28 | run: | 29 | npm install -g corepack 30 | corepack enable 31 | echo "Corepack enabled" 32 | 33 | - uses: pnpm/action-setup@v4.1.0 34 | if: ${{ inputs.enable-corepack == 'false' }} 35 | with: 36 | run_install: false 37 | # If you're not setting the packageManager field in package.json, add the version here 38 | # version: 8.6.7 39 | 40 | - name: Expose pnpm config(s) through "$GITHUB_OUTPUT" 41 | id: pnpm-config 42 | shell: bash 43 | run: | 44 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT 45 | 46 | - name: Cache rotation keys 47 | id: cache-rotation 48 | shell: bash 49 | run: | 50 | echo "YEAR_MONTH=$(/bin/date -u "+%Y%m")" >> $GITHUB_OUTPUT 51 | 52 | - uses: actions/cache@v4 53 | name: Setup pnpm cache 54 | with: 55 | path: ${{ steps.pnpm-config.outputs.STORE_PATH }} 56 | key: ${{ runner.os }}-node-${{ inputs.node-version }}-pnpm-store-cache-${{ steps.cache-rotation.outputs.YEAR_MONTH }}-${{ hashFiles('**/pnpm-lock.yaml') }} 57 | restore-keys: | 58 | ${{ runner.os }}-node-${{ inputs.node-version }}-pnpm-store-cache-${{ steps.cache-rotation.outputs.YEAR_MONTH }}- 59 | 60 | - name: Install dependencies 61 | shell: bash 62 | run: pnpm install --frozen-lockfile --prefer-offline 63 | env: 64 | HUSKY: '0' # By default do not run HUSKY install 65 | SKIP_INSTALL_SIMPLE_GIT_HOOKS: 1 # By default do not run simple-git-hooks install 66 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Packages 2 | 3 | on: workflow_call 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | - name: Install dependencies 12 | uses: ./.github/actions/pnpm-install 13 | - name: Build Packages 14 | run: pnpm run build 15 | - name: Run publint & attw 16 | run: pnpm run check 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: ci-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | name: Build Packages 16 | uses: ./.github/workflows/build.yml 17 | unit-testing: 18 | name: Unit Testing 19 | needs: build 20 | uses: ./.github/workflows/unit-testing.yml 21 | e2e-testing: 22 | name: E2E Testing 23 | needs: build 24 | uses: ./.github/workflows/e2e.yml 25 | secrets: inherit 26 | -------------------------------------------------------------------------------- /.github/workflows/cr.yml: -------------------------------------------------------------------------------- 1 | name: CR 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: [opened, synchronize, labeled] 8 | 9 | permissions: {} 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.event.number }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | release: 17 | if: github.repository == 'LekoArts/astro-loaders' && (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'snapshot')) 18 | runs-on: ubuntu-latest 19 | name: 'Release: pkg.pr.new' 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Install dependencies 24 | uses: ./.github/actions/pnpm-install 25 | - name: Build Packages 26 | run: pnpm run build 27 | - name: Publish to StackBlitz 28 | run: pnpx pkg-pr-new publish --compact --no-template --pnpm './packages/*' 29 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E Testing 2 | 3 | on: workflow_call 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | e2e-testing: 11 | timeout-minutes: 60 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: lts/* 18 | check-latest: true 19 | - uses: actions/cache@v4 20 | id: playwright-cache 21 | with: 22 | path: | 23 | ~/.cache/ms-playwright 24 | key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }} 25 | restore-keys: | 26 | ${{ runner.os }}-playwright- 27 | - uses: actions/cache@v4 28 | id: astro-cache 29 | env: 30 | files: >- 31 | ${{ hashFiles('demos/website/astro.config.ts') }}-${{ hashFiles('demos/website/src/content.config.ts') }} 32 | with: 33 | path: | 34 | demos/website/dist 35 | key: ${{ runner.os }}-astro-${{ env.files }}-${{ hashFiles('**/pnpm-lock.yaml') }} 36 | restore-keys: | 37 | ${{ runner.os }}-astro- 38 | - name: Install dependencies 39 | uses: ./.github/actions/pnpm-install 40 | - name: Build Packages 41 | run: pnpm run build 42 | - name: Install Playwright 43 | if: steps.playwright-cache.outputs.cache-hit != 'true' 44 | run: npx playwright install chromium --with-deps 45 | - name: Build Astro site 46 | run: pnpm run website:build 47 | env: 48 | FLICKR_API_KEY: ${{ secrets.FLICKR_API_KEY }} 49 | CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} 50 | PLAUSIBLE_API_KEY: ${{ secrets.PLAUSIBLE_API_KEY }} 51 | - name: Run Playwright 52 | run: pnpm run e2e:build 53 | - uses: actions/upload-artifact@v4 54 | if: ${{ cancelled() || failure() }} 55 | with: 56 | name: playwright-traces-${{ github.run_id }}-${{ github.run-attempt }} 57 | path: playwright-report/ 58 | retention-days: 1 59 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | id-token: write 13 | packages: write 14 | pull-requests: write 15 | issues: read 16 | statuses: write 17 | checks: write 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - name: Install dependencies 24 | uses: ./.github/actions/pnpm-install 25 | - name: Build Packages 26 | run: pnpm run build 27 | - name: Check publint & attw 28 | run: pnpm run check 29 | - name: Create Release Pull Request or Publish to npm 30 | id: changesets 31 | uses: changesets/action@v1 32 | with: 33 | commit: 'chore(release): Publish' 34 | title: 'Changesets: Version Packages' 35 | publish: pnpm release 36 | version: pnpm version:ci 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | NPM_CONFIG_PROVENANCE: true 41 | -------------------------------------------------------------------------------- /.github/workflows/unit-testing.yml: -------------------------------------------------------------------------------- 1 | name: Unit Testing 2 | 3 | on: workflow_call 4 | 5 | jobs: 6 | unit-testing: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | version: [18, 20, 22] 11 | name: Node.js ${{ matrix.version }} 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Install dependencies 16 | uses: ./.github/actions/pnpm-install 17 | with: 18 | node-version: ${{ matrix.version }} 19 | - name: Run Vitest 20 | run: pnpm run test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !.vscode 2 | .cache 3 | coverage 4 | 5 | # build output 6 | dist/ 7 | 8 | # generated types 9 | .astro/ 10 | 11 | # dependencies 12 | node_modules/ 13 | 14 | # logs 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | pnpm-debug.log* 19 | 20 | # environment variables 21 | .env 22 | .env.production 23 | .env.development 24 | .env.local 25 | 26 | # macOS-specific files 27 | .DS_Store 28 | 29 | # jetbrains setting folder 30 | .idea/ 31 | /test-results/ 32 | /playwright-report/ 33 | /blob-report/ 34 | /playwright/.cache/ 35 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14.0 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Disable the default formatter, use eslint instead 3 | "prettier.enable": false, 4 | "editor.formatOnSave": false, 5 | 6 | // Auto fix 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit", 9 | "source.organizeImports": "never" 10 | }, 11 | 12 | // Silent the stylistic rules in you IDE, but still auto fix them 13 | "eslint.rules.customizations": [ 14 | { "rule": "style/*", "severity": "off", "fixable": true }, 15 | { "rule": "format/*", "severity": "off", "fixable": true }, 16 | { "rule": "*-indent", "severity": "off", "fixable": true }, 17 | { "rule": "*-spacing", "severity": "off", "fixable": true }, 18 | { "rule": "*-spaces", "severity": "off", "fixable": true }, 19 | { "rule": "*-order", "severity": "off", "fixable": true }, 20 | { "rule": "*-dangle", "severity": "off", "fixable": true }, 21 | { "rule": "*-newline", "severity": "off", "fixable": true }, 22 | { "rule": "*quotes", "severity": "off", "fixable": true }, 23 | { "rule": "*semi", "severity": "off", "fixable": true } 24 | ], 25 | 26 | // Enable eslint for all supported languages 27 | "eslint.validate": [ 28 | "javascript", 29 | "javascriptreact", 30 | "typescript", 31 | "typescriptreact", 32 | "vue", 33 | "html", 34 | "markdown", 35 | "json", 36 | "json5", 37 | "jsonc", 38 | "yaml", 39 | "toml", 40 | "xml", 41 | "gql", 42 | "graphql", 43 | "astro", 44 | "css", 45 | "less", 46 | "scss", 47 | "pcss", 48 | "postcss" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LekoArts 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Astro Loaders by LekoArts 2 | 3 | Loaders for Astro's [content layer](https://docs.astro.build/en/guides/content-collections/). 4 | 5 | **Want to see an overview of all my loaders? Visit [astro-loaders.lekoarts.de](https://astro-loaders.lekoarts.de) ✨** 6 | 7 | ## Packages 8 | 9 | - [@lekoarts/flickr-loader](./packages/flickr/) - Load data from Flickr 10 | - [@lekoarts/clerk-loader](./packages/clerk/) - Load data from Clerk 11 | - [@lekoarts/plausible-loader](./packages/plausible/) - Load data from Plausible 12 | -------------------------------------------------------------------------------- /demos/website/.env.example: -------------------------------------------------------------------------------- 1 | FLICKR_API_KEY= 2 | CLERK_SECRET_KEY= 3 | PLAUSIBLE_API_KEY= -------------------------------------------------------------------------------- /demos/website/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # jetbrains setting folder 24 | .idea/ 25 | -------------------------------------------------------------------------------- /demos/website/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | Showcasing all loaders ✨ 4 | -------------------------------------------------------------------------------- /demos/website/astro.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite' 2 | import { defineConfig } from 'astro/config' 3 | 4 | // https://astro.build/config 5 | export default defineConfig({ 6 | vite: { 7 | plugins: [tailwindcss()], 8 | }, 9 | devToolbar: { 10 | enabled: false, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /demos/website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "private": true, 6 | "scripts": { 7 | "dev": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@lekoarts/clerk-loader": "workspace:^", 14 | "@lekoarts/flickr-loader": "workspace:^", 15 | "@lekoarts/plausible-loader": "workspace:^", 16 | "@tailwindcss/vite": "^4.1.7", 17 | "astro": "catalog:astro", 18 | "tailwindcss": "^4.1.7" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /demos/website/public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LekoArts/astro-loaders/142f727a1a75ce0d5a57811b6b3f7775710aed0d/demos/website/public/og.png -------------------------------------------------------------------------------- /demos/website/src/assets/flickr.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demos/website/src/assets/noise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LekoArts/astro-loaders/142f727a1a75ce0d5a57811b6b3f7775710aed0d/demos/website/src/assets/noise.png -------------------------------------------------------------------------------- /demos/website/src/components/ArtImage.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { HTMLAttributes } from 'astro/types' 3 | 4 | interface Props extends HTMLAttributes<'a'> { 5 | images: IImages 6 | photoId: string 7 | alt: string 8 | imgClass?: string 9 | } 10 | 11 | interface IImageSize { 12 | url: string 13 | width: number 14 | height: number 15 | } 16 | 17 | interface IImages { 18 | lg: IImageSize 19 | md: IImageSize 20 | sm: IImageSize 21 | } 22 | 23 | const getSrcSet = (images: IImages) => { 24 | const { lg, md, sm } = images 25 | 26 | return ` 27 | ${lg.url} ${lg.width}w, 28 | ${md.url} ${md.width}w, 29 | ${sm.url} ${sm.width}w 30 | ` 31 | } 32 | 33 | const { images, alt, class: className } = Astro.props 34 | 35 | const src = images.lg.url 36 | const { width: maxWidth, height: maxHeight } = images.lg 37 | const aspectRatio = maxWidth / maxHeight 38 | --- 39 | 40 | {alt} -------------------------------------------------------------------------------- /demos/website/src/components/ExampleOutput.astro: -------------------------------------------------------------------------------- 1 |
Example Output
-------------------------------------------------------------------------------- /demos/website/src/components/Icon.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { HTMLAttributes } from 'astro/types' 3 | 4 | interface Props extends HTMLAttributes<'svg'> { 5 | name: 'clerk' | 'plausible' | 'github' 6 | } 7 | 8 | const { name, class: className } = Astro.props 9 | --- 10 | 11 | {name === 'clerk' ? ( 12 | 13 | ) : name === 'plausible' ? ( 14 | 15 | ) : name === 'github' ? ( 16 | 17 | ) : null} -------------------------------------------------------------------------------- /demos/website/src/components/NpmInstall.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = { 3 | url: string 4 | name: string 5 | } 6 | 7 | const { url, name } = Astro.props; 8 | --- 9 | 10 | Install {name} -------------------------------------------------------------------------------- /demos/website/src/components/Section.astro: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /demos/website/src/components/clerk/GetUserList.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | 4 | const allUsers = await getCollection('clerk') 5 | --- 6 | 7 |

Return values of users.getUserList():

8 |
9 | {allUsers.map(({ data }) => ( 10 | {data.username} 11 | ))} 12 |
-------------------------------------------------------------------------------- /demos/website/src/components/flickr/PeopleGetPhotos.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | import ArtImage from '../ArtImage.astro'; 4 | 5 | const allPeopleGetPhotos = await getCollection('peopleGetPhotos') 6 | --- 7 | 8 |
9 | {allPeopleGetPhotos.slice(0, 6).map(({ data: photo }) => { 10 | const sm = photo.imageUrls['640px'] 11 | const md = photo.imageUrls['800px'] 12 | const lg = photo.imageUrls['1024px'] 13 | 14 | return ( 19 | ) 20 | })} 21 |
-------------------------------------------------------------------------------- /demos/website/src/components/flickr/PhotosetsGetList.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | 4 | const allPhotosetsGetList = await getCollection('photosetsGetList') 5 | --- 6 | 7 |
8 | {allPhotosetsGetList.slice(0, 6).map(({ data }) => ( 9 | {data.title} ({data.photos}) 10 | ))} 11 |
-------------------------------------------------------------------------------- /demos/website/src/components/flickr/PhotosetsGetListWithPhotos.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | import ArtImage from '../ArtImage.astro'; 4 | 5 | const photosetsGetPhotos = await getCollection('photosetsGetListWithPhotos') 6 | --- 7 | 8 | {photosetsGetPhotos.slice(0,2).map(({ data: photoset }, index) => ( 9 |

Title: {photoset.title} - Total images: {photoset.photos.length}

10 |
11 | {photoset.photos.slice(0, 6).map((photo) => { 12 | const sm = photo.imageUrls['640px'] 13 | const md = photo.imageUrls['800px'] 14 | const lg = photo.imageUrls['1024px'] 15 | 16 | return ( 21 | ) 22 | })} 23 |
24 | ))} -------------------------------------------------------------------------------- /demos/website/src/components/flickr/PhotosetsGetPhotos.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | import ArtImage from '../ArtImage.astro'; 4 | 5 | const photosetsGetPhotos = await getCollection('photosetsGetPhotos') 6 | 7 | const photoset = photosetsGetPhotos[0].data.photoset 8 | --- 9 | 10 |
11 |

Title: {photoset.title} - Total images: {photoset.total}

12 |
13 | {photosetsGetPhotos.slice(0, 6).map(({ data: photo }) => { 14 | const sm = photo.imageUrls['640px'] 15 | const md = photo.imageUrls['800px'] 16 | const lg = photo.imageUrls['1024px'] 17 | 18 | return ( 23 | ) 24 | })} 25 |
26 |
-------------------------------------------------------------------------------- /demos/website/src/components/plausible/Plausible.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from 'astro:content'; 3 | 4 | const result = await getCollection('plausible') 5 | --- 6 | 7 |

Return value of a custom query:

8 |
{JSON.stringify(result[0].data, null, 2)}
-------------------------------------------------------------------------------- /demos/website/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const FLICKR_USERNAME = 'ars_aurea' 2 | -------------------------------------------------------------------------------- /demos/website/src/content.config.ts: -------------------------------------------------------------------------------- 1 | import { clerkLoader } from '@lekoarts/clerk-loader' 2 | import { flickrPeopleGetPhotosLoader, flickrPhotosetsGetListLoader, flickrPhotosetsGetListWithPhotosLoader, flickrPhotosetsGetPhotosLoader } from '@lekoarts/flickr-loader' 3 | import { plausibleLoader } from '@lekoarts/plausible-loader' 4 | import { defineCollection } from 'astro:content' 5 | import { FLICKR_USERNAME } from './constants' 6 | 7 | const peopleGetPhotos = defineCollection({ 8 | loader: flickrPeopleGetPhotosLoader({ 9 | username: FLICKR_USERNAME, 10 | }), 11 | }) 12 | 13 | const photosetsGetList = defineCollection({ 14 | loader: flickrPhotosetsGetListLoader({ 15 | username: FLICKR_USERNAME, 16 | }), 17 | }) 18 | 19 | const photosetsGetPhotos = defineCollection({ 20 | loader: flickrPhotosetsGetPhotosLoader({ 21 | username: FLICKR_USERNAME, 22 | photoset_id: '72177720313250218', 23 | }), 24 | }) 25 | 26 | const photosetsGetListWithPhotos = defineCollection({ 27 | loader: flickrPhotosetsGetListWithPhotosLoader({ 28 | username: FLICKR_USERNAME, 29 | in: ['72177720317993398', '72177720317980095'], 30 | }), 31 | }) 32 | 33 | const clerk = defineCollection({ 34 | loader: clerkLoader({ 35 | method: { 36 | name: 'users.getUserList', 37 | }, 38 | }), 39 | }) 40 | 41 | const plausible = defineCollection({ 42 | loader: plausibleLoader({ 43 | query: { 44 | site_id: 'lekoarts.de', 45 | metrics: ['visitors'], 46 | date_range: ['2024-08-01', '2024-08-15'], 47 | }, 48 | }), 49 | }) 50 | 51 | export const collections = { 52 | peopleGetPhotos, 53 | photosetsGetList, 54 | photosetsGetPhotos, 55 | photosetsGetListWithPhotos, 56 | clerk, 57 | plausible, 58 | } 59 | -------------------------------------------------------------------------------- /demos/website/src/global.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | .force-square-aspect-ratio { 4 | aspect-ratio: 1 !important; 5 | object-fit: cover; 6 | max-height: 100% !important; 7 | } 8 | 9 | .noise-bg { 10 | position: relative; 11 | } 12 | 13 | .noise-bg::before { 14 | background-image: url('./assets/noise.png'); 15 | background-blend-mode: hard-light; 16 | content: ''; 17 | position: absolute; 18 | top: 0; 19 | left: 0; 20 | right: 0; 21 | bottom: 0; 22 | opacity: 0.25; 23 | pointer-events: none; 24 | } 25 | -------------------------------------------------------------------------------- /demos/website/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import "../global.css" 3 | 4 | const seo = { 5 | title: 'Astro Loaders by LekoArts', 6 | description: "Need to pull in content using Astro's content layer? Reach for one of the provided loaders and stop dealing with complicated third-party APIs.", 7 | url: 'https://astro-loaders.lekoarts.de', 8 | image: 'https://astro-loaders.lekoarts.de/og.png' 9 | } 10 | --- 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {seo.title} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | 42 |
43 | 44 | 45 | 46 | 59 | -------------------------------------------------------------------------------- /demos/website/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import PeopleGetPhotos from '../components/flickr/PeopleGetPhotos.astro'; 3 | import Layout from '../layouts/Layout.astro'; 4 | import Section from '../components/Section.astro'; 5 | import flickrLogo from '../assets/flickr.svg'; 6 | import NpmInstall from '../components/NpmInstall.astro'; 7 | import ExampleOutput from '../components/ExampleOutput.astro'; 8 | import PhotosetsGetList from '../components/flickr/PhotosetsGetList.astro'; 9 | import PhotosetsGetPhotos from '../components/flickr/PhotosetsGetPhotos.astro'; 10 | import PhotosetsGetListWithPhotos from '../components/flickr/PhotosetsGetListWithPhotos.astro'; 11 | import GetUserList from '../components/clerk/GetUserList.astro'; 12 | import Plausible from '../components/plausible/Plausible.astro'; 13 | import Icon from '../components/Icon.astro'; 14 | --- 15 | 16 | 17 |
18 |
19 |

Astro Loaders by LekoArts

20 |

Need to pull in content using Astro's content layer? Reach for one of the provided loaders below and stop dealing with complicated third-party APIs.

21 | 22 | 23 | Star on Github 24 | 25 |

Use loaders for

26 |
    27 | {[ 28 | { 29 | name: 'Clerk', 30 | icon: 31 | }, 32 | { 33 | name: 'Flickr', 34 | icon: 35 | }, 36 | { 37 | name: 'Plausible', 38 | icon: 39 | } 40 | ].map(s => ( 41 |
  • 42 | {s.icon} {s.name} 43 |
  • 44 | ))} 45 |
46 |
47 |
48 |
49 |
50 | 51 |

Clerk

52 |
53 | 54 |
55 |

clerkLoader({ clientOptions?: ClerkClientOptions; method: { name: string; options: string | Object } })

56 |

Access certain Clerk Backend APIs by choosing a method name.

57 | 58 | 59 | 60 |
61 |
62 |
63 |
64 | 65 |

Flickr

66 |
67 | 68 |
69 |

flickrPeopleGetPhotosLoader({ username: string })

70 |

Return photos from the given user's photostream. Only photos visible to the calling user will be returned.

71 | 72 | 73 | 74 |

flickrPhotosetsGetListLoader({ username: string })

75 |

Returns the photosets belonging to the specified user.

76 | 77 | 78 | 79 |

flickrPhotosetsGetPhotosLoader({ username: string; photoset_id: string })

80 |

Get the list of photos in a photoset.

81 | 82 | 83 | 84 |

flickrPhotosetsGetListWithPhotosLoader({ username: string })

85 |

This loader combines the flickrPhotosetsGetListLoader() and flickrPhotosetsGetPhotosLoader() loaders to get the most out of photosets. You'll get back the photosets and their list of photos.

86 | 87 | 88 | 89 |
90 |
91 |
92 |
93 | 94 |

Plausible

95 |
96 | 97 |
98 |

plausibleLoader({ query: PlausibleQuery })

99 |

Access the Plausible Stats API with your custom query.

100 | 101 | 102 | 103 |
104 |
105 | 108 |
109 | -------------------------------------------------------------------------------- /demos/website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"] 5 | } 6 | -------------------------------------------------------------------------------- /e2e/clerk.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/') 5 | }) 6 | 7 | test('clerk', async ({ page }) => { 8 | const userList = await page.getByTestId('clerk-getUserList-item') 9 | 10 | await expect(userList).toHaveCount(6) 11 | 12 | await expect(userList.last()).toHaveText('arsaurea') 13 | }) 14 | -------------------------------------------------------------------------------- /e2e/flickr.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/') 5 | }) 6 | 7 | test('peopleGetPhotos', async ({ page }) => { 8 | const images = await page.getByTestId('flickr-peopleGetPhotos-wrapper').getByRole('img') 9 | 10 | await expect(images).toHaveCount(6) 11 | 12 | for (const image of await images.all()) { 13 | await expect(image).toBeVisible() 14 | } 15 | }) 16 | 17 | test('photosetsGetList', async ({ page }) => { 18 | const photosets = await page.getByTestId('flickr-photosetsGetList-item') 19 | 20 | await expect(photosets).toHaveCount(6) 21 | 22 | // Items have the text pattern "Name ()" 23 | for (const photoset of await photosets.all()) { 24 | await expect(photoset).toHaveText(/(.+) \(\d+\)/) 25 | } 26 | }) 27 | 28 | test('photosetsGetPhotos', async ({ page }) => { 29 | const images = await page.getByTestId('flickr-photosetsGetPhotos-wrapper').getByRole('img') 30 | const text = await page.getByTestId('flickr-photosetsGetPhotos-text') 31 | 32 | await expect(text).toHaveText('Title: Yosemite - Total images: 12') 33 | 34 | await expect(images).toHaveCount(6) 35 | 36 | for (const image of await images.all()) { 37 | await expect(image).toBeVisible() 38 | } 39 | }) 40 | 41 | test('photosetsGetListWithPhotos', async ({ page }) => { 42 | const titleZero = await page.getByTestId('flickr-photosetsGetListWithPhotos-text-0') 43 | const titleOne = await page.getByTestId('flickr-photosetsGetListWithPhotos-text-1') 44 | 45 | await expect(titleZero).toHaveText('Title: Tallinn - Total images: 5') 46 | await expect(titleOne).toHaveText('Title: Helsinki - Total images: 7') 47 | 48 | const imagesZero = await page.getByTestId('flickr-photosetsGetListWithPhotos-wrapper-0').getByRole('img') 49 | const imagesOne = await page.getByTestId('flickr-photosetsGetListWithPhotos-wrapper-1').getByRole('img') 50 | 51 | await expect(imagesZero).toHaveCount(5) 52 | await expect(imagesOne).toHaveCount(6) 53 | 54 | for (const image of await imagesZero.all()) { 55 | await expect(image).toBeVisible() 56 | } 57 | 58 | for (const image of await imagesOne.all()) { 59 | await expect(image).toBeVisible() 60 | } 61 | }) 62 | -------------------------------------------------------------------------------- /e2e/plausible.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/') 5 | }) 6 | 7 | test('plausible', async ({ page }) => { 8 | const queryResult = await page.getByTestId('plausible-item') 9 | 10 | await expect(queryResult).toContainText('results') 11 | await expect(queryResult).toContainText('metrics') 12 | await expect(queryResult).toContainText('dimensions') 13 | await expect(queryResult).toContainText('744') 14 | await expect(queryResult).toContainText('meta') 15 | await expect(queryResult).not.toContainText('query') 16 | }) 17 | -------------------------------------------------------------------------------- /e2e/smoke.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/') 5 | }) 6 | 7 | test('has ', async ({ page }) => { 8 | await expect(page).toHaveTitle(/Astro Loaders by LekoArts/) 9 | }) 10 | 11 | test('has <h1>', async ({ page }) => { 12 | const h1 = await page.locator('h1') 13 | await expect(h1).toHaveText('Astro Loaders by LekoArts') 14 | }) 15 | 16 | test('has link to "Star on GitHub"', async ({ page }) => { 17 | const link = await page.locator('a:has-text("Star on GitHub")') 18 | await expect(link).toHaveAttribute('href', 'https://github.com/LekoArts/astro-loaders') 19 | }) 20 | 21 | test('has install links', async ({ page }) => { 22 | const loaders = [ 23 | ['@lekoarts/flickr-loader', 'flickr'], 24 | ] 25 | 26 | for (const details of loaders) { 27 | const link = await page.locator(`a:has-text("Install ${details[0]}")`) 28 | await expect(link).toHaveAttribute('href', `https://github.com/LekoArts/astro-loaders/tree/main/packages/${details[1]}`) 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "moduleDetection": "force", 5 | "module": "preserve", 6 | "resolveJsonModule": true, 7 | "allowJs": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noUncheckedIndexedAccess": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "isolatedModules": true, 14 | "verbatimModuleSyntax": true, 15 | "skipLibCheck": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | formatters: true, 5 | typescript: true, 6 | }) 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-loaders", 3 | "packageManager": "pnpm@10.11.0", 4 | "author": "LekoArts", 5 | "license": "MIT", 6 | "scripts": { 7 | "bootstrap": "pnpm install && pnpm playwright:init && pnpm build", 8 | "test": "pnpm run --filter @lekoarts/* test", 9 | "dev": "pnpm run --filter @lekoarts/* dev", 10 | "build": "pnpm run --filter @lekoarts/* build", 11 | "check": "pnpm run --filter @lekoarts/* check", 12 | "lint": "eslint .", 13 | "lint:fix": "eslint . --fix", 14 | "changeset": "changeset", 15 | "version": "changeset version", 16 | "version:ci": "changeset version && pnpm install --lockfile-only", 17 | "release": "changeset publish", 18 | "website:dev": "pnpm run --filter website dev", 19 | "website:build": "pnpm run --filter website build", 20 | "website:preview": "pnpm run --filter website preview", 21 | "playwright:init": "playwright install chromium", 22 | "e2e:dev": "playwright test --ui", 23 | "e2e:build": "IS_BUILD=true playwright test" 24 | }, 25 | "devDependencies": { 26 | "@antfu/eslint-config": "^4.13.2", 27 | "@changesets/changelog-github": "^0.5.1", 28 | "@changesets/cli": "^2.29.4", 29 | "@playwright/test": "^1.52.0", 30 | "@types/node": "^22.15.21", 31 | "eslint": "^9.27.0", 32 | "eslint-plugin-format": "^1.0.1", 33 | "lint-staged": "^16.0.0", 34 | "simple-git-hooks": "^2.13.0", 35 | "vite": "^6.3.5", 36 | "vitest": "^3.1.4" 37 | }, 38 | "simple-git-hooks": { 39 | "pre-commit": "pnpm lint-staged" 40 | }, 41 | "lint-staged": { 42 | "*": "eslint --fix" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/clerk/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @lekoarts/clerk-loader 2 | 3 | ## 1.0.3 4 | 5 | ### Patch Changes 6 | 7 | - [#76](https://github.com/LekoArts/astro-loaders/pull/76) [`1b765ca`](https://github.com/LekoArts/astro-loaders/commit/1b765cae75164526df93c110f6245f6957faf9f1) Thanks [@LekoArts](https://github.com/LekoArts)! - Internal change: Switch from tsup to tsdown. No behavior change should occur. 8 | 9 | ## 1.0.2 10 | 11 | ### Patch Changes 12 | 13 | - [#59](https://github.com/LekoArts/astro-loaders/pull/59) [`988770c`](https://github.com/LekoArts/astro-loaders/commit/988770c04540e6c059693132937931335ee071ef) Thanks [@LekoArts](https://github.com/LekoArts)! - Validate incoming Clerk data by using parseData. No behavior change for end users. 14 | 15 | ## 1.0.1 16 | 17 | ### Patch Changes 18 | 19 | - [`9e3eed1`](https://github.com/LekoArts/astro-loaders/commit/9e3eed1acf0c5ed76133f678589c019d34d1e213) Thanks [@LekoArts](https://github.com/LekoArts)! - Improve README by adding other package managers to install instructions 20 | -------------------------------------------------------------------------------- /packages/clerk/README.md: -------------------------------------------------------------------------------- 1 | # Astro Clerk loader 2 | 3 | This package provides a [Clerk](https://clerk.com) content loader for Astro's [content layer](https://docs.astro.build/en/guides/content-collections/). You can access a selection of Clerk Backend APIs by simply providing the method name. 4 | 5 | **Want to see an overview of all my loaders? Visit [astro-loaders.lekoarts.de](https://astro-loaders.lekoarts.de) ✨** 6 | 7 | <!-- automd:badges license --> 8 | 9 | [![npm version](https://img.shields.io/npm/v/@lekoarts/clerk-loader)](https://npmjs.com/package/@lekoarts/clerk-loader) 10 | [![npm downloads](https://img.shields.io/npm/dm/@lekoarts/clerk-loader)](https://npm.chart.dev/@lekoarts/clerk-loader) 11 | [![license](https://img.shields.io/github/license/LekoArts/astro-loaders)](https://github.com/LekoArts/astro-loaders/blob/main/LICENSE) 12 | 13 | <!-- /automd --> 14 | 15 | ## Prerequisites 16 | 17 | - Astro 5 or later installed 18 | - An existing Clerk application. [Setup your Clerk account](https://clerk.com/docs/quickstarts/setup-clerk). 19 | 20 | ## Installation 21 | 22 | <!-- automd:pm-install separate auto=false --> 23 | 24 | ```sh 25 | # npm 26 | npm install @lekoarts/clerk-loader 27 | ``` 28 | 29 | ```sh 30 | # yarn 31 | yarn add @lekoarts/clerk-loader 32 | ``` 33 | 34 | ```sh 35 | # pnpm 36 | pnpm install @lekoarts/clerk-loader 37 | ``` 38 | 39 | <!-- /automd --> 40 | 41 | ## Usage 42 | 43 | Import `@lekoarts/clerk-loader` into `src/content.config.ts` and define your collections. 44 | 45 | **Important:** You need to either define the Clerk Secret Key as an environment variable (`CLERK_SECRET_KEY`) or pass it as the `secretKey` option inside `clientOptions`. 46 | 47 | ```ts 48 | import { clerkLoader } from '@lekoarts/clerk-loader' 49 | 50 | const clerk = defineCollection({ 51 | loader: clerkLoader({ 52 | method: { 53 | name: 'users.getUserList', 54 | }, 55 | }), 56 | }) 57 | ``` 58 | 59 | ## Options 60 | 61 | ### `method` (required) 62 | 63 | You **must** pass `method.name` to the loader. The available method names are generated through the [`PublicLoaderAPI`](https://github.com/LekoArts/astro-loaders/blob/main/packages/clerk/src/types.ts) and stored in dot notation (`api.method`). 64 | If the endpoint takes parameters, you can define them through `method.options`. 65 | 66 | Use your IDE's autocompletion to discover all available options and method names. 67 | 68 | ```ts 69 | import { clerkLoader } from '@lekoarts/clerk-loader' 70 | 71 | const clerk = defineCollection({ 72 | loader: clerkLoader({ 73 | method: { 74 | name: 'users.getUserList', 75 | options: { 76 | last_active_at_since: 1234567890, 77 | } 78 | }, 79 | }), 80 | }) 81 | ``` 82 | 83 | ### `clientOptions` (optional) 84 | 85 | You can configure the underlying [`createClerkClient()`](https://clerk.com/docs/references/backend/overview#create-clerk-client-options) call by passing in these options. 86 | -------------------------------------------------------------------------------- /packages/clerk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lekoarts/clerk-loader", 3 | "type": "module", 4 | "version": "1.0.3", 5 | "description": "Astro content loader for Clerk", 6 | "author": "LekoArts", 7 | "license": "MIT", 8 | "homepage": "https://github.com/LekoArts/astro-loaders", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com:LekoArts/astro-loaders.git", 12 | "directory": "packages/clerk" 13 | }, 14 | "keywords": [ 15 | "withastro", 16 | "astro-loader", 17 | "clerk" 18 | ], 19 | "exports": { 20 | ".": "./dist/index.js" 21 | }, 22 | "main": "dist/index.js", 23 | "types": "dist/index.d.ts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "engines": { 28 | "node": "^18.17.1 || ^20.3.0 || >=22.0.0" 29 | }, 30 | "scripts": { 31 | "build": "tsdown", 32 | "dev": "tsdown --watch", 33 | "prepublishOnly": "node --run build", 34 | "check": "publint && attw --pack . --profile esm-only", 35 | "test": "vitest" 36 | }, 37 | "peerDependencies": { 38 | "astro": "^5.0.0" 39 | }, 40 | "dependencies": { 41 | "@clerk/backend": "^1.33.0", 42 | "type-fest": "^4.41.0" 43 | }, 44 | "devDependencies": { 45 | "@arethetypeswrong/cli": "catalog:linting", 46 | "astro": "catalog:astro", 47 | "publint": "catalog:linting", 48 | "tsdown": "catalog:repo", 49 | "typescript": "catalog:repo" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/clerk/src/__tests__/types.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { isNumber, isObjectLike, isPaginatedLike } from '../types.js' 3 | 4 | describe('type guards', () => { 5 | describe('isNumber', () => { 6 | it('returns true for numbers', () => { 7 | expect(isNumber(42)).toBe(true) 8 | expect(isNumber(0)).toBe(true) 9 | expect(isNumber(-1)).toBe(true) 10 | }) 11 | 12 | it('returns false for non-numbers', () => { 13 | expect(isNumber('42')).toBe(false) 14 | expect(isNumber(null)).toBe(false) 15 | expect(isNumber(undefined)).toBe(false) 16 | expect(isNumber({})).toBe(false) 17 | expect(isNumber([])).toBe(false) 18 | }) 19 | }) 20 | 21 | describe('isPaginatedLike', () => { 22 | it('returns true for paginated objects', () => { 23 | const paginated = { 24 | data: [{ id: '1', createdAt: 123, updatedAt: 456, _raw: { id: '1', created_at: 123, updated_at: 456 } }], 25 | totalCount: 1, 26 | } 27 | expect(isPaginatedLike(paginated)).toBe(true) 28 | }) 29 | 30 | it('returns false for non-paginated objects', () => { 31 | expect(isPaginatedLike(null)).toBe(false) 32 | expect(isPaginatedLike({})).toBe(false) 33 | expect(isPaginatedLike({ data: [] })).toBe(false) 34 | expect(isPaginatedLike({ totalCount: 0 })).toBe(false) 35 | expect(isPaginatedLike([])).toBe(false) 36 | }) 37 | }) 38 | 39 | describe('isObjectLike', () => { 40 | it('returns true for object-like values', () => { 41 | const obj = { id: '1', _raw: { id: '1' } } 42 | expect(isObjectLike(obj)).toBe(true) 43 | expect(isObjectLike({})).toBe(true) 44 | }) 45 | 46 | it('returns false for non-object-like values', () => { 47 | expect(isObjectLike(null)).toBe(false) 48 | expect(isObjectLike(undefined)).toBe(false) 49 | expect(isObjectLike([])).toBe(false) 50 | expect(isObjectLike('string')).toBe(false) 51 | expect(isObjectLike(42)).toBe(false) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /packages/clerk/src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import type { PaginatedLike } from '../types.js' 2 | import { describe, expect, it, vi } from 'vitest' 3 | import { paginate } from '../utils.js' 4 | 5 | describe('paginate', () => { 6 | it('should handle single page of results', async () => { 7 | const mockData: PaginatedLike = { 8 | // @ts-expect-error - Testing purposes 9 | data: [1, 2, 3], 10 | totalCount: 3, 11 | } 12 | const mockFn = vi.fn().mockResolvedValue(mockData) 13 | 14 | const results = await paginate(mockFn) 15 | 16 | expect(mockFn).toHaveBeenCalledTimes(1) 17 | expect(results).toEqual([mockData]) 18 | }) 19 | 20 | it('should handle multiple pages of results', async () => { 21 | const page1: PaginatedLike = { 22 | // @ts-expect-error - Testing purposes 23 | data: [1, 2], 24 | totalCount: 4, 25 | } 26 | const page2: PaginatedLike = { 27 | // @ts-expect-error - Testing purposes 28 | data: [3, 4], 29 | totalCount: 4, 30 | } 31 | 32 | const mockFn = vi.fn() 33 | .mockResolvedValueOnce(page1) 34 | .mockResolvedValueOnce(page2) 35 | 36 | const results = await paginate(mockFn, 2) 37 | 38 | expect(mockFn).toHaveBeenCalledTimes(2) 39 | expect(mockFn).toHaveBeenNthCalledWith(1, 2, 0) 40 | expect(mockFn).toHaveBeenNthCalledWith(2, 2, 2) 41 | expect(results).toEqual([page1, page2]) 42 | }) 43 | 44 | it('should respect custom limit and offset', async () => { 45 | const mockData: PaginatedLike = { 46 | // @ts-expect-error - Testing purposes 47 | data: [1], 48 | totalCount: 1, 49 | } 50 | const mockFn = vi.fn().mockResolvedValue(mockData) 51 | 52 | await paginate(mockFn, 10, 20) 53 | 54 | expect(mockFn).toHaveBeenCalledWith(10, 20) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /packages/clerk/src/clerk-loader.ts: -------------------------------------------------------------------------------- 1 | import type { Loader } from 'astro/loaders' 2 | import type { ClerkLoaderOptions, GetNamespaceAndMethod, PaginatedLike, PathsAutocomplete, SimplifiedReturnType } from './types.js' 3 | import { createClerkClient } from '@clerk/backend' 4 | import { AstroError } from 'astro/errors' 5 | import { DEFAULT_LIMIT, DEFAULT_OFFSET } from './constants.js' 6 | import { clerkApiReponseToZodSchema } from './schema.js' 7 | import { isNumber, isObjectLike, isPaginatedLike } from './types.js' 8 | import { paginate } from './utils.js' 9 | 10 | export function clerkLoader<MethodName extends PathsAutocomplete>( 11 | options: ClerkLoaderOptions<MethodName>, 12 | ): Loader { 13 | let secretKey: string 14 | 15 | if (options?.clientOptions?.secretKey) { 16 | secretKey = options.clientOptions.secretKey 17 | } 18 | else { 19 | const { CLERK_SECRET_KEY } = import.meta.env 20 | 21 | if (!CLERK_SECRET_KEY) { 22 | throw new AstroError('Missing Clerk Secret Key. Define the CLERK_SECRET_KEY environment variable or pass it as an option to clientOptions.') 23 | } 24 | 25 | secretKey = CLERK_SECRET_KEY 26 | } 27 | 28 | const clerk = createClerkClient({ ...(options?.clientOptions ? options.clientOptions : {}), secretKey }) 29 | 30 | return { 31 | name: 'clerk', 32 | load: async ({ logger, store, generateDigest, parseData }) => { 33 | logger.info(`Calling ${options.method.name}`) 34 | 35 | const [namespace, method] = options.method.name.split('.') as GetNamespaceAndMethod<MethodName> 36 | const methodOptions = options.method?.options ?? {} 37 | 38 | function getResponse(limit: number, offset: number): Promise<PaginatedLike> { 39 | // @ts-expect-error - FIXME 40 | return clerk[namespace][method]({ ...methodOptions, limit, offset }) 41 | } 42 | 43 | let result: SimplifiedReturnType 44 | 45 | try { 46 | // @ts-expect-error - FIXME 47 | result = await clerk[namespace][method](methodOptions) 48 | } 49 | catch (e) { 50 | logger.error(`Failed to call ${options.method.name}. Original error:`) 51 | throw e 52 | } 53 | 54 | if (isNumber(result)) { 55 | const data = { count: result } 56 | const digest = generateDigest(data) 57 | const id = generateDigest({ name: options.method.name, count: result }) 58 | 59 | store.set({ 60 | id, 61 | data, 62 | digest, 63 | }) 64 | } 65 | else if (isPaginatedLike(result)) { 66 | const limit = (methodOptions as { limit?: number })?.limit ?? DEFAULT_LIMIT 67 | const offset = (methodOptions as { offset?: number })?.offset ?? DEFAULT_OFFSET 68 | 69 | let paginatedResult: PaginatedLike[] 70 | 71 | /** 72 | * At this point the first API call has already been made, so result is already populated with the first entries. Now we need to paginate through the rest of the entries (if any). 73 | */ 74 | try { 75 | paginatedResult = await paginate( 76 | getResponse, 77 | limit, 78 | offset + limit, 79 | [result], 80 | ) 81 | } 82 | catch (e) { 83 | logger.error(`Failed to call ${options.method.name}. Original error:`) 84 | throw e 85 | } 86 | 87 | const flattenedResult = paginatedResult.flatMap(r => r.data) 88 | 89 | for (const result of flattenedResult) { 90 | if (!result.id) { 91 | continue 92 | } 93 | 94 | const id = result.id 95 | const data = await parseData({ id, data: result._raw }) 96 | const digest = generateDigest(String(data.updated_at)) 97 | 98 | store.set({ 99 | id, 100 | data, 101 | digest, 102 | }) 103 | } 104 | 105 | logger.info(`Loaded ${flattenedResult.length} entries from ${options.method.name}`) 106 | } 107 | else if (isObjectLike(result)) { 108 | const id = result.id 109 | const data = await parseData({ id, data: result._raw }) 110 | const digest = generateDigest(data) 111 | 112 | store.set({ 113 | id, 114 | data, 115 | digest, 116 | }) 117 | } 118 | else { 119 | throw new AstroError(`Unexpected return type from ${options.method.name}`) 120 | } 121 | }, 122 | schema: () => clerkApiReponseToZodSchema(options.method.name), 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /packages/clerk/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_LIMIT = 100 2 | export const DEFAULT_OFFSET = 0 3 | -------------------------------------------------------------------------------- /packages/clerk/src/index.ts: -------------------------------------------------------------------------------- 1 | export { clerkLoader } from './clerk-loader.js' 2 | export type { ClerkLoaderOptions } from './types.js' 3 | -------------------------------------------------------------------------------- /packages/clerk/src/schema.ts: -------------------------------------------------------------------------------- 1 | import type { ZodTypeAny } from 'astro/zod' 2 | import type { PathsAutocomplete } from './types.js' 3 | import { z } from 'astro/zod' 4 | import { get_GetEmailAddress, get_GetOrganization, get_GetPhoneNumber, get_GetUser, get_GetUserList, get_ListInvitations, Organization, OrganizationInvitation, OrganizationMembership, schemas_SAMLConnection } from './openapi.js' 5 | 6 | const METHOD_TO_SCHEMA_MAP: Record<PathsAutocomplete, ZodTypeAny> = { 7 | 'users.getUserList': get_GetUserList.response.element, 8 | 'users.getUser': get_GetUser.response, 9 | 'users.getCount': z.object({ count: z.number() }), 10 | 'users.getOrganizationMembershipList': OrganizationMembership, 11 | 'organizations.getOrganization': get_GetOrganization.response, 12 | 'organizations.getOrganizationList': Organization, 13 | 'organizations.getOrganizationMembershipList': OrganizationMembership, 14 | 'organizations.getOrganizationInvitationList': OrganizationInvitation, 15 | 'invitations.getInvitationList': get_ListInvitations.response.element, 16 | 'emailAddresses.getEmailAddress': get_GetEmailAddress.response, 17 | 'phoneNumbers.getPhoneNumber': get_GetPhoneNumber.response, 18 | 'samlConnections.getSamlConnectionList': schemas_SAMLConnection, 19 | 'samlConnections.getSamlConnection': schemas_SAMLConnection, 20 | } as const 21 | 22 | export function clerkApiReponseToZodSchema(methodName: PathsAutocomplete): ZodTypeAny { 23 | return METHOD_TO_SCHEMA_MAP[methodName] 24 | } 25 | -------------------------------------------------------------------------------- /packages/clerk/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ClerkClient, ClerkOptions } from '@clerk/backend' 2 | import type { Get, Paths } from 'type-fest' 3 | 4 | /** 5 | * It doesn't make sense to allow all available methods of Clerk's backend API to be called since only a subset of them are useful in the context of a static site. Only `GET` methods should be allowed. 6 | * 7 | * This interface defines the methods that are allowed to be called by the Clerk loader. 8 | */ 9 | export interface PublicLoaderAPI { 10 | users: { 11 | getUserList: ClerkClient['users']['getUserList'] 12 | getUser: ClerkClient['users']['getUser'] 13 | getCount: ClerkClient['users']['getCount'] 14 | getOrganizationMembershipList: ClerkClient['users']['getOrganizationMembershipList'] 15 | } 16 | organizations: { 17 | getOrganization: ClerkClient['organizations']['getOrganization'] 18 | getOrganizationList: ClerkClient['organizations']['getOrganizationList'] 19 | getOrganizationMembershipList: ClerkClient['organizations']['getOrganizationMembershipList'] 20 | getOrganizationInvitationList: ClerkClient['organizations']['getOrganizationInvitationList'] 21 | } 22 | invitations: { 23 | getInvitationList: ClerkClient['invitations']['getInvitationList'] 24 | } 25 | emailAddresses: { 26 | getEmailAddress: ClerkClient['emailAddresses']['getEmailAddress'] 27 | } 28 | phoneNumbers: { 29 | getPhoneNumber: ClerkClient['phoneNumbers']['getPhoneNumber'] 30 | } 31 | samlConnections: { 32 | getSamlConnectionList: ClerkClient['samlConnections']['getSamlConnectionList'] 33 | getSamlConnection: ClerkClient['samlConnections']['getSamlConnection'] 34 | } 35 | } 36 | 37 | /** 38 | * Extract the function parameters from the method name inside PublicLoaderAPI. 39 | * @example 40 | * type Params = GetNestedMethodParams<'users.getUser'> 41 | */ 42 | export type GetNestedMethodParams<Path extends PathsAutocomplete> = Parameters<Get<PublicLoaderAPI, Path>>[0] 43 | 44 | /** 45 | * Extract the return type from the method name inside PublicLoaderAPI. 46 | * @example 47 | * type ReturnType = GetNestedReturnType<'users.getUser'> 48 | */ 49 | export type GetNestedReturnType<Path extends PathsAutocomplete> = Awaited<ReturnType<Get<PublicLoaderAPI, Path>>> 50 | 51 | /** 52 | * Extract the namespace and method name from the method name. 53 | * @example 54 | * type NamespaceAndMethod = GetNamespaceAndMethod<'users.getUser'> 55 | * // ['users', 'getUser'] 56 | */ 57 | export type GetNamespaceAndMethod<MethodName extends PathsAutocomplete> = MethodName extends `${infer Namespace}.${infer Method}` 58 | ? [Namespace, Method] 59 | : never 60 | 61 | /** 62 | * Union of all available method names as dot notation path 63 | */ 64 | export type PathsAutocomplete = Paths<PublicLoaderAPI, { leavesOnly: true }> 65 | 66 | export interface PaginatedLike { 67 | data: Array<{ 68 | id: string 69 | createdAt: number 70 | updatedAt: number 71 | _raw: { 72 | id: string 73 | created_at: number 74 | updated_at: number 75 | [key: string]: unknown 76 | } 77 | [key: string]: unknown 78 | }> 79 | totalCount: number 80 | } 81 | 82 | export interface ObjectLike { 83 | id: string 84 | _raw: { 85 | id: string 86 | [key: string]: unknown 87 | } 88 | [key: string]: unknown 89 | } 90 | 91 | export type SimplifiedReturnType = number | ObjectLike | PaginatedLike 92 | 93 | export function isNumber(value: unknown): value is number { 94 | return typeof value === 'number' 95 | } 96 | 97 | export function isPaginatedLike(value: unknown): value is PaginatedLike { 98 | return typeof value === 'object' && value !== null && 'data' in value && 'totalCount' in value 99 | } 100 | 101 | export function isObjectLike(value: unknown): value is ObjectLike { 102 | return typeof value === 'object' && value !== null && !Array.isArray(value) 103 | } 104 | 105 | export interface ClerkLoaderOptions<MethodName extends PathsAutocomplete> { 106 | clientOptions?: ClerkOptions 107 | method: { 108 | name: MethodName 109 | options?: GetNestedMethodParams<MethodName> 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/clerk/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { PaginatedLike } from './types.js' 2 | import { DEFAULT_LIMIT, DEFAULT_OFFSET } from './constants.js' 3 | 4 | /** 5 | * If the Clerk API returns a paginated response, it will always have a `data` array and a `totalCount` number. If the data array length is smaller than the total count, it means there are more pages to fetch. 6 | * 7 | * You can pass a `limit` and `offset` option to the API calls. 8 | * If the sum of offset and limit is bigger than the total count, the recursion will stop. 9 | */ 10 | export function paginate<T extends PaginatedLike>( 11 | fn: (limit: number, offset: number) => Promise<T>, 12 | limit: number = DEFAULT_LIMIT, 13 | offset: number = DEFAULT_OFFSET, 14 | results: T[] = [], 15 | ): Promise<T[]> { 16 | return fn(limit, offset).then((data) => { 17 | results.push(data) 18 | 19 | if (offset + limit < data.totalCount) { 20 | return paginate(fn, limit, offset + limit, results) 21 | } 22 | 23 | return results 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /packages/clerk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "vite/client" 6 | ] 7 | }, 8 | "include": [ 9 | "src" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /packages/clerk/tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | }) 6 | -------------------------------------------------------------------------------- /packages/flickr/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @lekoarts/flickr-loader 2 | 3 | ## 1.2.5 4 | 5 | ### Patch Changes 6 | 7 | - [#76](https://github.com/LekoArts/astro-loaders/pull/76) [`1b765ca`](https://github.com/LekoArts/astro-loaders/commit/1b765cae75164526df93c110f6245f6957faf9f1) Thanks [@LekoArts](https://github.com/LekoArts)! - Internal change: Switch from tsup to tsdown. No behavior change should occur. 8 | 9 | ## 1.2.4 10 | 11 | ### Patch Changes 12 | 13 | - [#70](https://github.com/LekoArts/astro-loaders/pull/70) [`f59b4ec`](https://github.com/LekoArts/astro-loaders/commit/f59b4ecb4ed91e3b6f56944372c1864d93aff1cf) Thanks [@LekoArts](https://github.com/LekoArts)! - Add all available image sizes to `extras` query parameter (`url_l` etc.). Previously only a subset was added which caused not all available sizes to be queryable. 14 | 15 | ## 1.2.3 16 | 17 | ### Patch Changes 18 | 19 | - [`9e3eed1`](https://github.com/LekoArts/astro-loaders/commit/9e3eed1acf0c5ed76133f678589c019d34d1e213) Thanks [@LekoArts](https://github.com/LekoArts)! - Improve README by adding other package managers to install instructions 20 | 21 | ## 1.2.2 22 | 23 | ### Patch Changes 24 | 25 | - [#15](https://github.com/LekoArts/astro-loaders/pull/15) [`003756a`](https://github.com/LekoArts/astro-loaders/commit/003756ac7f107d9d8eb04a6cb101531ee2bc7f37) Thanks [@LekoArts](https://github.com/LekoArts)! - Adjust TypeScript types to more accurately represent the `queryParams` you can pass as an option to the loaders. 26 | 27 | Internally the `flickr-sdk` has been replaced with custom fetch calls making the package leaner. 28 | 29 | ## 1.2.1 30 | 31 | ### Patch Changes 32 | 33 | - [#11](https://github.com/LekoArts/astro-loaders/pull/11) [`b6bee0b`](https://github.com/LekoArts/astro-loaders/commit/b6bee0b09647388ceaeac04e8237af29f962c40d) Thanks [@LekoArts](https://github.com/LekoArts)! - Internal change: Use generateDigest 34 | 35 | ## 1.2.0 36 | 37 | ### Minor Changes 38 | 39 | - [#7](https://github.com/LekoArts/astro-loaders/pull/7) [`c05cafa`](https://github.com/LekoArts/astro-loaders/commit/c05cafa9b2be79c7696398cd28b8425f6691757a) Thanks [@LekoArts](https://github.com/LekoArts)! - Add `flickrPhotosetsGetPhotosLoader()` and `flickrPhotosetsGetListWithPhotosLoader()` loaders. 40 | 41 | #### `flickrPhotosetsGetPhotosLoader` 42 | 43 | Get the list of photos in a photoset. 44 | 45 | Flickr API: [`flickr.photosets.getPhotos`](https://www.flickr.com/services/api/flickr.photosets.getPhotos.html) 46 | 47 | ##### Required options 48 | 49 | - `username` (string) 50 | - `photoset_id` (string) 51 | 52 | ##### Usage 53 | 54 | ```ts 55 | import { flickrPhotosetsGetPhotosLoader } from "@lekoarts/flickr-loader"; 56 | 57 | const photosetsGetPhotos = defineCollection({ 58 | loader: flickrPhotosetsGetPhotosLoader({ 59 | username: "flickr-username", 60 | photoset_id: "72177720313250218", 61 | }), 62 | }); 63 | ``` 64 | 65 | #### `flickrPhotosetsGetListWithPhotosLoader` 66 | 67 | This loader combines the `flickrPhotosetsGetListLoader()` and `flickrPhotosetsGetPhotosLoader()` loaders to get the most out of photosets. You'll get back the photosets and their list of photos. 68 | 69 | Flickr API: [`flickr.photosets.getList`](https://www.flickr.com/services/api/flickr.photosets.getList.html) + [`flickr.photosets.getPhotos`](https://www.flickr.com/services/api/flickr.photosets.getPhotos.html) 70 | 71 | ##### Required options 72 | 73 | - `username` (string) 74 | 75 | ##### Optional options 76 | 77 | - `in` (string[]) - Array of photoset IDs to match against 78 | - `nin` (string[]) - Array of photoset IDs to exclude 79 | 80 | ##### Usage 81 | 82 | Fetching all photosets a user has. 83 | 84 | ```ts 85 | import { flickrPhotosetsGetListWithPhotosLoader } from "@lekoarts/flickr-loader"; 86 | 87 | const photosetsGetListWithPhotos = defineCollection({ 88 | loader: flickrPhotosetsGetListWithPhotosLoader({ 89 | username: "flickr-username", 90 | }), 91 | }); 92 | ``` 93 | 94 | Only fetching the photosets `123` and `456`. 95 | 96 | ```ts 97 | import { flickrPhotosetsGetListWithPhotosLoader } from "@lekoarts/flickr-loader"; 98 | 99 | const photosetsGetListWithPhotos = defineCollection({ 100 | loader: flickrPhotosetsGetListWithPhotosLoader({ 101 | username: "flickr-username", 102 | in: ["123", "456"], 103 | }), 104 | }); 105 | ``` 106 | 107 | Excluding the photosets `789` and `001`. 108 | 109 | ```ts 110 | import { flickrPhotosetsGetListWithPhotosLoader } from "@lekoarts/flickr-loader"; 111 | 112 | const photosetsGetListWithPhotos = defineCollection({ 113 | loader: flickrPhotosetsGetListWithPhotosLoader({ 114 | username: "flickr-username", 115 | nin: ["789", "001"], 116 | }), 117 | }); 118 | ``` 119 | 120 | ## 1.1.0 121 | 122 | ### Minor Changes 123 | 124 | - [#5](https://github.com/LekoArts/astro-loaders/pull/5) [`75d239b`](https://github.com/LekoArts/astro-loaders/commit/75d239ba438b2e7dfb288d8d576925b1aa56d147) Thanks [@LekoArts](https://github.com/LekoArts)! - Add `flickrPhotosetsGetListLoader()` loader. It returns the photosets belonging to the specified user. Flickr API: [`flickr.photosets.getList`](https://www.flickr.com/services/api/flickr.photosets.getList.html). 125 | 126 | Required options: 127 | 128 | - `username` 129 | 130 | Usage: 131 | 132 | ```ts 133 | import { flickrPhotosetsGetListLoader } from "@lekoarts/flickr-loader"; 134 | 135 | const photosetsGetList = defineCollection({ 136 | loader: flickrPhotosetsGetListLoader({ 137 | username: "flickr-username", 138 | }), 139 | }); 140 | ``` 141 | 142 | ## 1.0.1 143 | 144 | ### Patch Changes 145 | 146 | - [#2](https://github.com/LekoArts/astro-loaders/pull/2) [`741d8ba`](https://github.com/LekoArts/astro-loaders/commit/741d8ba4bde0030b33de0b7b7aef1895da06c06f) Thanks [@LekoArts](https://github.com/LekoArts)! - Swap user_id with username 147 | -------------------------------------------------------------------------------- /packages/flickr/README.md: -------------------------------------------------------------------------------- 1 | # Astro Flickr loader 2 | 3 | This package provides multiple [Flickr](https://flickr.com/) content loaders for Astro's [content layer](https://docs.astro.build/en/guides/content-collections/). Most loaders correspond to a single Flickr API endpoint, however some loaders call multiple one for better results. The data returned from Flickr is normalized and cleaned up, so that each loader's response is similar and easy to work with. 4 | 5 | **Want to see an overview of all my loaders? Visit [astro-loaders.lekoarts.de](https://astro-loaders.lekoarts.de) ✨** 6 | 7 | <!-- automd:badges license --> 8 | 9 | [![npm version](https://img.shields.io/npm/v/@lekoarts/flickr-loader)](https://npmjs.com/package/@lekoarts/flickr-loader) 10 | [![npm downloads](https://img.shields.io/npm/dm/@lekoarts/flickr-loader)](https://npm.chart.dev/@lekoarts/flickr-loader) 11 | [![license](https://img.shields.io/github/license/LekoArts/astro-loaders)](https://github.com/LekoArts/astro-loaders/blob/main/LICENSE) 12 | 13 | <!-- /automd --> 14 | 15 | ## Prerequisites 16 | 17 | - Astro 5 or later installed 18 | - A Flickr API key 19 | - Create an account on Flickr, go to [App Garden](https://www.flickr.com/services/apps/create/) to register an app, and copy the `Key` 20 | 21 | ## Installation 22 | 23 | <!-- automd:pm-install separate auto=false --> 24 | 25 | ```sh 26 | # npm 27 | npm install @lekoarts/flickr-loader 28 | ``` 29 | 30 | ```sh 31 | # yarn 32 | yarn add @lekoarts/flickr-loader 33 | ``` 34 | 35 | ```sh 36 | # pnpm 37 | pnpm install @lekoarts/flickr-loader 38 | ``` 39 | 40 | <!-- /automd --> 41 | 42 | ## Usage 43 | 44 | Import `@lekoarts/flickr-loader` into `src/content.config.ts` and define your collections. You can import various loaders that correspond to their respective Flickr API endpoints. 45 | 46 | **Important:** You need to either define the Flickr API key as an environment variable (`FLICKR_API_KEY`) or pass it to every loader with the `api_key` argument. 47 | 48 | ### `flickrPeopleGetPhotosLoader` 49 | 50 | Return photos from the given user's photostream. Only photos visible to the calling user will be returned. 51 | 52 | Flickr API: [`flickr.people.getPhotos`](https://www.flickr.com/services/api/flickr.people.getPhotos.html) 53 | 54 | #### Required options 55 | 56 | - `username` (string) 57 | 58 | #### Usage 59 | 60 | ```ts 61 | import { flickrPeopleGetPhotosLoader } from '@lekoarts/flickr-loader' 62 | 63 | const peopleGetPhotos = defineCollection({ 64 | loader: flickrPeopleGetPhotosLoader({ 65 | username: 'flickr-username', 66 | }), 67 | }) 68 | ``` 69 | 70 | ### `flickrPhotosetsGetListLoader` 71 | 72 | Returns the photosets belonging to the specified user. 73 | 74 | Flickr API: [`flickr.photosets.getList`](https://www.flickr.com/services/api/flickr.photosets.getList.html) 75 | 76 | #### Required options 77 | 78 | - `username` (string) 79 | 80 | #### Usage 81 | 82 | ```ts 83 | import { flickrPhotosetsGetListLoader } from '@lekoarts/flickr-loader' 84 | 85 | const photosetsGetList = defineCollection({ 86 | loader: flickrPhotosetsGetListLoader({ 87 | username: 'flickr-username', 88 | }), 89 | }) 90 | ``` 91 | 92 | ### `flickrPhotosetsGetPhotosLoader` 93 | 94 | Get the list of photos in a photoset. 95 | 96 | Flickr API: [`flickr.photosets.getPhotos`](https://www.flickr.com/services/api/flickr.photosets.getPhotos.html) 97 | 98 | #### Required options 99 | 100 | - `username` (string) 101 | - `photoset_id` (string) 102 | 103 | #### Usage 104 | 105 | ```ts 106 | import { flickrPhotosetsGetPhotosLoader } from '@lekoarts/flickr-loader' 107 | 108 | const photosetsGetPhotos = defineCollection({ 109 | loader: flickrPhotosetsGetPhotosLoader({ 110 | username: 'flickr-username', 111 | photoset_id: '72177720313250218', 112 | }), 113 | }) 114 | ``` 115 | 116 | ### `flickrPhotosetsGetListWithPhotosLoader` 117 | 118 | This loader combines the `flickrPhotosetsGetListLoader()` and `flickrPhotosetsGetPhotosLoader()` loaders to get the most out of photosets. You'll get back the photosets and their list of photos. 119 | 120 | Flickr API: [`flickr.photosets.getList`](https://www.flickr.com/services/api/flickr.photosets.getList.html) + [`flickr.photosets.getPhotos`](https://www.flickr.com/services/api/flickr.photosets.getPhotos.html) 121 | 122 | #### Required options 123 | 124 | - `username` (string) 125 | 126 | #### Optional options 127 | 128 | - `in` (string[]) - Array of photoset IDs to match against 129 | - `nin` (string[]) - Array of photoset IDs to exclude 130 | 131 | #### Usage 132 | 133 | Fetching all photosets a user has. 134 | 135 | ```ts 136 | import { flickrPhotosetsGetListWithPhotosLoader } from '@lekoarts/flickr-loader' 137 | 138 | const photosetsGetListWithPhotos = defineCollection({ 139 | loader: flickrPhotosetsGetListWithPhotosLoader({ 140 | username: 'flickr-username', 141 | }), 142 | }) 143 | ``` 144 | 145 | Only fetching the photosets `123` and `456`. 146 | 147 | ```ts 148 | import { flickrPhotosetsGetListWithPhotosLoader } from '@lekoarts/flickr-loader' 149 | 150 | const photosetsGetListWithPhotos = defineCollection({ 151 | loader: flickrPhotosetsGetListWithPhotosLoader({ 152 | username: 'flickr-username', 153 | in: ['123', '456'], 154 | }), 155 | }) 156 | ``` 157 | 158 | Excluding the photosets `789` and `001`. 159 | 160 | ```ts 161 | import { flickrPhotosetsGetListWithPhotosLoader } from '@lekoarts/flickr-loader' 162 | 163 | const photosetsGetListWithPhotos = defineCollection({ 164 | loader: flickrPhotosetsGetListWithPhotosLoader({ 165 | username: 'flickr-username', 166 | nin: ['789', '001'], 167 | }), 168 | }) 169 | ``` 170 | -------------------------------------------------------------------------------- /packages/flickr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lekoarts/flickr-loader", 3 | "type": "module", 4 | "version": "1.2.5", 5 | "description": "Astro content loader for Flickr", 6 | "author": "LekoArts", 7 | "license": "MIT", 8 | "homepage": "https://github.com/LekoArts/astro-loaders", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com:LekoArts/astro-loaders.git", 12 | "directory": "packages/flickr" 13 | }, 14 | "keywords": [ 15 | "withastro", 16 | "astro-loader" 17 | ], 18 | "exports": { 19 | ".": "./dist/index.js" 20 | }, 21 | "main": "dist/index.js", 22 | "types": "dist/index.d.ts", 23 | "files": [ 24 | "dist" 25 | ], 26 | "engines": { 27 | "node": "^18.17.1 || ^20.3.0 || >=22.0.0" 28 | }, 29 | "scripts": { 30 | "build": "tsdown", 31 | "dev": "tsdown --watch", 32 | "prepublishOnly": "node --run build", 33 | "check": "publint && attw --pack . --profile esm-only", 34 | "test": "vitest" 35 | }, 36 | "peerDependencies": { 37 | "astro": "^5.0.0" 38 | }, 39 | "dependencies": { 40 | "ky": "^1.8.1" 41 | }, 42 | "devDependencies": { 43 | "@arethetypeswrong/cli": "catalog:linting", 44 | "astro": "catalog:astro", 45 | "publint": "catalog:linting", 46 | "tsdown": "catalog:repo", 47 | "typescript": "catalog:repo" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/flickr/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SIZES = { 2 | sq: 'sq_75px', 3 | q: 'sq_150px', 4 | t: '100px', 5 | s: '240px', 6 | n: '320px', 7 | m: '500px', 8 | z: '640px', 9 | c: '800px', 10 | l: '1024px', 11 | h: '1600px', 12 | k: '2048px', 13 | o: 'original', 14 | } as const 15 | 16 | const SIZES_AS_EXTRA_STRING = Object.keys(SIZES).map(k => `url_${k}`).join(',') 17 | 18 | export const DEFAULT_OPTIONS = { 19 | extras: `description,last_update,date_taken,media,views,original_format,${SIZES_AS_EXTRA_STRING}`, 20 | per_page: '300', 21 | } 22 | 23 | export const BASE_URL = 'https://api.flickr.com/services/rest/' 24 | -------------------------------------------------------------------------------- /packages/flickr/src/index.ts: -------------------------------------------------------------------------------- 1 | export { flickrPeopleGetPhotosLoader } from './loaders/people-getPhotos.js' 2 | export { flickrPhotosetsGetListWithPhotosLoader } from './loaders/photosets-getList-withPhotos.js' 3 | export { flickrPhotosetsGetListLoader } from './loaders/photosets-getList.js' 4 | export { flickrPhotosetsGetPhotosLoader } from './loaders/photosets-getPhotos.js' 5 | 6 | export type { FlickrPeopleGetPhotosLoaderOptions, FlickrPhotosetsGetListLoaderOptions, FlickrPhotosetsGetListWithPhotosLoaderOptions, FlickrPhotosetsGetPhotosLoaderOptions } from './types/loader.js' 7 | -------------------------------------------------------------------------------- /packages/flickr/src/loaders/people-getPhotos.ts: -------------------------------------------------------------------------------- 1 | import type { Loader } from 'astro/loaders' 2 | import type { FlickrPeopleGetPhotosLoaderOptions } from '../types/loader.js' 3 | import { DEFAULT_OPTIONS } from '../constants.js' 4 | import { PeopleGetPhotos } from '../schema.js' 5 | import { missingApiKey } from '../utils/errors.js' 6 | import { getUserIdFromUsername } from '../utils/get-user-id.js' 7 | import { createFlickr } from '../utils/ky.js' 8 | import { normalize } from '../utils/normalize.js' 9 | import { paginate } from '../utils/paginate.js' 10 | 11 | /** 12 | * Return photos from the given user's photostream. Only photos visible to the calling user will be returned. 13 | */ 14 | export function flickrPeopleGetPhotosLoader({ 15 | api_key = import.meta.env.FLICKR_API_KEY, 16 | username, 17 | queryParams, 18 | }: FlickrPeopleGetPhotosLoaderOptions): Loader { 19 | if (!api_key) { 20 | missingApiKey() 21 | } 22 | const { flickr } = createFlickr(api_key) 23 | 24 | return { 25 | name: 'flickr-people-get-photos', 26 | load: async ({ logger, parseData, store, generateDigest }) => { 27 | logger.info('Fetching photostream photos') 28 | let user_id: string 29 | 30 | try { 31 | user_id = await getUserIdFromUsername(username, flickr) 32 | } 33 | catch (e) { 34 | logger.error('Failed to get user ID from username. Original error:') 35 | throw e 36 | } 37 | 38 | function getPhotos(page: number) { 39 | return flickr('flickr.people.getPhotos', { 40 | user_id, 41 | per_page: DEFAULT_OPTIONS.per_page, 42 | page: page.toString(), 43 | extras: DEFAULT_OPTIONS.extras, 44 | ...queryParams, 45 | }) 46 | } 47 | 48 | const result = await paginate(getPhotos) 49 | const flattenedResult = result.flatMap(r => r.photos.photo) 50 | 51 | for (const result of flattenedResult) { 52 | if (!result.id) { 53 | continue 54 | } 55 | 56 | const normalized = normalize(result) 57 | const data = await parseData({ id: normalized.id, data: normalized }) 58 | const digest = generateDigest(data) 59 | 60 | store.set({ 61 | id: normalized.id, 62 | data, 63 | digest, 64 | }) 65 | } 66 | 67 | logger.info(`Loaded ${flattenedResult.length} photos`) 68 | }, 69 | schema: PeopleGetPhotos, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/flickr/src/loaders/photosets-getList-withPhotos.ts: -------------------------------------------------------------------------------- 1 | import type { Loader } from 'astro/loaders' 2 | import type { FlickrPhotosetsGetListWithPhotosLoaderOptions } from '../types/loader.js' 3 | import { DEFAULT_OPTIONS } from '../constants.js' 4 | import { PhotosetsGetListWithPhotos } from '../schema.js' 5 | import { missingApiKey } from '../utils/errors.js' 6 | import { getUserIdFromUsername } from '../utils/get-user-id.js' 7 | import { createFlickr } from '../utils/ky.js' 8 | import { normalize } from '../utils/normalize.js' 9 | import { paginate } from '../utils/paginate.js' 10 | 11 | /** 12 | * Returns the photosets belonging to the specified user. 13 | */ 14 | export function flickrPhotosetsGetListWithPhotosLoader({ 15 | api_key = import.meta.env.FLICKR_API_KEY, 16 | username, 17 | queryParams, 18 | in: _in, 19 | nin: _nin, 20 | }: FlickrPhotosetsGetListWithPhotosLoaderOptions): Loader { 21 | if (!api_key) { 22 | missingApiKey() 23 | } 24 | const { flickr } = createFlickr(api_key) 25 | 26 | return { 27 | name: 'flickr-photosets-get-list-with-photos', 28 | load: async ({ logger, parseData, store, generateDigest }) => { 29 | logger.info('Fetching photosets list') 30 | let user_id: string 31 | 32 | try { 33 | user_id = await getUserIdFromUsername(username, flickr) 34 | } 35 | catch (e) { 36 | logger.error('Failed to get user ID from username. Original error:') 37 | throw e 38 | } 39 | 40 | function getPhotosetsList(page: number) { 41 | return flickr('flickr.photosets.getList', { 42 | user_id, 43 | per_page: DEFAULT_OPTIONS.per_page, 44 | page: page.toString(), 45 | ...queryParams, 46 | }) 47 | } 48 | 49 | const result = await paginate(getPhotosetsList) 50 | const flattenedResult = result.flatMap(r => r.photosets.photoset) 51 | 52 | logger.info(`Loaded ${flattenedResult.length} photosets`) 53 | 54 | let photosetCounter = 0 55 | 56 | for (const result of flattenedResult) { 57 | // If no photoset ID is present there's no use in calling another API with that ID 58 | if (!result.id) { 59 | continue 60 | } 61 | 62 | // Handle the "in" and "nin" options a user may provide. With "in" they say: Only fetch photosets with these IDs. With "nin" they say: Fetch all photosets except these. 63 | if (_in && !_in.includes(result.id)) { 64 | continue 65 | } 66 | if (_nin && _nin.includes(result.id)) { 67 | continue 68 | } 69 | 70 | const normalizedPhotoset = normalize(result) 71 | 72 | function getPhotosetsPhotos(page: number) { 73 | return flickr('flickr.photosets.getPhotos', { 74 | user_id, 75 | photoset_id: normalizedPhotoset.id, 76 | per_page: DEFAULT_OPTIONS.per_page, 77 | page: page.toString(), 78 | extras: DEFAULT_OPTIONS.extras, 79 | ...queryParams, 80 | }) 81 | } 82 | 83 | // Fetch all images of the given photoset 84 | const photosResult = await paginate(getPhotosetsPhotos) 85 | const flattenedPhotosResult = photosResult.flatMap(r => r.photoset.photo) 86 | const photos = flattenedPhotosResult.map(normalize) 87 | 88 | const data = await parseData({ id: normalizedPhotoset.id, data: { 89 | ...normalizedPhotoset, 90 | photos, 91 | } }) 92 | const digest = generateDigest(data) 93 | 94 | store.set({ 95 | id: normalizedPhotoset.id, 96 | data, 97 | digest, 98 | }) 99 | 100 | photosetCounter++ 101 | } 102 | 103 | logger.info(`Processed ${photosetCounter} photosets`) 104 | }, 105 | schema: PhotosetsGetListWithPhotos, 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /packages/flickr/src/loaders/photosets-getList.ts: -------------------------------------------------------------------------------- 1 | import type { Loader } from 'astro/loaders' 2 | import type { FlickrPhotosetsGetListLoaderOptions } from '../types/loader.js' 3 | import { DEFAULT_OPTIONS } from '../constants.js' 4 | import { PhotosetsGetList } from '../schema.js' 5 | import { missingApiKey } from '../utils/errors.js' 6 | import { getUserIdFromUsername } from '../utils/get-user-id.js' 7 | import { createFlickr } from '../utils/ky.js' 8 | import { normalize } from '../utils/normalize.js' 9 | import { paginate } from '../utils/paginate.js' 10 | 11 | /** 12 | * Returns the photosets belonging to the specified user. 13 | */ 14 | export function flickrPhotosetsGetListLoader({ 15 | api_key = import.meta.env.FLICKR_API_KEY, 16 | username, 17 | queryParams, 18 | }: FlickrPhotosetsGetListLoaderOptions): Loader { 19 | if (!api_key) { 20 | missingApiKey() 21 | } 22 | const { flickr } = createFlickr(api_key) 23 | 24 | return { 25 | name: 'flickr-photosets-get-list', 26 | load: async ({ logger, parseData, store, generateDigest }) => { 27 | logger.info('Fetching photosets list') 28 | let user_id: string 29 | 30 | try { 31 | user_id = await getUserIdFromUsername(username, flickr) 32 | } 33 | catch (e) { 34 | logger.error('Failed to get user ID from username. Original error:') 35 | throw e 36 | } 37 | 38 | function getPhotosetsList(page: number) { 39 | return flickr('flickr.photosets.getList', { 40 | user_id, 41 | per_page: DEFAULT_OPTIONS.per_page, 42 | page: page.toString(), 43 | ...queryParams, 44 | }) 45 | } 46 | 47 | const result = await paginate(getPhotosetsList) 48 | const flattenedResult = result.flatMap(r => r.photosets.photoset) 49 | 50 | for (const result of flattenedResult) { 51 | if (!result.id) { 52 | continue 53 | } 54 | 55 | const normalized = normalize(result) 56 | const data = await parseData({ id: normalized.id, data: normalized }) 57 | const digest = generateDigest(data) 58 | 59 | store.set({ 60 | id: normalized.id, 61 | data, 62 | digest, 63 | }) 64 | } 65 | 66 | logger.info(`Loaded ${flattenedResult.length} photosets`) 67 | }, 68 | schema: PhotosetsGetList, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/flickr/src/loaders/photosets-getPhotos.ts: -------------------------------------------------------------------------------- 1 | import type { Loader } from 'astro/loaders' 2 | import type { z } from 'astro/zod' 3 | import type { FlickrPhotosetsGetPhotosLoaderOptions } from '../types/loader.js' 4 | import { DEFAULT_OPTIONS } from '../constants.js' 5 | import { PhotosetsGetPhotos } from '../schema.js' 6 | import { missingApiKey } from '../utils/errors.js' 7 | import { getUserIdFromUsername } from '../utils/get-user-id.js' 8 | import { createFlickr } from '../utils/ky.js' 9 | import { normalize } from '../utils/normalize.js' 10 | import { paginate } from '../utils/paginate.js' 11 | 12 | type Res = z.infer<typeof PhotosetsGetPhotos> 13 | 14 | /** 15 | * Get the list of photos in a set. 16 | */ 17 | export function flickrPhotosetsGetPhotosLoader({ 18 | api_key = import.meta.env.FLICKR_API_KEY, 19 | username, 20 | photoset_id, 21 | queryParams, 22 | }: FlickrPhotosetsGetPhotosLoaderOptions): Loader { 23 | if (!api_key) { 24 | missingApiKey() 25 | } 26 | const { flickr } = createFlickr(api_key) 27 | 28 | return { 29 | name: 'flickr-photosets-get-photos', 30 | load: async ({ logger, parseData, store, generateDigest }) => { 31 | logger.info(`Fetching photos from photoset ${photoset_id}`) 32 | let user_id: string 33 | 34 | try { 35 | user_id = await getUserIdFromUsername(username, flickr) 36 | } 37 | catch (e) { 38 | logger.error('Failed to get user ID from username. Original error:') 39 | throw e 40 | } 41 | 42 | function getPhotosetsPhotos(page: number) { 43 | return flickr('flickr.photosets.getPhotos', { 44 | user_id, 45 | photoset_id, 46 | per_page: DEFAULT_OPTIONS.per_page, 47 | page: page.toString(), 48 | extras: DEFAULT_OPTIONS.extras, 49 | ...queryParams, 50 | }) 51 | } 52 | 53 | const result = await paginate(getPhotosetsPhotos) 54 | const flattenedPhotos = result.flatMap(r => r.photoset.photo) 55 | 56 | const photoset = { 57 | id: result[0]!.photoset.id, 58 | primary: result[0]!.photoset.primary, 59 | owner: result[0]!.photoset.owner, 60 | ownername: result[0]!.photoset.ownername, 61 | title: result[0]!.photoset.title, 62 | total: result[0]!.photoset.total, 63 | } satisfies Res['photoset'] 64 | 65 | for (const result of flattenedPhotos) { 66 | if (!result.id) { 67 | continue 68 | } 69 | 70 | const normalized = normalize(result) 71 | const data = await parseData({ id: normalized.id, data: { ...normalized, photoset } }) 72 | const digest = generateDigest(data) 73 | 74 | store.set({ 75 | id: normalized.id, 76 | data, 77 | digest, 78 | }) 79 | } 80 | 81 | logger.info(`Loaded ${flattenedPhotos.length} photos from photoset ${photoset_id}`) 82 | }, 83 | schema: PhotosetsGetPhotos, 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/flickr/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'astro/zod' 2 | 3 | const PhotoMeta = z.object({ 4 | height: z.number(), 5 | width: z.number(), 6 | orientation: z.enum(['landscape', 'portrait', 'square']), 7 | url: z.string(), 8 | }) 9 | 10 | const imageUrls = z.object({ 11 | 'sq_75px': PhotoMeta.optional(), 12 | '100px': PhotoMeta.optional(), 13 | 'sq_150px': PhotoMeta.optional(), 14 | '240px': PhotoMeta.optional(), 15 | '320px': PhotoMeta.optional(), 16 | '500px': PhotoMeta.optional(), 17 | '640px': PhotoMeta.optional(), 18 | '800px': PhotoMeta.optional(), 19 | '1024px': PhotoMeta.optional(), 20 | '1600px': PhotoMeta.optional(), 21 | '2048px': PhotoMeta.optional(), 22 | 'original': PhotoMeta.optional(), 23 | }) 24 | 25 | const AlwaysAvailable = z.object({ 26 | id: z.string(), 27 | }) 28 | 29 | const NormalizedPhoto = AlwaysAvailable.extend({ 30 | title: z.string(), 31 | is_public: z.boolean(), 32 | is_friend: z.boolean(), 33 | is_family: z.boolean(), 34 | description: z.string().optional(), 35 | date_last_update: z.date().optional(), 36 | date_taken: z.date().optional(), 37 | views: z.number().optional(), 38 | media: z.string().optional(), 39 | media_status: z.string().optional(), 40 | imageUrls, 41 | }) 42 | 43 | export const PeopleGetPhotos = NormalizedPhoto.extend({ 44 | owner: z.string(), 45 | }) 46 | 47 | export const PhotosetsGetList = AlwaysAvailable.extend({ 48 | owner: z.string(), 49 | title: z.string(), 50 | description: z.string().optional(), 51 | username: z.string(), 52 | primary: z.string(), 53 | views: z.number().optional(), 54 | comments: z.number().optional(), 55 | photos: z.number().optional(), 56 | videos: z.number().optional(), 57 | date_create: z.date().optional(), 58 | date_last_update: z.date().optional(), 59 | }) 60 | 61 | export const PhotosetsGetPhotos = NormalizedPhoto.extend({ 62 | photoset: z.object({ 63 | id: z.string(), 64 | primary: z.string(), 65 | owner: z.string(), 66 | ownername: z.string(), 67 | title: z.string(), 68 | total: z.number(), 69 | }), 70 | }) 71 | 72 | export const PhotosetsGetListWithPhotos = PhotosetsGetList.extend({ 73 | photos: z.array(NormalizedPhoto), 74 | }) 75 | -------------------------------------------------------------------------------- /packages/flickr/src/types/flickr.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * --------------- 3 | * CONSTANTS 4 | * --------------- 5 | */ 6 | 7 | const SIZE_CODE = { 8 | Thumb: 'url_t', 9 | Square75: 'url_sq', 10 | Square150: 'url_q', 11 | Small240: 'url_s', 12 | Small320: 'url_n', 13 | Medium500: 'url_m', 14 | Medium640: 'url_z', 15 | Medium800: 'url_c', 16 | Large1024: 'url_l', 17 | Large1600: 'url_h', 18 | Large2048: 'url_k', 19 | Original: 'url_o', 20 | } as const 21 | 22 | interface Content { 23 | _content: string 24 | } 25 | 26 | /** 27 | * --------------- 28 | * INPUT 29 | * --------------- 30 | */ 31 | 32 | /** 33 | * Query parameters you can pass to the request. Check the Flickr API documentation for more details. 34 | */ 35 | export interface GetPhotosQueryParams { 36 | user_id?: string 37 | content_types?: string 38 | safe_search?: string 39 | min_upload_date?: string 40 | max_upload_date?: string 41 | min_taken_date?: string 42 | max_taken_date?: string 43 | privacy_filter?: string 44 | extras?: string 45 | per_page?: string 46 | page?: string 47 | } 48 | 49 | /** 50 | * Query parameters you can pass to the request. Check the Flickr API documentation for more details. 51 | */ 52 | export interface PhotosetsGetListParams { 53 | user_id?: string 54 | page?: string 55 | per_page?: string 56 | primary_photo_extras?: string 57 | photo_ids?: string 58 | sort_groups?: string 59 | } 60 | 61 | /** 62 | * Query parameters you can pass to the request. Check the Flickr API documentation for more details. 63 | */ 64 | export interface PhotosetsGetPhotosParams { 65 | user_id?: string 66 | photoset_id: string 67 | extras?: string 68 | per_page?: string 69 | page?: string 70 | privacy_filter?: string 71 | media?: string 72 | } 73 | 74 | /** 75 | * Query parameters you can pass to the request. Check the Flickr API documentation for more details. 76 | */ 77 | export interface PeopleFindByUsernameParams { 78 | username: string 79 | } 80 | 81 | /** 82 | * --------------- 83 | * OUTPUT 84 | * --------------- 85 | */ 86 | 87 | /** 88 | * A minimal photo object from Flickr's API. 89 | */ 90 | interface FlickrMinimalResponse { 91 | id: string 92 | owner: string 93 | secret: string 94 | server: string 95 | farm: number 96 | title: string | { _content: string } 97 | ispublic: number 98 | isfriend: number 99 | isfamily: number 100 | } 101 | 102 | interface SizeInfo { 103 | [SIZE_CODE.Square75]?: string 104 | height_sq?: number | string 105 | width_sq?: number | string 106 | [SIZE_CODE.Thumb]?: string 107 | height_t?: number | string 108 | width_t?: number | string 109 | [SIZE_CODE.Small240]?: string 110 | height_s?: number | string 111 | width_s?: number | string 112 | [SIZE_CODE.Square150]?: string 113 | height_q?: number | string 114 | width_q?: number | string 115 | [SIZE_CODE.Medium500]?: string 116 | height_m?: number | string 117 | width_m?: number | string 118 | [SIZE_CODE.Small320]?: string 119 | height_n?: number | string 120 | width_n?: number | string 121 | [SIZE_CODE.Medium640]?: string 122 | height_z?: number | string 123 | width_z?: number | string 124 | [SIZE_CODE.Medium800]?: string 125 | height_c?: number | string 126 | width_c?: number | string 127 | [SIZE_CODE.Large1024]?: string 128 | height_l?: number | string 129 | width_l?: number | string 130 | [SIZE_CODE.Large1600]?: string 131 | height_h?: number | string 132 | width_h?: number | string 133 | [SIZE_CODE.Large2048]?: string 134 | height_k?: number | string 135 | width_k?: number | string 136 | [SIZE_CODE.Original]?: string 137 | height_o?: number | string 138 | width_o?: number | string 139 | } 140 | 141 | interface PhotosetsGetListEntry extends FlickrMinimalResponse { 142 | username: string 143 | primary: string 144 | count_views: string 145 | count_comments: string 146 | count_photos: number 147 | count_videos: number 148 | description: Content 149 | can_comment: number 150 | date_create: string 151 | date_update: string 152 | sorting_option_id: string 153 | photos: number 154 | videos: number 155 | visibility_can_see_set: number 156 | needs_interstitial: number 157 | } 158 | 159 | interface GetPhotosPhoto extends FlickrMinimalResponse, SizeInfo { 160 | description?: Content 161 | lastupdate?: string 162 | datetaken?: string 163 | datetakengranularity?: number 164 | datetakenunknown?: string 165 | views?: string 166 | media?: string 167 | media_status?: string 168 | } 169 | 170 | export interface FlickrResponse extends Partial<GetPhotosPhoto>, Partial<PhotosetsGetListEntry> {} 171 | 172 | export interface GetPhotosResponse { 173 | photos: { 174 | page: number 175 | pages: number 176 | perpage: number 177 | total: number 178 | photo: GetPhotosPhoto[] 179 | } 180 | } 181 | 182 | export interface PhotosetsGetListResponse { 183 | photosets: { 184 | page: number 185 | pages: number 186 | perpage: number 187 | total: string 188 | photoset: PhotosetsGetListEntry[] 189 | } 190 | } 191 | 192 | export interface PhotosetsGetPhotosResponse { 193 | photoset: { 194 | id: string 195 | primary: string 196 | owner: string 197 | ownername: string 198 | page: number 199 | per_page: number 200 | perpage: number 201 | pages: number 202 | title: string 203 | total: number 204 | photo: GetPhotosPhoto[] 205 | } 206 | } 207 | 208 | export interface FindByUsernameResponse { 209 | user: { 210 | id: string 211 | nsid: string 212 | username: { 213 | _content: string 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /packages/flickr/src/types/loader.ts: -------------------------------------------------------------------------------- 1 | import type { GetPhotosQueryParams, PhotosetsGetListParams, PhotosetsGetPhotosParams } from './flickr.js' 2 | 3 | export interface StandardOptions { 4 | /** 5 | * Your API application key. See here for more details: https://www.flickr.com/services/api/misc.api_keys.html 6 | */ 7 | api_key?: string 8 | /** 9 | * Flickr username 10 | */ 11 | username: string 12 | } 13 | 14 | export interface FlickrPeopleGetPhotosLoaderOptions extends StandardOptions { 15 | /** 16 | * Optional query parameters you can pass to the request. By passing options here you will override any defaults that may be set. 17 | */ 18 | queryParams?: GetPhotosQueryParams 19 | } 20 | 21 | export interface FlickrPhotosetsGetListLoaderOptions extends StandardOptions { 22 | /** 23 | * Optional query parameters you can pass to the request. By passing options here you will override any defaults that may be set. 24 | */ 25 | queryParams?: PhotosetsGetListParams 26 | } 27 | 28 | export interface FlickrPhotosetsGetPhotosLoaderOptions extends StandardOptions { 29 | /** 30 | * The ID of the photoset you want to fetch photos from 31 | */ 32 | photoset_id: string 33 | /** 34 | * Optional query parameters you can pass to the request. By passing options here you will override any defaults that may be set. 35 | */ 36 | queryParams?: PhotosetsGetPhotosParams 37 | } 38 | 39 | type FlickrPhotosetsGetListWithPhotos = { 40 | /** 41 | * Array of photoset IDs to match against 42 | */ 43 | in?: string[] 44 | /** 45 | * Array of photoset IDs to exclude 46 | */ 47 | nin?: never 48 | } | { 49 | /** 50 | * Array of photoset IDs to match against 51 | */ 52 | in?: never 53 | /** 54 | * Array of photoset IDs to exclude 55 | */ 56 | nin?: string[] 57 | } 58 | 59 | export type FlickrPhotosetsGetListWithPhotosLoaderOptions = StandardOptions & FlickrPhotosetsGetListWithPhotos & { 60 | /** 61 | * Optional query parameters you can pass to the request. By passing options here you will override any defaults that may be set. 62 | */ 63 | queryParams?: PhotosetsGetListParams 64 | } 65 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/__tests__/__snapshots__/normalize.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`normalize > should normalize people-getPhotos response 1`] = ` 4 | { 5 | "date_last_update": 2023-08-24T14:22:22.000Z, 6 | "date_taken": 2023-07-20T21:04:31.000Z, 7 | "description": "Photo", 8 | "id": "53077440094", 9 | "imageUrls": { 10 | "100px": { 11 | "height": 56, 12 | "orientation": "landscape", 13 | "url": "URL", 14 | "width": 100, 15 | }, 16 | "1024px": { 17 | "height": 576, 18 | "orientation": "landscape", 19 | "url": "URL", 20 | "width": 1024, 21 | }, 22 | "1600px": { 23 | "height": 576, 24 | "orientation": "landscape", 25 | "url": "URL", 26 | "width": 1024, 27 | }, 28 | "2048px": { 29 | "height": 1024, 30 | "orientation": "square", 31 | "url": "URL", 32 | "width": 1024, 33 | }, 34 | "240px": { 35 | "height": 135, 36 | "orientation": "landscape", 37 | "url": "URL", 38 | "width": 240, 39 | }, 40 | "320px": { 41 | "height": 180, 42 | "orientation": "landscape", 43 | "url": "URL", 44 | "width": 320, 45 | }, 46 | "500px": { 47 | "height": 281, 48 | "orientation": "landscape", 49 | "url": "URL", 50 | "width": 500, 51 | }, 52 | "640px": { 53 | "height": 360, 54 | "orientation": "landscape", 55 | "url": "URL", 56 | "width": 640, 57 | }, 58 | "800px": { 59 | "height": 450, 60 | "orientation": "landscape", 61 | "url": "URL", 62 | "width": 800, 63 | }, 64 | "original": { 65 | "height": 3000, 66 | "orientation": "portrait", 67 | "url": "URL", 68 | "width": 1024, 69 | }, 70 | "sq_150px": { 71 | "height": 150, 72 | "orientation": "square", 73 | "url": "URL", 74 | "width": 150, 75 | }, 76 | "sq_75px": { 77 | "height": 75, 78 | "orientation": "square", 79 | "url": "URL", 80 | "width": 75, 81 | }, 82 | }, 83 | "is_family": false, 84 | "is_friend": false, 85 | "is_public": true, 86 | "media": "photo", 87 | "media_status": "ready", 88 | "owner": "192975453@N04", 89 | "title": "DSCF1603", 90 | "views": 24, 91 | } 92 | `; 93 | 94 | exports[`normalize > should normalize photosets-getList response 1`] = ` 95 | { 96 | "comments": 0, 97 | "date_create": 2024-06-17T12:30:55.000Z, 98 | "date_last_update": 2024-06-17T12:33:01.000Z, 99 | "description": "", 100 | "id": "72177720317993398", 101 | "owner": "192975453@N04", 102 | "photos": 5, 103 | "primary": "53796202792", 104 | "title": "Tallinn", 105 | "username": "ars_aurea", 106 | "videos": 0, 107 | "views": 0, 108 | } 109 | `; 110 | 111 | exports[`normalize > should normalize photosets-getPhotos response 1`] = ` 112 | { 113 | "date_last_update": 2024-06-17T12:30:55.000Z, 114 | "date_taken": 2024-06-10T09:54:34.000Z, 115 | "description": "Tallinn old town", 116 | "id": "53796202792", 117 | "imageUrls": { 118 | "100px": { 119 | "height": 100, 120 | "orientation": "portrait", 121 | "url": "https://live.staticflickr.com/65535/53796202792_cc929e770a_t.jpg", 122 | "width": 67, 123 | }, 124 | "1024px": { 125 | "height": 1024, 126 | "orientation": "portrait", 127 | "url": "https://live.staticflickr.com/65535/53796202792_cc929e770a_b.jpg", 128 | "width": 683, 129 | }, 130 | "240px": { 131 | "height": 240, 132 | "orientation": "portrait", 133 | "url": "https://live.staticflickr.com/65535/53796202792_cc929e770a_m.jpg", 134 | "width": 160, 135 | }, 136 | "320px": { 137 | "height": 320, 138 | "orientation": "portrait", 139 | "url": "https://live.staticflickr.com/65535/53796202792_cc929e770a_n.jpg", 140 | "width": 213, 141 | }, 142 | "500px": { 143 | "height": 500, 144 | "orientation": "portrait", 145 | "url": "https://live.staticflickr.com/65535/53796202792_cc929e770a.jpg", 146 | "width": 333, 147 | }, 148 | "640px": { 149 | "height": 640, 150 | "orientation": "portrait", 151 | "url": "https://live.staticflickr.com/65535/53796202792_cc929e770a_z.jpg", 152 | "width": 427, 153 | }, 154 | "800px": { 155 | "height": 800, 156 | "orientation": "portrait", 157 | "url": "https://live.staticflickr.com/65535/53796202792_cc929e770a_c.jpg", 158 | "width": 533, 159 | }, 160 | "sq_150px": { 161 | "height": 150, 162 | "orientation": "square", 163 | "url": "https://live.staticflickr.com/65535/53796202792_cc929e770a_q.jpg", 164 | "width": 150, 165 | }, 166 | "sq_75px": { 167 | "height": 75, 168 | "orientation": "square", 169 | "url": "https://live.staticflickr.com/65535/53796202792_cc929e770a_s.jpg", 170 | "width": 75, 171 | }, 172 | }, 173 | "is_family": false, 174 | "is_friend": false, 175 | "is_public": true, 176 | "media": "photo", 177 | "media_status": "ready", 178 | "title": "DSCF5186", 179 | "views": 20, 180 | } 181 | `; 182 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/__tests__/fixtures/people-getPhotos.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "53077440094", 3 | "owner": "192975453@N04", 4 | "secret": "eb945151d9", 5 | "server": "65535", 6 | "farm": 66, 7 | "title": "DSCF1603", 8 | "ispublic": 1, 9 | "isfriend": 0, 10 | "isfamily": 0, 11 | "description": { 12 | "_content": "Photo" 13 | }, 14 | "lastupdate": "1692886942", 15 | "datetaken": "2023-07-20 21:04:31", 16 | "datetakengranularity": 0, 17 | "datetakenunknown": "0", 18 | "views": "24", 19 | "media": "photo", 20 | "media_status": "ready", 21 | "url_sq": "URL", 22 | "height_sq": 75, 23 | "width_sq": 75, 24 | "url_t": "URL", 25 | "height_t": 56, 26 | "width_t": 100, 27 | "url_s": "URL", 28 | "height_s": 135, 29 | "width_s": 240, 30 | "url_q": "URL", 31 | "height_q": 150, 32 | "width_q": 150, 33 | "url_m": "URL", 34 | "height_m": 281, 35 | "width_m": 500, 36 | "url_n": "URL", 37 | "height_n": 180, 38 | "width_n": 320, 39 | "url_z": "URL", 40 | "height_z": 360, 41 | "width_z": 640, 42 | "url_c": "URL", 43 | "height_c": 450, 44 | "width_c": 800, 45 | "url_l": "URL", 46 | "height_l": 576, 47 | "width_l": 1024, 48 | "url_h": "URL", 49 | "height_h": 576, 50 | "width_h": 1024, 51 | "url_k": "URL", 52 | "height_k": 1024, 53 | "width_k": 1024, 54 | "url_o": "URL", 55 | "height_o": 3000, 56 | "width_o": 1024 57 | } 58 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/__tests__/fixtures/photosets-getList.json: -------------------------------------------------------------------------------- 1 | { 2 | "photosets": { 3 | "page": 1, 4 | "pages": 1, 5 | "perpage": 500, 6 | "total": 21, 7 | "photoset": [ 8 | { 9 | "id": "72177720317993398", 10 | "owner": "192975453@N04", 11 | "username": "ars_aurea", 12 | "primary": "53796202792", 13 | "secret": "cc929e770a", 14 | "server": "65535", 15 | "farm": 66, 16 | "count_views": "0", 17 | "count_comments": "0", 18 | "count_photos": 5, 19 | "count_videos": 0, 20 | "title": { 21 | "_content": "Tallinn" 22 | }, 23 | "description": { 24 | "_content": "" 25 | }, 26 | "can_comment": 0, 27 | "date_create": "1718627455", 28 | "date_update": "1718627581", 29 | "sorting_option_id": "date-taken-asc", 30 | "photos": 5, 31 | "videos": 0, 32 | "visibility_can_see_set": 1, 33 | "needs_interstitial": 0 34 | } 35 | ] 36 | }, 37 | "stat": "ok" 38 | } 39 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/__tests__/fixtures/photosets-getPhotos.json: -------------------------------------------------------------------------------- 1 | { 2 | "photoset": { 3 | "id": "72177720317993398", 4 | "primary": "53796202792", 5 | "owner": "192975453@N04", 6 | "ownername": "ars_aurea", 7 | "photo": [ 8 | { 9 | "id": "53796202792", 10 | "secret": "cc929e770a", 11 | "server": "65535", 12 | "farm": 66, 13 | "title": "DSCF5186", 14 | "isprimary": "0", 15 | "ispublic": 1, 16 | "isfriend": 0, 17 | "isfamily": 0, 18 | "description": { 19 | "_content": "Tallinn old town" 20 | }, 21 | "lastupdate": "1718627455", 22 | "datetaken": "2024-06-10 09:54:34", 23 | "datetakengranularity": 0, 24 | "datetakenunknown": "0", 25 | "views": "20", 26 | "media": "photo", 27 | "media_status": "ready", 28 | "url_sq": "https://live.staticflickr.com/65535/53796202792_cc929e770a_s.jpg", 29 | "height_sq": 75, 30 | "width_sq": 75, 31 | "url_t": "https://live.staticflickr.com/65535/53796202792_cc929e770a_t.jpg", 32 | "height_t": 100, 33 | "width_t": 67, 34 | "url_s": "https://live.staticflickr.com/65535/53796202792_cc929e770a_m.jpg", 35 | "height_s": 240, 36 | "width_s": 160, 37 | "url_q": "https://live.staticflickr.com/65535/53796202792_cc929e770a_q.jpg", 38 | "height_q": 150, 39 | "width_q": 150, 40 | "url_m": "https://live.staticflickr.com/65535/53796202792_cc929e770a.jpg", 41 | "height_m": 500, 42 | "width_m": 333, 43 | "url_n": "https://live.staticflickr.com/65535/53796202792_cc929e770a_n.jpg", 44 | "height_n": 320, 45 | "width_n": 213, 46 | "url_z": "https://live.staticflickr.com/65535/53796202792_cc929e770a_z.jpg", 47 | "height_z": 640, 48 | "width_z": 427, 49 | "url_c": "https://live.staticflickr.com/65535/53796202792_cc929e770a_c.jpg", 50 | "height_c": 800, 51 | "width_c": 533, 52 | "url_l": "https://live.staticflickr.com/65535/53796202792_cc929e770a_b.jpg", 53 | "height_l": 1024, 54 | "width_l": 683 55 | } 56 | ], 57 | "page": 1, 58 | "per_page": 500, 59 | "perpage": 500, 60 | "pages": 1, 61 | "title": "Tallinn", 62 | "sorting_option_id": "date-taken-asc", 63 | "total": 5 64 | }, 65 | "stat": "ok" 66 | } 67 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/__tests__/normalize.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 2 | import { normalize } from '../normalize.js' 3 | 4 | // @ts-expect-error - Does not matter 5 | import peopleGetPhotosFixture from './fixtures/people-getPhotos.json' 6 | // @ts-expect-error - Does not matter 7 | import photosetsGetList from './fixtures/photosets-getList.json' 8 | // @ts-expect-error - Does not matter 9 | import photosetsGetPhotos from './fixtures/photosets-getPhotos.json' 10 | 11 | describe('normalize', () => { 12 | let originalTZ: string | undefined 13 | 14 | beforeAll(() => { 15 | originalTZ = process.env.TZ 16 | process.env.TZ = 'UTC' 17 | }) 18 | 19 | afterAll(() => { 20 | process.env.TZ = originalTZ 21 | }) 22 | 23 | it('should normalize people-getPhotos response', () => { 24 | const normalized = normalize(peopleGetPhotosFixture) 25 | 26 | expect(normalized).toMatchSnapshot() 27 | }) 28 | 29 | it('should normalize photosets-getList response', () => { 30 | const normalized = normalize(photosetsGetList.photosets.photoset[0]!) 31 | 32 | expect(normalized).toMatchSnapshot() 33 | }) 34 | 35 | it('should normalize photosets-getPhotos response', () => { 36 | const normalized = normalize(photosetsGetPhotos.photoset.photo[0]!) 37 | 38 | expect(normalized).toMatchSnapshot() 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import { AstroError } from 'astro/errors' 2 | 3 | export function missingApiKey(): void { 4 | throw new AstroError('Missing Flickr API key. Define the FLICKR_API_KEY environment variable or pass it as an option.') 5 | } 6 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/get-user-id.ts: -------------------------------------------------------------------------------- 1 | import type { Flickr } from './ky.js' 2 | 3 | export async function getUserIdFromUsername(username: string, flickr: Flickr): Promise<string> { 4 | const res = await flickr('flickr.people.findByUsername', { 5 | username, 6 | }) 7 | 8 | return res.user.nsid 9 | } 10 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/ky.ts: -------------------------------------------------------------------------------- 1 | import type { FindByUsernameResponse, GetPhotosQueryParams, GetPhotosResponse, PeopleFindByUsernameParams, PhotosetsGetListParams, PhotosetsGetListResponse, PhotosetsGetPhotosParams, PhotosetsGetPhotosResponse } from '../types/flickr.js' 2 | import ky from 'ky' 3 | import { BASE_URL, DEFAULT_OPTIONS } from '../constants.js' 4 | 5 | interface API { 6 | 'flickr.people.getPhotos': [ 7 | GetPhotosQueryParams, 8 | GetPhotosResponse, 9 | ] 10 | 'flickr.photosets.getPhotos': [ 11 | PhotosetsGetPhotosParams, 12 | PhotosetsGetPhotosResponse, 13 | ] 14 | 'flickr.photosets.getList': [ 15 | PhotosetsGetListParams, 16 | PhotosetsGetListResponse, 17 | ] 18 | 'flickr.people.findByUsername': [ 19 | PeopleFindByUsernameParams, 20 | FindByUsernameResponse, 21 | ] 22 | } 23 | 24 | export interface Flickr { 25 | <T extends keyof API>(method: T, params: API[T][0]): Promise<API[T][1]> 26 | } 27 | 28 | export function createFlickr(api_key: string) { 29 | return { 30 | flickr: <T extends keyof API>(method: T, params: API[T][0]): Promise<API[T][1]> => { 31 | return ky(BASE_URL, { 32 | method: 'get', 33 | headers: { 34 | 'user-agent': '@lekoarts/flickr-loader', 35 | }, 36 | searchParams: { 37 | api_key, 38 | format: 'json', 39 | nojsoncallback: 1, 40 | extras: DEFAULT_OPTIONS.extras, 41 | per_page: DEFAULT_OPTIONS.per_page, 42 | method, 43 | ...params, 44 | }, 45 | }).json() 46 | }, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/normalize.ts: -------------------------------------------------------------------------------- 1 | import type { z } from 'astro/zod' 2 | import type { PeopleGetPhotos, PhotosetsGetList } from '../schema.js' 3 | import type { FlickrResponse } from '../types/flickr.js' 4 | import { SIZES } from '../constants.js' 5 | 6 | type Res = z.infer<typeof PeopleGetPhotos> & z.infer<typeof PhotosetsGetList> 7 | type SizesArray = Array<keyof typeof SIZES> 8 | type SizesUnion = keyof typeof SIZES 9 | 10 | interface ImageUrl { 11 | url: string 12 | height: number 13 | width: number 14 | orientation: 'landscape' | 'portrait' | 'square' 15 | } 16 | 17 | /** 18 | * The responses you get back from Flickr's API are a mess. They mix naming conventions, different data types for the same category, and so on. This function tries to normalize the data a bit. 19 | */ 20 | export function normalize(res: FlickrResponse): Res { 21 | const { server, farm, secret, ...rest } = res 22 | const copy = { ...rest } as Partial<FlickrResponse> 23 | const output = { 24 | id: rest.id, 25 | } as Res 26 | 27 | if (Object.prototype.hasOwnProperty.call(copy, 'owner')) { 28 | if (typeof copy.owner !== 'undefined') { 29 | output.owner = copy.owner 30 | } 31 | } 32 | 33 | ;(Object.keys(SIZES) as SizesArray).forEach((suffix) => { 34 | if (Object.prototype.hasOwnProperty.call(copy, `height_${suffix}`)) { 35 | copy[`height_${suffix}`] = Number.parseInt(copy[`height_${suffix}`] as string, 10) 36 | } 37 | if (Object.prototype.hasOwnProperty.call(copy, `width_${suffix}`)) { 38 | copy[`width_${suffix}`] = Number.parseInt(copy[`width_${suffix}`] as string, 10) 39 | } 40 | }) 41 | 42 | if (Object.prototype.hasOwnProperty.call(copy, 'ispublic')) { 43 | output.is_public = copy.ispublic === 1 44 | } 45 | 46 | if (Object.prototype.hasOwnProperty.call(copy, 'isfriend')) { 47 | output.is_friend = copy.isfriend === 1 48 | } 49 | 50 | if (Object.prototype.hasOwnProperty.call(copy, 'isfamily')) { 51 | output.is_family = copy.isfamily === 1 52 | } 53 | 54 | if (Object.prototype.hasOwnProperty.call(copy, 'description')) { 55 | output.description = copy.description?._content 56 | } 57 | 58 | if (Object.prototype.hasOwnProperty.call(copy, 'lastupdate')) { 59 | if (copy.lastupdate) { 60 | output.date_last_update = new Date(+copy.lastupdate * 1000) 61 | } 62 | } 63 | 64 | if (Object.prototype.hasOwnProperty.call(copy, 'datetaken')) { 65 | if (copy.datetaken) { 66 | output.date_taken = new Date(copy.datetaken) 67 | } 68 | } 69 | 70 | if (Object.prototype.hasOwnProperty.call(copy, 'views')) { 71 | if (typeof copy.views !== 'undefined') { 72 | output.views = Number.parseInt(copy.views, 10) 73 | } 74 | } 75 | 76 | if (Object.prototype.hasOwnProperty.call(copy, 'media')) { 77 | output.media = copy.media 78 | } 79 | 80 | if (Object.prototype.hasOwnProperty.call(copy, 'media_status')) { 81 | output.media_status = copy.media_status 82 | } 83 | 84 | if (Object.prototype.hasOwnProperty.call(copy, 'title')) { 85 | if (typeof copy.title === 'string') { 86 | output.title = copy.title 87 | } 88 | else if (copy.title?._content) { 89 | output.title = copy.title._content 90 | } 91 | } 92 | 93 | if (Object.prototype.hasOwnProperty.call(copy, 'username')) { 94 | output.username = copy.username! 95 | } 96 | 97 | if (Object.prototype.hasOwnProperty.call(copy, 'primary')) { 98 | output.primary = copy.primary! 99 | } 100 | 101 | if (Object.prototype.hasOwnProperty.call(copy, 'count_views')) { 102 | if (typeof copy.count_views !== 'undefined') { 103 | output.views = Number.parseInt(copy.count_views, 10) 104 | } 105 | } 106 | 107 | if (Object.prototype.hasOwnProperty.call(copy, 'count_comments')) { 108 | if (copy.count_comments) { 109 | output.comments = Number.parseInt(copy.count_comments, 10) 110 | } 111 | } 112 | 113 | if (Object.prototype.hasOwnProperty.call(copy, 'count_photos')) { 114 | if (typeof copy.count_photos !== 'undefined') { 115 | output.photos = copy.count_photos 116 | } 117 | } 118 | 119 | if (Object.prototype.hasOwnProperty.call(copy, 'count_videos')) { 120 | if (typeof copy.count_videos !== 'undefined') { 121 | output.videos = copy.count_videos 122 | } 123 | } 124 | 125 | if (Object.prototype.hasOwnProperty.call(copy, 'date_create')) { 126 | if (copy.date_create) { 127 | output.date_create = new Date(+copy.date_create * 1000) 128 | } 129 | } 130 | 131 | if (Object.prototype.hasOwnProperty.call(copy, 'date_update')) { 132 | if (copy.date_update) { 133 | output.date_last_update = new Date(+copy.date_update * 1000) 134 | } 135 | } 136 | 137 | const hasImageUrls = Object.keys(copy).some(key => key.startsWith('url_')) 138 | 139 | if (!hasImageUrls) { 140 | // Early return so we don't have to create the imageUrls object 141 | return output 142 | } 143 | 144 | output.imageUrls = {} 145 | 146 | for (const key in copy) { 147 | if (Object.prototype.hasOwnProperty.call(copy, key)) { 148 | const firstElem = key.toString().split(`_`).shift() 149 | const lastElem = key.toString().split(`_`).pop() as SizesUnion 150 | 151 | if (firstElem && lastElem && (Object.keys(SIZES) as Array<keyof typeof SIZES>).includes(lastElem)) { 152 | const sizeKey = SIZES[lastElem] 153 | // @ts-expect-error - Fixme 154 | const newElem = firstElem === `url` ? { [firstElem]: copy[key] } : { [firstElem]: copy[key] } 155 | // @ts-expect-error - Fixme 156 | output.imageUrls[sizeKey] = { ...output.imageUrls[sizeKey], ...newElem } 157 | } 158 | 159 | for (const image in output.imageUrls) { 160 | if (Object.prototype.hasOwnProperty.call(output.imageUrls, image)) { 161 | // @ts-expect-error - Fixme 162 | const element: ImageUrl = output.imageUrls[image] 163 | 164 | element.orientation = element.width === element.height ? `square` : element.width > element.height ? `landscape` : `portrait` 165 | } 166 | } 167 | } 168 | } 169 | 170 | return output 171 | } 172 | -------------------------------------------------------------------------------- /packages/flickr/src/utils/paginate.ts: -------------------------------------------------------------------------------- 1 | import type { GetPhotosResponse, PhotosetsGetListResponse, PhotosetsGetPhotosResponse } from '../types/flickr.js' 2 | 3 | type Response = GetPhotosResponse | PhotosetsGetListResponse | PhotosetsGetPhotosResponse 4 | 5 | // Typeguard functions to check which response type we're dealing with 6 | 7 | export function isGetPhotosResponse(response: Response): response is GetPhotosResponse { 8 | return 'photos' in response 9 | } 10 | 11 | export function isPhotosetsGetListResponse(response: Response): response is PhotosetsGetListResponse { 12 | return 'photosets' in response 13 | } 14 | 15 | export function isPhotosetsGetPhotosResponse(response: Response): response is PhotosetsGetPhotosResponse { 16 | return 'photoset' in response 17 | } 18 | 19 | /** 20 | * Unwrap data to have access to "pages" and "page" properties. 21 | */ 22 | function unwrap(response: Response) { 23 | if (isGetPhotosResponse(response)) { 24 | return response.photos 25 | } 26 | if (isPhotosetsGetListResponse(response)) { 27 | return response.photosets 28 | } 29 | if (isPhotosetsGetPhotosResponse(response)) { 30 | return response.photoset 31 | } 32 | } 33 | 34 | /** 35 | * Paginate through flickr() API calls. They always provide a `page` and `pages` property in the response. 36 | */ 37 | export function paginate<T extends Response>(fn: (page: number) => Promise<T>, page = 1, results: T[] = []): Promise<T[]> { 38 | return fn(page).then((data) => { 39 | results.push(data) 40 | 41 | const unwrapped = unwrap(data) 42 | 43 | if (unwrapped) { 44 | if (page < unwrapped.pages) { 45 | return paginate(fn, page + 1, results) 46 | } 47 | } 48 | 49 | return results 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /packages/flickr/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "vite/client" 6 | ] 7 | }, 8 | "include": [ 9 | "src" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /packages/flickr/tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | }) 6 | -------------------------------------------------------------------------------- /packages/plausible/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @lekoarts/plausible-loader 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - [#76](https://github.com/LekoArts/astro-loaders/pull/76) [`1b765ca`](https://github.com/LekoArts/astro-loaders/commit/1b765cae75164526df93c110f6245f6957faf9f1) Thanks [@LekoArts](https://github.com/LekoArts)! - Internal change: Switch from tsup to tsdown. No behavior change should occur. 8 | 9 | ## 1.0.1 10 | 11 | ### Patch Changes 12 | 13 | - [`f998420`](https://github.com/LekoArts/astro-loaders/commit/f9984200d85ce639d609c93b62f91cd21d3e6388) Thanks [@LekoArts](https://github.com/LekoArts)! - Add type support for new [`'28d'` and `'90d'` periods](https://github.com/plausible/docs/pull/594) for `date_range`. 14 | -------------------------------------------------------------------------------- /packages/plausible/README.md: -------------------------------------------------------------------------------- 1 | # Astro Plausible loader 2 | 3 | This package provides a [Plausible](https://plausible.io/) content loader for Astro's [content layer](https://docs.astro.build/en/guides/content-collections/). You can access the Stats API **v2** to view historical and real-time stats of your website. 4 | 5 | **Want to see an overview of all my loaders? Visit [astro-loaders.lekoarts.de](https://astro-loaders.lekoarts.de) ✨** 6 | 7 | <!-- automd:badges license --> 8 | 9 | [![npm version](https://img.shields.io/npm/v/@lekoarts/plausible-loader)](https://npmjs.com/package/@lekoarts/plausible-loader) 10 | [![npm downloads](https://img.shields.io/npm/dm/@lekoarts/plausible-loader)](https://npm.chart.dev/@lekoarts/plausible-loader) 11 | [![license](https://img.shields.io/github/license/LekoArts/astro-loaders)](https://github.com/LekoArts/astro-loaders/blob/main/LICENSE) 12 | 13 | <!-- /automd --> 14 | 15 | ## Prerequisites 16 | 17 | - Astro 5 or later installed 18 | - A Plausible API key 19 | - Go to your Plausible Analytics account, navigate to **"Account Settings"** and click on the section called **"API Keys"**. 20 | 21 | ## Installation 22 | 23 | <!-- automd:pm-install separate auto=false --> 24 | 25 | ```sh 26 | # npm 27 | npm install @lekoarts/plausible-loader 28 | ``` 29 | 30 | ```sh 31 | # yarn 32 | yarn add @lekoarts/plausible-loader 33 | ``` 34 | 35 | ```sh 36 | # pnpm 37 | pnpm install @lekoarts/plausible-loader 38 | ``` 39 | 40 | <!-- /automd --> 41 | 42 | ## Usage 43 | 44 | Import `@lekoarts/plausible-loader` into `src/content.config.ts` and define your collections. 45 | 46 | **Important:** You need to either define the Plausible API key as an environment variable (`PLAUSIBLE_API_KEY`) or pass it as the `api_key` option. 47 | 48 | ```ts 49 | import { plausibleLoader } from '@lekoarts/plausible-loader' 50 | 51 | const plausible = defineCollection({ 52 | loader: plausibleLoader({ 53 | query: { 54 | site_id: 'example.com', 55 | metrics: ['visitors'], 56 | date_range: '7d', 57 | }, 58 | }), 59 | }) 60 | ``` 61 | 62 | Similar to the Stats API [response structure](https://plausible.io/docs/stats-api#response-structure) the loader returns an object of `{ results, meta }`. If you want to access the computed `query`, you can run Astro with the `--verbose` flag to read that. 63 | 64 | ## Options 65 | 66 | ### `query` (required) 67 | 68 | The Plausible Stats API v2 accepts a `query` object. Pass the [request query](https://plausible.io/docs/stats-api#request-structure) to the endpoint through this option. 69 | 70 | Read the documentation on the individual keys you can use in said object. You always **have to** include the `site_id`, `date_range`, and `metrics` keys. 71 | 72 | For example, to get a timeseries, pass in this object: 73 | 74 | ```ts 75 | import { plausibleLoader } from '@lekoarts/plausible-loader' 76 | 77 | const plausible = defineCollection({ 78 | loader: plausibleLoader({ 79 | query: { 80 | site_id: 'example.com', 81 | metrics: ['visitors', 'events'], 82 | date_range: '7d', 83 | filters: [ 84 | ['is', 'visit:os', ['GNU/Linux', 'Mac']] 85 | ], 86 | dimensions: ['time:day'] 87 | }, 88 | }), 89 | }) 90 | ``` 91 | 92 | ### `api_key` 93 | 94 | If you didn't define the `PLAUSIBLE_API_KEY` environment variable you have to pass in your Plausible API key here. 95 | 96 | ```ts 97 | import { plausibleLoader } from '@lekoarts/plausible-loader' 98 | 99 | const plausible = defineCollection({ 100 | loader: plausibleLoader({ 101 | api_key: 'your-api-key', 102 | query: {/* Your query */}, 103 | }), 104 | }) 105 | ``` 106 | 107 | ### `api_url` 108 | 109 | If you self-host Plausible, you can set the URL to your instance here. By default, `https://plausible.io` is used. 110 | 111 | ```ts 112 | import { plausibleLoader } from '@lekoarts/plausible-loader' 113 | 114 | const plausible = defineCollection({ 115 | loader: plausibleLoader({ 116 | api_url: 'https://plausible.io', 117 | query: {/* Your query */}, 118 | }), 119 | }) 120 | ``` 121 | -------------------------------------------------------------------------------- /packages/plausible/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lekoarts/plausible-loader", 3 | "type": "module", 4 | "version": "1.0.2", 5 | "description": "Astro content loader for Plausible", 6 | "author": "LekoArts", 7 | "license": "MIT", 8 | "homepage": "https://github.com/LekoArts/astro-loaders", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com:LekoArts/astro-loaders.git", 12 | "directory": "packages/plausible" 13 | }, 14 | "keywords": [ 15 | "withastro", 16 | "astro-loader", 17 | "plausible" 18 | ], 19 | "exports": { 20 | ".": "./dist/index.js" 21 | }, 22 | "main": "dist/index.js", 23 | "types": "dist/index.d.ts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "engines": { 28 | "node": "^18.17.1 || ^20.3.0 || >=22.0.0" 29 | }, 30 | "scripts": { 31 | "build": "tsdown", 32 | "dev": "tsdown --watch", 33 | "prepublishOnly": "node --run build", 34 | "check": "publint && attw --pack . --profile esm-only", 35 | "test": "vitest --typecheck" 36 | }, 37 | "peerDependencies": { 38 | "astro": "^5.0.0" 39 | }, 40 | "dependencies": { 41 | "ky": "^1.8.1" 42 | }, 43 | "devDependencies": { 44 | "@arethetypeswrong/cli": "catalog:linting", 45 | "astro": "catalog:astro", 46 | "publint": "catalog:linting", 47 | "tsdown": "catalog:repo", 48 | "typescript": "catalog:repo" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/plausible/src/__tests__/fixtures/responses.ts: -------------------------------------------------------------------------------- 1 | export const responses = [ 2 | { 3 | results: [{ metrics: [944, 1797], dimensions: [] }], 4 | meta: {}, 5 | }, 6 | { 7 | results: [{ metrics: [1140], dimensions: [] }], 8 | meta: {}, 9 | }, 10 | { 11 | results: [ 12 | { metrics: [99, 98, 94], dimensions: ['Estonia', 'Tallinn'] }, 13 | { metrics: [98, 82, 97], dimensions: ['Brazil', 'São Paulo'] }, 14 | { metrics: [97, 77, 98], dimensions: ['Germany', 'Berlin'] }, 15 | { metrics: [94, 86, 93], dimensions: ['Italy', 'Rome'] }, 16 | { metrics: [89, 77, 96], dimensions: ['United States', 'San Francisco'] }, 17 | { metrics: [82, 78, 92], dimensions: ['Poland', 'Warsaw'] }, 18 | ], 19 | meta: {}, 20 | }, 21 | { 22 | results: [ 23 | { metrics: [320, 382, 297], dimensions: ['(not set)', 'duckduckgo'] }, 24 | { metrics: [302, 363, 296], dimensions: ['social', 'twitter'] }, 25 | { metrics: [293, 342, 280], dimensions: ['social', 'facebook'] }, 26 | { metrics: [288, 345, 292], dimensions: ['(not set)', 'google'] }, 27 | { metrics: [169, 185, 147], dimensions: ['(not set)', '(not set)'] }, 28 | { metrics: [159, 180, 153], dimensions: ['email', '(not set)'] }, 29 | ], 30 | meta: {}, 31 | }, 32 | { 33 | results: [{ metrics: [10], dimensions: [] }], 34 | meta: {}, 35 | }, 36 | { 37 | results: [{ metrics: [10], dimensions: [] }], 38 | meta: {}, 39 | }, 40 | { 41 | results: [{ metrics: [0, 0], dimensions: [] }], 42 | meta: {}, 43 | }, 44 | { 45 | results: [ 46 | { metrics: [129, 144], dimensions: ['2024-09-04'] }, 47 | { metrics: [65, 68], dimensions: ['2024-09-05'] }, 48 | { metrics: [72, 78], dimensions: ['2024-09-06'] }, 49 | { metrics: [94, 98], dimensions: ['2024-09-07'] }, 50 | { metrics: [44, 49], dimensions: ['2024-09-08'] }, 51 | { metrics: [14, 14], dimensions: ['2024-09-09'] }, 52 | { metrics: [12, 12], dimensions: ['2024-09-10'] }, 53 | ], 54 | meta: {}, 55 | }, 56 | { 57 | results: [ 58 | { metrics: [2], dimensions: ['2024-09-10 01:00:00'] }, 59 | { metrics: [4], dimensions: ['2024-09-10 02:00:00'] }, 60 | { metrics: [3], dimensions: ['2024-09-10 08:00:00'] }, 61 | { metrics: [1], dimensions: ['2024-09-10 12:00:00'] }, 62 | { metrics: [1], dimensions: ['2024-09-10 13:00:00'] }, 63 | { metrics: [1], dimensions: ['2024-09-10 23:00:00'] }, 64 | ], 65 | meta: { 66 | time_labels: [ 67 | '2024-09-10 00:00:00', 68 | '2024-09-10 01:00:00', 69 | '2024-09-10 02:00:00', 70 | '2024-09-10 03:00:00', 71 | '2024-09-10 04:00:00', 72 | '2024-09-10 05:00:00', 73 | '2024-09-10 06:00:00', 74 | '2024-09-10 07:00:00', 75 | '2024-09-10 08:00:00', 76 | '2024-09-10 09:00:00', 77 | '2024-09-10 10:00:00', 78 | '2024-09-10 11:00:00', 79 | '2024-09-10 12:00:00', 80 | '2024-09-10 13:00:00', 81 | '2024-09-10 14:00:00', 82 | '2024-09-10 15:00:00', 83 | '2024-09-10 16:00:00', 84 | '2024-09-10 17:00:00', 85 | '2024-09-10 18:00:00', 86 | '2024-09-10 19:00:00', 87 | '2024-09-10 20:00:00', 88 | '2024-09-10 21:00:00', 89 | '2024-09-10 22:00:00', 90 | '2024-09-10 23:00:00', 91 | ], 92 | }, 93 | }, 94 | { 95 | results: [ 96 | { metrics: [162], dimensions: ['2024-09-04', 'true'] }, 97 | { metrics: [126], dimensions: ['2024-09-04', 'false'] }, 98 | { metrics: [74], dimensions: ['2024-09-05', 'true'] }, 99 | { metrics: [86], dimensions: ['2024-09-05', 'false'] }, 100 | { metrics: [116], dimensions: ['2024-09-06', 'true'] }, 101 | { metrics: [97], dimensions: ['2024-09-06', 'false'] }, 102 | { metrics: [176], dimensions: ['2024-09-07', 'true'] }, 103 | { metrics: [191], dimensions: ['2024-09-07', 'false'] }, 104 | { metrics: [161], dimensions: ['2024-09-08', 'true'] }, 105 | { metrics: [172], dimensions: ['2024-09-08', 'false'] }, 106 | { metrics: [38], dimensions: ['2024-09-09', 'true'] }, 107 | { metrics: [50], dimensions: ['2024-09-09', 'false'] }, 108 | { metrics: [129], dimensions: ['2024-09-10', 'true'] }, 109 | { metrics: [125], dimensions: ['2024-09-10', 'false'] }, 110 | ], 111 | meta: {}, 112 | }, 113 | { 114 | results: [ 115 | { metrics: [325, 397, 317], dimensions: ['(not set)', 'duckduckgo'] }, 116 | { metrics: [311, 369, 295], dimensions: ['(not set)', 'google'] }, 117 | { metrics: [296, 357, 292], dimensions: ['social', 'twitter'] }, 118 | ], 119 | meta: { total_rows: 6 }, 120 | }, 121 | { 122 | results: [ 123 | { metrics: [91349], dimensions: ['Direct / None'] }, 124 | { metrics: [90901], dimensions: ['Twitter'] }, 125 | { metrics: [90265], dimensions: ['Facebook'] }, 126 | { metrics: [89511], dimensions: ['Google'] }, 127 | { metrics: [89243], dimensions: ['DuckDuckGo'] }, 128 | ], 129 | meta: { imports_included: true }, 130 | }, 131 | { 132 | results: [ 133 | { metrics: [1192], dimensions: ['Google'] }, 134 | { metrics: [1191], dimensions: ['Direct / None'] }, 135 | { metrics: [1189], dimensions: ['DuckDuckGo'] }, 136 | { metrics: [1186], dimensions: ['Facebook'] }, 137 | { metrics: [1185], dimensions: ['Twitter'] }, 138 | ], 139 | meta: { 140 | imports_included: false, 141 | imports_skip_reason: 'unsupported_query', 142 | imports_warning: 'Imported stats are not included in the results because query parameters are not supported. For more information, see: https://plausible.io/docs/stats-api#filtering-imported-stats', 143 | }, 144 | }, 145 | { 146 | results: [ 147 | { 148 | dimensions: ['North America Purchases'], 149 | metrics: [ 150 | { 151 | short: '$96.3M', 152 | value: 96336315, 153 | long: '$96,336,315.00', 154 | currency: 'USD', 155 | }, 156 | ], 157 | }, 158 | { 159 | dimensions: ['Visit /'], 160 | metrics: [null], 161 | }, 162 | ], 163 | meta: {}, 164 | }, 165 | { 166 | results: [ 167 | { metrics: [null], dimensions: ['Visit /'] }, 168 | ], 169 | meta: { 170 | metric_warnings: { 171 | total_revenue: { 172 | code: 'no_single_revenue_currency', 173 | warning: 'Revenue metrics are null as there are multiple currencies for the selected event:goals.', 174 | }, 175 | }, 176 | }, 177 | }, 178 | ] as const 179 | -------------------------------------------------------------------------------- /packages/plausible/src/__tests__/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { PlausibleResponseSchema } from '../schema.js' 3 | import { responses } from './fixtures/responses.js' 4 | 5 | describe('plausibleResponseSchema', () => { 6 | it.each(responses)('validates response %#', (res) => { 7 | const result = PlausibleResponseSchema.safeParse(res) 8 | 9 | expect(result.success).toBe(true) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /packages/plausible/src/__tests__/types.test-d.ts: -------------------------------------------------------------------------------- 1 | import type { PlausibleQuery } from '../types.js' 2 | import { describe, expectTypeOf, it } from 'vitest' 3 | 4 | const site_id = 'example.com' 5 | 6 | describe('plausibleQuery', () => { 7 | it('simple aggregate query', () => { 8 | const query: PlausibleQuery = { 9 | metrics: ['visitors', 'events'], 10 | date_range: '7d', 11 | site_id, 12 | } 13 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 14 | }) 15 | it('custom date range', () => { 16 | const query: PlausibleQuery = { 17 | metrics: ['visitors'], 18 | date_range: ['2024-08-01', '2024-08-15'], 19 | site_id, 20 | } 21 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 22 | }) 23 | it('country and city analysis', () => { 24 | const query: PlausibleQuery = { 25 | metrics: ['visitors', 'pageviews', 'bounce_rate'], 26 | date_range: '7d', 27 | filters: [ 28 | ['is_not', 'visit:country_name', ['']], 29 | ], 30 | dimensions: ['visit:country_name', 'visit:city_name'], 31 | site_id, 32 | } 33 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 34 | }) 35 | it('uTM medium, source analysis', () => { 36 | const query: PlausibleQuery = { 37 | metrics: ['visitors', 'events', 'pageviews'], 38 | date_range: '7d', 39 | dimensions: ['visit:utm_medium', 'visit:utm_source'], 40 | site_id, 41 | } 42 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 43 | }) 44 | it('filtering by page and country', () => { 45 | const query: PlausibleQuery = { 46 | metrics: ['visitors'], 47 | date_range: '7d', 48 | filters: [ 49 | ['contains', 'event:page', ['/docs', '/pricing']], 50 | ['or', [ 51 | ['not', ['is', 'visit:utm_campaign', ['Referral']]], 52 | ['is', 'visit:country_name', ['Estonia', 'United States of America']], 53 | ]], 54 | ], 55 | site_id, 56 | } 57 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 58 | }) 59 | it('case insensitive filtering', () => { 60 | const query: PlausibleQuery = { 61 | metrics: ['visitors'], 62 | date_range: '7d', 63 | filters: [ 64 | ['contains', 'event:page', ['blog'], { case_sensitive: false }], 65 | ], 66 | site_id, 67 | } 68 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 69 | }) 70 | it('filtering by segment', () => { 71 | const query: PlausibleQuery = { 72 | metrics: ['visitors', 'events'], 73 | filters: [['is', 'segment', [2]]], 74 | date_range: '7d', 75 | site_id, 76 | } 77 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 78 | }) 79 | it('timeseries query', () => { 80 | const query: PlausibleQuery = { 81 | metrics: ['visitors', 'events'], 82 | date_range: '7d', 83 | filters: [ 84 | ['is', 'visit:os', ['GNU/Linux', 'Mac']], 85 | ], 86 | dimensions: ['time:day'], 87 | site_id, 88 | } 89 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 90 | }) 91 | it('timeseries query hourly, with labels', () => { 92 | const query: PlausibleQuery = { 93 | metrics: ['visitors'], 94 | date_range: 'day', 95 | dimensions: ['time:hour'], 96 | include: { 97 | time_labels: true, 98 | }, 99 | site_id, 100 | } 101 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 102 | }) 103 | it('using custom properties', () => { 104 | const query: PlausibleQuery = { 105 | metrics: ['visitors'], 106 | date_range: '7d', 107 | dimensions: ['time:day', 'event:props:is_customer'], 108 | order_by: [['time:day', 'asc'], ['event:props:is_customer', 'desc']], 109 | site_id, 110 | } 111 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 112 | }) 113 | it('pagination', () => { 114 | const query: PlausibleQuery = { 115 | metrics: ['visitors', 'events', 'pageviews'], 116 | date_range: '7d', 117 | dimensions: ['visit:utm_medium', 'visit:utm_source'], 118 | include: { total_rows: true }, 119 | pagination: { limit: 3, offset: 1 }, 120 | site_id, 121 | } 122 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 123 | }) 124 | it('including imported data', () => { 125 | const query: PlausibleQuery = { 126 | metrics: ['visitors'], 127 | date_range: 'all', 128 | dimensions: ['visit:source'], 129 | include: { 130 | imports: true, 131 | }, 132 | site_id, 133 | } 134 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 135 | }) 136 | it('including imported data failed', () => { 137 | const query: PlausibleQuery = { 138 | metrics: ['visitors'], 139 | date_range: 'all', 140 | dimensions: ['visit:source'], 141 | filters: [['is', 'visit:country_name', ['Estonia']]], 142 | include: { 143 | imports: true, 144 | }, 145 | site_id, 146 | } 147 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 148 | }) 149 | it('revenue metrics', () => { 150 | const query: PlausibleQuery = { 151 | metrics: ['total_revenue'], 152 | date_range: 'all', 153 | dimensions: ['event:goal'], 154 | site_id, 155 | } 156 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 157 | }) 158 | it('revenue metrics could not be calculated', () => { 159 | const query: PlausibleQuery = { 160 | metrics: ['total_revenue'], 161 | date_range: 'all', 162 | filters: [ 163 | ['is', 'event:goal', ['PurchaseUSD', 'PurchaseEUR']], 164 | ], 165 | site_id, 166 | } 167 | expectTypeOf(query).toEqualTypeOf<PlausibleQuery>() 168 | }) 169 | }) 170 | -------------------------------------------------------------------------------- /packages/plausible/src/index.ts: -------------------------------------------------------------------------------- 1 | export { plausibleLoader } from './plausible-loader.js' 2 | export type { PlausibleLoaderOptions } from './types.js' 3 | -------------------------------------------------------------------------------- /packages/plausible/src/ky.ts: -------------------------------------------------------------------------------- 1 | import type { KyInstance } from 'ky' 2 | import ky from 'ky' 3 | 4 | export function createPlausible(api_key: string, api_url: string): KyInstance { 5 | return ky.create({ 6 | headers: { 7 | 'user-agent': '@lekoarts/plausible-loader', 8 | 'authorization': `Bearer ${api_key}`, 9 | }, 10 | prefixUrl: api_url, 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /packages/plausible/src/plausible-loader.ts: -------------------------------------------------------------------------------- 1 | import type { Loader } from 'astro/loaders' 2 | import type { PlausibleLoaderOptions, PlausibleResponse } from './types.js' 3 | import { AstroError } from 'astro/errors' 4 | import { createPlausible } from './ky.js' 5 | import { PlausibleResponseSchema } from './schema.js' 6 | 7 | const PLAUSIBLE_API_URL = 'https://plausible.io' 8 | 9 | export function plausibleLoader({ 10 | api_key = import.meta.env.PLAUSIBLE_API_KEY, 11 | api_url = PLAUSIBLE_API_URL, 12 | query, 13 | }: PlausibleLoaderOptions): Loader { 14 | if (!api_key) { 15 | throw new AstroError('Missing Plausible API key. Define the PLAUSIBLE_API_KEY environment variable or pass it as an option.') 16 | } 17 | 18 | const plausible = createPlausible(api_key, api_url) 19 | 20 | return { 21 | name: 'plausible', 22 | load: async ({ logger, store, generateDigest, parseData }) => { 23 | logger.info(`Fetching stats for ${query.site_id}`) 24 | 25 | const { results, meta, query: executedQuery } = await plausible.post<PlausibleResponse>('api/v2/query', { 26 | json: query, 27 | }).json() 28 | 29 | logger.debug(`Executed query: 30 | ${JSON.stringify(executedQuery, null, 2)} 31 | `) 32 | 33 | const result = { results, meta } 34 | const id = generateDigest(JSON.stringify(executedQuery)) 35 | const data = await parseData({ id, data: result }) 36 | const digest = generateDigest(data) 37 | 38 | store.set({ 39 | id, 40 | data, 41 | digest, 42 | }) 43 | 44 | logger.info('Loaded successfully') 45 | }, 46 | schema: () => PlausibleResponseSchema, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/plausible/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'astro/zod' 2 | 3 | export const PlausibleResponseSchema = z.object({ 4 | results: z.array(z.object({ 5 | metrics: z.array(z.union([z.number(), z.null(), z.object({ 6 | short: z.string(), 7 | value: z.number(), 8 | long: z.string(), 9 | currency: z.string(), 10 | })])), 11 | dimensions: z.array(z.string()), 12 | })), 13 | meta: z.object({ 14 | imports_included: z.boolean().optional(), 15 | imports_skip_reason: z.string().optional(), 16 | imports_warning: z.string().optional(), 17 | metric_warnings: z.object({ 18 | total_revenue: z.object({ 19 | code: z.string(), 20 | warning: z.string(), 21 | }), 22 | }).optional(), 23 | time_labels: z.array(z.string()).optional(), 24 | total_rows: z.number().optional(), 25 | }), 26 | }) 27 | -------------------------------------------------------------------------------- /packages/plausible/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface PlausibleLoaderOptions { 2 | /** 3 | * Your Plausible API key. Go to your Plausible Analytics account, navigating to "Account Settings" and click on the section called "API Keys". 4 | */ 5 | api_key?: string 6 | /** 7 | * If you self-host Plausible, you can set the URL to your instance here. 8 | * @default 'https://plausible.io' 9 | * @example 'https://plausible.example.com' 10 | */ 11 | api_url?: string 12 | /** 13 | * Your query against the [Stats API](https://plausible.io/docs/stats-api) of Plausible. 14 | */ 15 | query: PlausibleQuery 16 | } 17 | 18 | type TYear = `${number}${number}${number}${number}` 19 | type TMonth = `${number}${number}` 20 | type TDay = `${number}${number}` 21 | type THours = `${number}${number}` 22 | type TMinutes = `${number}${number}` 23 | type TSeconds = `${number}${number}` 24 | 25 | /** 26 | * Represent a string like `2024-01-01` 27 | */ 28 | type TDateISODate = `${TYear}-${TMonth}-${TDay}` 29 | 30 | /** 31 | * Represent a string like `15:59:59` 32 | */ 33 | type TDateISOTime = `${THours}:${TMinutes}:${TSeconds}` 34 | 35 | /** 36 | * Represent a string like `2024-01-01T15:59:59+02:00` (format: ISO 8601). 37 | */ 38 | type TDateISO = `${TDateISODate}T${TDateISOTime}+${THours}:${TMinutes}` 39 | 40 | type DateRangeUnit = 'day' | '7d' | '28d' | '30d' | '90d' | 'month' | '6mo' | '12mo' | 'year' | 'all' 41 | 42 | type Metric = 'visitors' | 'visits' | 'pageviews' | 'views_per_visit' | 'bounce_rate' | 'visit_duration' | 'events' | 'scroll_depth' | 'percentage' | 'conversion_rate' | 'group_conversion_rate' | 'average_revenue' | 'total_revenue' 43 | 44 | type Events = 'goal' | 'page' | 'hostname' 45 | type EventDimensions = `event:${Events}` 46 | 47 | type Visits = 'entry_page' | 'exit_page' | 'source' | 'referrer' | 'channel' | 'utm_medium' | 'utm_source' | 'utm_campaign' | 'utm_content' | 'utm_term' | 'device' | 'browser' | 'browser_version' | 'os' | 'os_version' | 'country' | 'region' | 'city' | 'country_name' | 'region_name' | 'city_name' 48 | type VisitDimensions = `visit:${Visits}` 49 | 50 | type Time = 'hour' | 'day' | 'week' | 'month' 51 | type TimeDimensions = 'time' | `time:${Time}` 52 | 53 | type CustomProperties = `event:props:${string}` 54 | 55 | type Dimension = EventDimensions | VisitDimensions | TimeDimensions | CustomProperties 56 | 57 | type FilterOperator = 'is' | 'is_not' | 'contains' | 'contains_not' | 'matches' | 'matches_not' 58 | 59 | interface FilterModifiers { 60 | case_sensitive?: boolean 61 | } 62 | 63 | type SimpleFilter = [ 64 | operator: FilterOperator, 65 | dimension: EventDimensions | VisitDimensions, 66 | clauses: string[], 67 | modifiers?: FilterModifiers, 68 | ] 69 | 70 | type SegmentFilter = [ 71 | operator: 'is', 72 | dimension: 'segment', 73 | clauses: Array<string | number>, 74 | ] 75 | 76 | type LogicalAndOrFilter = [ 77 | operator: 'and' | 'or', 78 | filters: Array<Filter>, 79 | ] 80 | 81 | type LogicalNotFilter = [ 82 | operator: 'not', 83 | filter: Filter, 84 | ] 85 | 86 | type Filter = SimpleFilter | SegmentFilter | LogicalAndOrFilter | LogicalNotFilter 87 | 88 | interface Revenue { 89 | short: string 90 | value: number 91 | long: string 92 | currency: string 93 | } 94 | 95 | export interface PlausibleQuery { 96 | /** 97 | * Domain of your site on Plausible to be queried. 98 | * @example 'example.com' 99 | */ 100 | site_id: string 101 | /** 102 | * Date range to be queried. 103 | */ 104 | date_range: DateRangeUnit | [TDateISO, TDateISO] | [TDateISODate, TDateISODate] 105 | /** 106 | * Metrics represent values to be calculated with the query. 107 | */ 108 | metrics: Array<Metric> 109 | /** 110 | * List of dimensions to group by. 111 | * 112 | * Dimensions are attributes of your dataset. Using them in queries enables analyzing and compare multiple groups against each other. Think of them as `GROUP BY` in SQL. 113 | * @default [] 114 | */ 115 | dimensions?: Array<Dimension> 116 | /** 117 | * Filters allow limiting the data analyzed in a query. 118 | * Each simple filter is an array with three or four elements [operator, dimension, clauses] or [operator, dimension, clauses, modifiers]. 119 | * 120 | * @default [] 121 | */ 122 | filters?: Array<Filter> 123 | /** 124 | * Allows for custom ordering of query results. 125 | * 126 | * List of tuples `[dimension_or_metric, direction]`, where: 127 | * - `dimension_or_metric` needs to be listed in query `metrics` or `dimensions` respectively. 128 | * - `direction` can be one of `"asc"` or `"desc"` 129 | * 130 | * When not specified, the default ordering is: 131 | * 1. If a time dimensions is present, `[time_dimension, "asc"]` 132 | * 2. By the first metric specified, descending. 133 | */ 134 | order_by?: Array<[Metric | Dimension, 'asc' | 'desc']> 135 | /** 136 | * Additional options for the query as to what data to include. 137 | */ 138 | include?: { 139 | /** 140 | * If true, tries to include imported data in the result. 141 | * 142 | * If set, `meta.imports_included` field will be set as a boolean. If the applied combination of filters and dimensions is not supported for imported stats, the results are still returned based only on native stats. Additionally, `meta.imports_skip_reason` and `meta.imports_warning` response fields will contain more information on why including imported data failed. 143 | * @default false 144 | */ 145 | imports?: boolean 146 | /** 147 | * Requires a `time` dimension being set. If true, sets `meta.time_labels` in response containing all time labels valid for `date_range`. 148 | * @default false 149 | */ 150 | time_labels?: boolean 151 | /** 152 | * Should be used for pagination. If true, sets `meta.total_rows` in response containing the total number of rows for this query. 153 | * @default false 154 | */ 155 | total_rows?: boolean 156 | } 157 | /** 158 | * Pagination options for the query. 159 | * @default { limit: 1000, offset: 0 } 160 | */ 161 | pagination?: { 162 | limit: number 163 | offset: number 164 | } 165 | } 166 | 167 | export interface PlausibleResponse { 168 | /** 169 | * Results is an ordered list query results. 170 | */ 171 | results: Array<{ 172 | /** 173 | * List of metric values, in the same order as query metrics 174 | */ 175 | metrics: Array<number | Revenue | null> 176 | /** 177 | * Values for each dimension listed in query. In the same order as query dimensions, empty if no dimensions requested. 178 | */ 179 | dimensions: Array<string> 180 | }> 181 | meta: { 182 | /** 183 | * Whether imported data was included. Only set if include.imports was set. 184 | */ 185 | imports_included?: boolean 186 | /** 187 | * Information about why including imported data failed 188 | */ 189 | imports_skip_reason?: string 190 | imports_warning?: string 191 | /** 192 | * Warnings about specific metrics. Currently only set if a revenue metric was used and was unable to be calculated. 193 | */ 194 | metric_warnings?: { 195 | total_revenue: { 196 | code: string 197 | warning: string 198 | } 199 | } 200 | /** 201 | * Only set if include.time_labels was set 202 | */ 203 | time_labels?: Array<string> 204 | /** 205 | * Only set if include.total_rows was set 206 | */ 207 | total_rows?: number 208 | } 209 | query: PlausibleQuery 210 | } 211 | -------------------------------------------------------------------------------- /packages/plausible/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "vite/client" 6 | ] 7 | }, 8 | "include": [ 9 | "src" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /packages/plausible/tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | }) 6 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/prefer-global/process */ 2 | import { defineConfig, devices } from '@playwright/test' 3 | 4 | export default defineConfig({ 5 | testDir: './e2e', 6 | fullyParallel: true, 7 | forbidOnly: !!process.env.CI, 8 | retries: process.env.CI ? 2 : 0, 9 | workers: process.env.CI ? 1 : undefined, 10 | reporter: process.env.CI ? 'github' : 'list', 11 | use: { 12 | baseURL: 'http://localhost:4321', 13 | trace: 'on-first-retry', 14 | }, 15 | projects: [ 16 | { 17 | name: 'website', 18 | use: { ...devices['Desktop Chrome'] }, 19 | }, 20 | ], 21 | webServer: { 22 | command: process.env.IS_BUILD ? 'pnpm run website:preview' : 'pnpm run website:dev', 23 | url: 'http://localhost:4321', 24 | reuseExistingServer: !process.env.CI, 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'demos/*' 4 | 5 | catalogs: 6 | astro: 7 | astro: 5.8.0 8 | linting: 9 | '@arethetypeswrong/cli': 0.18.1 10 | publint: 0.3.12 11 | repo: 12 | tsdown: 0.12.3 13 | typescript: 5.8.3 14 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>unjs/renovate-config", 5 | ":widenPeerDependencies" 6 | ], 7 | "packageRules": [] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | /* If your code doesn't run in the DOM: */ 5 | "lib": [ 6 | "es2022" 7 | ], 8 | "moduleDetection": "force", 9 | /* If NOT transpiling with TypeScript: */ 10 | "module": "NodeNext", 11 | "moduleResolution": "nodenext", 12 | "resolveJsonModule": true, 13 | "allowJs": false, 14 | /* Strictness */ 15 | "strict": true, 16 | "noImplicitOverride": true, 17 | "noUncheckedIndexedAccess": true, 18 | /* AND if you're building for a library: */ 19 | "declaration": true, 20 | "declarationMap": true, 21 | "noEmit": true, 22 | /* Base Options: */ 23 | "esModuleInterop": true, 24 | "isolatedModules": true, 25 | "verbatimModuleSyntax": true, 26 | "skipLibCheck": true 27 | } 28 | } 29 | --------------------------------------------------------------------------------