├── .astro └── types.d.ts ├── .changeset ├── README.md └── config.json ├── .editorconfig ├── .github ├── actions │ └── install │ │ └── action.yml ├── renovate.json5 └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── astro.config.ts ├── env.d.ts ├── eslint-plugin-import.d.ts ├── eslint.config.js ├── index.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── assets │ └── astro-full-logo-light.svg ├── components │ ├── Story.astro │ └── Svg │ │ ├── Svg.astro │ │ ├── overrideSvgAttributes.bench.ts │ │ ├── overrideSvgAttributes.test.ts │ │ └── overrideSvgAttributes.ts ├── env.d.ts ├── layouts │ └── Layout.astro ├── pages │ └── index.astro └── types.ts └── tsconfig.json /.astro/types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by 4 | `@changesets/cli`, a build tool that works with multi-package repos, or 5 | single-package repos to help you version and publish your code. You can find the 6 | full documentation for it 7 | [in our repository](https://github.com/changesets/changesets) 8 | 9 | We have a quick list of common questions to get you started engaging with this 10 | project in 11 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 12 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "access": "public", 6 | "baseBranch": "main", 7 | "updateInternalDependencies": "patch" 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.yml] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/actions/install/action.yml: -------------------------------------------------------------------------------- 1 | name: Install Tools & Dependencies 2 | description: Installs pnpm, Node.js & package dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Setup pnpm 8 | uses: pnpm/action-setup@v4 9 | 10 | - name: Setup Node ${{ inputs.node-version }} 11 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 12 | with: 13 | node-version-file: ".nvmrc" 14 | cache: pnpm 15 | 16 | - name: Install dependencies 17 | run: pnpm install 18 | shell: bash 19 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: ["config:best-practices", "config:js-lib"], 4 | rangeStrategy: "update-lockfile", 5 | lockFileMaintenance: { 6 | enabled: true, 7 | automerge: true, 8 | }, 9 | packageRules: [ 10 | { 11 | groupName: "prettier and plugins", 12 | matchPackageNames: ["prettier"], 13 | }, 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | lint: 11 | name: Lint and typecheck 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 16 | - name: Install Tools & Dependencies 17 | uses: ./.github/actions/install 18 | - name: Check `*.astro` types 19 | run: pnpm check:astro 20 | - name: Check `*.ts` types 21 | run: pnpm check:ts 22 | - name: Lint astro and ts 23 | run: pnpm lint 24 | - name: Check formatting 25 | run: pnpm format:check 26 | test: 27 | name: Test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 32 | - name: Install Tools & Dependencies 33 | uses: ./.github/actions/install 34 | - name: Test 35 | run: pnpm test 36 | build-docs: 37 | name: Smoke-Test Docs Site 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Checkout code 41 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 42 | - name: Install Tools & Dependencies 43 | uses: ./.github/actions/install 44 | - name: Build the docs 45 | run: pnpm build 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | defaults: 9 | run: 10 | shell: bash 11 | 12 | jobs: 13 | changelog: 14 | name: Changelog PR or Release 15 | if: ${{ github.repository_owner == 'jasikpark' }} 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | id-token: write 20 | pull-requests: write 21 | steps: 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 23 | 24 | - name: Install Tools & Dependencies 25 | uses: ./.github/actions/install 26 | 27 | - name: Create Release Pull Request or Publish 28 | id: changesets 29 | uses: changesets/action@v1 30 | with: 31 | # Note: pnpm install after versioning is necessary to refresh lockfile 32 | version: pnpm run version 33 | publish: pnpm exec changeset publish 34 | commit: "[ci] release" 35 | title: "[ci] release" 36 | env: 37 | # Needs access to push to main 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | # Needs access to publish to npm 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | *.lcov 17 | 18 | 19 | # Dependency directories 20 | node_modules/ 21 | 22 | # TypeScript cache 23 | *.tsbuildinfo 24 | 25 | # Optional npm cache directory 26 | .npm 27 | 28 | # Optional eslint cache 29 | .eslintcache 30 | 31 | # Optional stylelint cache 32 | .stylelintcache 33 | 34 | # Optional REPL history 35 | .node_repl_history 36 | 37 | # Output of 'npm pack' 38 | *.tgz 39 | 40 | # Yarn Integrity file 41 | .yarn-integrity 42 | 43 | # dotenv environment variable files 44 | .env 45 | .env.development.local 46 | .env.test.local 47 | .env.production.local 48 | .env.local 49 | 50 | # parcel-bundler cache (https://parceljs.org/) 51 | .cache 52 | .parcel-cache 53 | 54 | # FuseBox cache 55 | .fusebox/ 56 | 57 | # DynamoDB Local files 58 | .dynamodb/ 59 | 60 | # TernJS port file 61 | .tern-port 62 | 63 | # Stores VSCode versions used for testing VSCode extensions 64 | .vscode-test 65 | 66 | # yarn v2 67 | .yarn/cache 68 | .yarn/unplugged 69 | .yarn/build-state.yml 70 | .yarn/install-state.gz 71 | .pnp.* 72 | 73 | 74 | dist/ 75 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | dist 3 | coverage 4 | pnpm-lock.yaml 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config & { experimentalTernaries: true }} */ 2 | export default { 3 | proseWrap: "preserve", 4 | plugins: ["prettier-plugin-astro"], 5 | overrides: [ 6 | { 7 | files: "*.astro", 8 | options: { 9 | parser: "astro", 10 | }, 11 | }, 12 | ], 13 | experimentalTernaries: true, 14 | }; 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.documentSelectors": ["**/*.astro"], 3 | "[astro]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "languageToolLinter.languageTool.ignoredWordsInWorkspace": ["astro", "vite"], 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact", 10 | "astro", // Enable .astro 11 | "typescript", // Enable .ts 12 | "typescriptreact" // Enable .tsx 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @jasikpark/astro-svg-loader 2 | 3 | ## 0.3.1 4 | 5 | ### Patch Changes 6 | 7 | - 83f32ce: Remove extraneous dependency 8 | 9 | ## 0.3.0 10 | 11 | ### Minor Changes 12 | 13 | - 523a49e: Permit Astro 4.0 as a peer dep 14 | 15 | ## 0.2.0 16 | 17 | ### Minor Changes 18 | 19 | - f755968: Update to allow Astro v3 as a valid peer dep 20 | 21 | ### Patch Changes 22 | 23 | - afa45a0: Start publishing with provenance (https://docs.npmjs.com/generating-provenance-statements) 24 | 25 | ## 0.1.0 26 | 27 | ### Minor Changes 28 | 29 | - eb2f29a: Move to supporting astro v2 instead of astro v1 30 | 31 | ### Patch Changes 32 | 33 | - 57e5f36: Error on `alt` prop passed to SVG; `aria-hidden` or `aria-label` 34 | should be used instead. 35 | 36 | ## 0.0.2 37 | 38 | ### Patch Changes 39 | 40 | - 99b36fe: Update README installation instructions 41 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | calebjasik@jasik.xyz. All complaints will be reviewed and investigated promptly 64 | and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Caleb Jasik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@jasikpark/astro-svg-loader` 2 | 3 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fjasikpark%2Fastro-svg-loader.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fjasikpark%2Fastro-svg-loader?ref=badge_shield) [![Tested with fast-check](https://img.shields.io/badge/tested%20with-fast%E2%80%91check%20%F0%9F%90%92-%23282ea9?flat&logoSize=auto&labelColor=%231b1b1d)](https://fast-check.dev/) 4 | 5 | This package allows you to import SVGs as Astro components, using 6 | [@natemoo-re](https://github.com/natemoo-re)'s `ultrahtml` and vite's support 7 | for 8 | [raw imports](https://vitejs.dev/guide/assets.html#importing-asset-as-string) to 9 | do the trick. 10 | 11 | Taking inspiration from 12 | that holds that icons should always be inlined. 13 | 14 | Usage: 15 | 16 | Peer deps are `astro` 17 | 18 | ``` 19 | npm add -D @jasikpark/astro-svg-loader 20 | ``` 21 | 22 | ``` 23 | yarn add -D @jasikpark/astro-svg-loader 24 | ``` 25 | 26 | ``` 27 | pnpm add -D @jasikpark/astro-svg-loader 28 | ``` 29 | 30 | then in an Astro component: 31 | 32 | ```astro 33 | --- 34 | import Svg from "@jasikpark/astro-svg-loader"; 35 | --- 36 | 37 | 41 | ``` 42 | 43 | Be sure to use a raw import: `?raw`, or you'll import the URL rather than the 44 | SVG source. 45 | 46 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/jasikpark/astro-svg-loader/) 47 | 48 | ## License 49 | 50 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fjasikpark%2Fastro-svg-loader.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fjasikpark%2Fastro-svg-loader?ref=badge_large) 51 | 52 | ## Inspiration 53 | 54 | - [astro-icon](https://github.com/natemoo-re/astro-icon) 55 | - [svgr](https://react-svgr.com/) 56 | -------------------------------------------------------------------------------- /astro.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | 3 | export default defineConfig({ 4 | vite: { 5 | test: { 6 | sequence: { 7 | shuffle: true, 8 | }, 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /eslint-plugin-import.d.ts: -------------------------------------------------------------------------------- 1 | declare module "eslint-plugin-import"; 2 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from "@eslint/js"; 4 | import eslintPluginAstro from "eslint-plugin-astro"; 5 | import tseslint from "typescript-eslint"; 6 | import { flatConfigs as importFlatConfigs } from "eslint-plugin-import"; 7 | 8 | export default tseslint.config( 9 | { 10 | ignores: ["dist", ".astro/"], 11 | }, 12 | eslint.configs.recommended, 13 | ...tseslint.configs.strictTypeChecked, 14 | ...tseslint.configs.stylisticTypeChecked, 15 | { 16 | languageOptions: { 17 | parserOptions: { 18 | projectService: true, 19 | // @ts-expect-error -- dirname is missing on the type :shrug: 20 | tsconfigRootDir: import.meta.dirname, 21 | }, 22 | }, 23 | rules: { 24 | "@typescript-eslint/consistent-type-definitions": ["error", "type"], 25 | }, 26 | }, 27 | importFlatConfigs.recommended, 28 | importFlatConfigs.typescript, 29 | { 30 | settings: { 31 | "import/resolver": { 32 | typescript: true, 33 | }, 34 | }, 35 | }, 36 | ...eslintPluginAstro.configs["jsx-a11y-recommended"], 37 | ); 38 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | // Do not write code directly here, instead use the `src` folder! 2 | // Then, use this file to export everything you want your user to access. 3 | 4 | // TS has issues validating that Astro components exist when imported. 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore 7 | import Svg from "./src/components/Svg/Svg.astro"; 8 | 9 | export default Svg; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jasikpark/astro-svg-loader", 3 | "repository": { 4 | "url": "https://github.com/jasikpark/astro-svg-loader" 5 | }, 6 | "license": "MIT", 7 | "version": "0.3.1", 8 | "type": "module", 9 | "exports": { 10 | ".": "./index.ts" 11 | }, 12 | "files": [ 13 | "src", 14 | "index.ts" 15 | ], 16 | "keywords": [ 17 | "astro-component", 18 | "svg", 19 | "svg-loader" 20 | ], 21 | "scripts": { 22 | "preinstall": "npx only-allow pnpm", 23 | "dev": "astro dev", 24 | "build": "astro build", 25 | "preview": "astro preview", 26 | "format": "prettier -w .", 27 | "format:check": "prettier -c .", 28 | "test": "vitest --run", 29 | "test:watch": "vitest", 30 | "test:watch:ui": "vitest --ui", 31 | "lint": "eslint", 32 | "check:astro": "astro check", 33 | "check:ts": "tsc -p .", 34 | "check": "pnpm run '/^check:.*/'", 35 | "version": "changeset version && pnpm install --no-frozen-lockfile && pnpm format", 36 | "changeset": "changeset" 37 | }, 38 | "devDependencies": { 39 | "@astrojs/check": "0.9.4", 40 | "@changesets/cli": "2.29.1", 41 | "@eslint/js": "9.24.0", 42 | "@ianvs/prettier-plugin-sort-imports": "4.4.1", 43 | "@types/eslint__js": "8.42.3", 44 | "@typescript-eslint/parser": "8.30.1", 45 | "@vitest/ui": "3.1.2", 46 | "astro": "4.16.18", 47 | "eslint": "8.57.1", 48 | "eslint-plugin-astro": "1.3.1", 49 | "eslint-plugin-import": "2.31.0", 50 | "eslint-plugin-jsx-a11y": "6.10.2", 51 | "fast-check": "4.1.1", 52 | "prettier": "3.5.3", 53 | "prettier-plugin-astro": "0.14.1", 54 | "shiki": "2.5.0", 55 | "typescript": "5.8.3", 56 | "typescript-eslint": "8.30.1", 57 | "vitest": "3.1.2" 58 | }, 59 | "peerDependencies": { 60 | "astro": "^2.0.0 || ^3.0.0 || ^4.0.0" 61 | }, 62 | "engines": { 63 | "node": ">18", 64 | "pnpm": ">=7.9.5" 65 | }, 66 | "packageManager": "pnpm@10.8.1", 67 | "dependencies": { 68 | "ultrahtml": "1.5.3" 69 | }, 70 | "publishConfig": { 71 | "provenance": true 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/assets/astro-full-logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/Story.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Code } from "astro/components"; 3 | 4 | type Props = { 5 | title: string; 6 | /** Provide a code example for the story */ 7 | source: string; 8 | }; 9 | 10 | const { title, source } = Astro.props as Props; 11 | --- 12 | 13 |
14 |

15 | {title}: 16 |

17 | 18 |
19 | {source ? : null} 20 |
21 | 22 |
23 | 24 |
25 |
26 | 27 | 38 | -------------------------------------------------------------------------------- /src/components/Svg/Svg.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { SVGAttributes } from "../../types"; 3 | import { overrideSvgAttributes } from "./overrideSvgAttributes"; 4 | 5 | // accepts SVG attributes which will override the ones in the original SVG 6 | export interface Props extends SVGAttributes { 7 | /** pass an `import("*.svg?raw")` to `Svg` for the svg file to use */ 8 | src: Promise; 9 | } 10 | 11 | const { src, ...attributeOverrides } = Astro.props as Props; 12 | 13 | const SVG_NOT_FOUND = "Could not find an SVG at the provided `src`"; 14 | const ALT_NOT_ALLOWED = 15 | "`alt` is not a valid prop for svg, perhaps you mean `aria-label` or `aria-hidden`?"; 16 | 17 | const svgImport = await src; 18 | 19 | if (!svgImport) { 20 | throw new Error(SVG_NOT_FOUND); 21 | } 22 | 23 | const svgSource = svgImport.default; 24 | 25 | if (!svgSource) { 26 | throw new Error(SVG_NOT_FOUND); 27 | } 28 | 29 | if ("alt" in attributeOverrides) { 30 | throw new Error(ALT_NOT_ALLOWED); 31 | } 32 | 33 | if (!svgSource.trimStart().toLowerCase().startsWith(" 42 | -------------------------------------------------------------------------------- /src/components/Svg/overrideSvgAttributes.bench.ts: -------------------------------------------------------------------------------- 1 | import type { SVGAttributes } from "../../types.js"; 2 | import { overrideSvgAttributes } from "./overrideSvgAttributes.js"; 3 | import fc from "fast-check"; 4 | import { parse } from "ultrahtml"; 5 | import { bench, describe, expect } from "vitest"; 6 | 7 | const svgArbitrary = fc 8 | .record({ 9 | start: fc.mixedCase(fc.constantFrom("")), 10 | middle: fc.string({ unit: "grapheme-composite" }), 11 | end: fc.mixedCase(fc.constantFrom("")), 12 | }) 13 | .map(({ start, middle, end }) => `${start}${middle}${end}`); 14 | 15 | const SVGAttributesArbitrary = fc.record({ 16 | height: fc.option(fc.integer()), 17 | width: fc.option(fc.integer()), 18 | "aria-hidden": fc.option(fc.boolean()), 19 | "aria-label": fc.option(fc.string()), 20 | viewBox: fc.option(fc.string()), 21 | fill: fc.option(fc.string()), 22 | xmlns: fc.option(fc.string()), 23 | }); 24 | 25 | describe("overrideSvgAtrributes", () => { 26 | bench("should return the same output if no overrides are given", async () => { 27 | expect(await overrideSvgAttributes("")).toBe(""); 28 | expect(await overrideSvgAttributes("")).toBe(""); 29 | expect(await overrideSvgAttributes("")).toBe(""); 30 | }); 31 | 32 | bench("should strip leading and following whitespace", async () => { 33 | expect(await overrideSvgAttributes(" ")).toBe(""); 34 | expect(await overrideSvgAttributes(" ")).toBe(""); 35 | expect(await overrideSvgAttributes(" ")).toBe(""); 36 | }); 37 | 38 | bench("should override height and width", async () => { 39 | expect( 40 | await overrideSvgAttributes(``, { 41 | height: 400, 42 | width: 20, 43 | }), 44 | ).toBe(''); 45 | }); 46 | 47 | bench("should not include null and undefined properties", async () => { 48 | expect( 49 | await overrideSvgAttributes("", { 50 | height: null, 51 | width: undefined, 52 | }), 53 | ).toBe(""); 54 | expect( 55 | await overrideSvgAttributes( 56 | ``, 57 | { 58 | height: null, 59 | width: undefined, 60 | }, 61 | ), 62 | ).toBe(``); 63 | }); 64 | 65 | bench("should throw an error if `svgSource` is empty", async () => { 66 | await expect( 67 | async () => await overrideSvgAttributes(""), 68 | ).rejects.toThrowErrorMatchingInlineSnapshot( 69 | '"`svgSource` must have content"', 70 | ); 71 | }); 72 | 73 | bench( 74 | "should throw an error if svgSource doesn't start with ` { 76 | await expect( 77 | async () => await overrideSvgAttributes("
"), 78 | ).rejects.toThrowErrorMatchingInlineSnapshot( 79 | '"`svgSource` must begin with ` await overrideSvgAttributes("/images/www/hero.svg"), 83 | ).rejects.toThrowErrorMatchingInlineSnapshot( 84 | '"`svgSource` must begin with ` { 90 | expect( 91 | await overrideSvgAttributes("", { 92 | height: null, 93 | width: null, 94 | "aria-hidden": true, 95 | "aria-label": null, 96 | viewBox: "0 0 2712 894", 97 | }), 98 | ).toBe(''); 99 | 100 | await fc.assert( 101 | fc.asyncProperty( 102 | svgArbitrary, 103 | SVGAttributesArbitrary, 104 | fc.context(), 105 | async (svgSource, overrides, ctx) => { 106 | ctx.log(svgSource); 107 | const transformedSource = await overrideSvgAttributes( 108 | svgSource, 109 | overrides, 110 | ); 111 | ctx.log(transformedSource); 112 | expect(transformedSource).toBeTruthy(); 113 | // every truthy override should exist in the transformed source 114 | Object.entries(overrides) 115 | .filter(([, value]) => { 116 | ctx.log(`${value}, ${!!value}`); 117 | return !!value; 118 | }) 119 | .forEach(([override]) => { 120 | expect(transformedSource).toContain(override); 121 | }); 122 | }, 123 | ), 124 | ); 125 | }); 126 | }); 127 | 128 | describe("parse()", () => { 129 | bench("should never throw", async () => { 130 | await fc.assert( 131 | fc.asyncProperty( 132 | fc.string({ unit: "grapheme-composite" }), 133 | async (input) => { 134 | expect(await parse(input)).toBeTruthy(); 135 | }, 136 | ), 137 | ); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /src/components/Svg/overrideSvgAttributes.test.ts: -------------------------------------------------------------------------------- 1 | import type { SVGAttributes } from "../../types.js"; 2 | import { overrideSvgAttributes } from "./overrideSvgAttributes.js"; 3 | import fc from "fast-check"; 4 | import { parse } from "ultrahtml"; 5 | import { describe, expect, it } from "vitest"; 6 | 7 | const whitespaceArb = fc.string({ unit: fc.constant(" ") }); 8 | 9 | const svgArbitrary = fc 10 | .record({ 11 | start: fc 12 | .record({ 13 | preWhitespace: whitespaceArb, 14 | svg: fc.mixedCase(fc.constant("")), 15 | postWhitespace: whitespaceArb, 16 | }) 17 | .map( 18 | ({ preWhitespace, svg, postWhitespace }) => 19 | `${preWhitespace}${svg}${postWhitespace}`, 20 | ), 21 | middle: fc.string({ unit: "grapheme-composite" }), 22 | end: fc 23 | .record({ 24 | preWhitespace: whitespaceArb, 25 | svg: fc.mixedCase(fc.constant("")), 26 | postWhitespace: whitespaceArb, 27 | }) 28 | .map( 29 | ({ preWhitespace, svg, postWhitespace }) => 30 | `${preWhitespace}${svg}${postWhitespace}`, 31 | ), 32 | }) 33 | .map(({ start, middle, end }) => `${start}${middle}${end}`); 34 | 35 | const SVGAttributesArbitrary = fc.record({ 36 | height: fc.option(fc.integer()), 37 | width: fc.option(fc.integer()), 38 | "aria-hidden": fc.option(fc.boolean()), 39 | "aria-label": fc.option(fc.string()), 40 | viewBox: fc.option(fc.string()), 41 | fill: fc.option(fc.string()), 42 | xmlns: fc.option(fc.string()), 43 | }); 44 | 45 | describe("overrideSvgAtrributes", () => { 46 | it("should return the same output if no overrides are given", async () => { 47 | expect(await overrideSvgAttributes("")).toBe(""); 48 | expect(await overrideSvgAttributes("")).toBe(""); 49 | expect(await overrideSvgAttributes("")).toBe(""); 50 | }); 51 | 52 | it("should strip leading and following whitespace", async () => { 53 | expect(await overrideSvgAttributes(" ")).toBe(""); 54 | expect(await overrideSvgAttributes(" ")).toBe(""); 55 | expect(await overrideSvgAttributes(" ")).toBe(""); 56 | }); 57 | 58 | it("should override height and width", async () => { 59 | expect( 60 | await overrideSvgAttributes(``, { 61 | height: 400, 62 | width: 20, 63 | }), 64 | ).toBe(''); 65 | }); 66 | 67 | it("should not include null and undefined properties", async () => { 68 | expect( 69 | await overrideSvgAttributes("", { 70 | height: null, 71 | width: undefined, 72 | }), 73 | ).toBe(""); 74 | expect( 75 | await overrideSvgAttributes( 76 | ``, 77 | { 78 | height: null, 79 | width: undefined, 80 | }, 81 | ), 82 | ).toBe(``); 83 | }); 84 | 85 | it("should throw an error if `svgSource` is empty", async () => { 86 | await expect( 87 | async () => await overrideSvgAttributes(""), 88 | ).rejects.toThrowErrorMatchingInlineSnapshot( 89 | `[Error: \`svgSource\` must have content]`, 90 | ); 91 | }); 92 | 93 | it("should throw an error if svgSource doesn't start with ` { 94 | await expect( 95 | async () => await overrideSvgAttributes("
"), 96 | ).rejects.toThrowErrorMatchingInlineSnapshot( 97 | `[Error: \`svgSource\` must begin with \` await overrideSvgAttributes("/images/www/hero.svg"), 101 | ).rejects.toThrowErrorMatchingInlineSnapshot( 102 | `[Error: \`svgSource\` must begin with \` { 107 | expect( 108 | await overrideSvgAttributes("", { 109 | height: null, 110 | width: null, 111 | "aria-hidden": true, 112 | "aria-label": null, 113 | viewBox: "0 0 2712 894", 114 | }), 115 | ).toBe(''); 116 | 117 | await fc.assert( 118 | fc.asyncProperty( 119 | svgArbitrary, 120 | SVGAttributesArbitrary, 121 | fc.context(), 122 | async (svgSource, overrides, ctx) => { 123 | ctx.log(svgSource); 124 | const transformedSource = await overrideSvgAttributes( 125 | svgSource, 126 | overrides, 127 | ); 128 | ctx.log(transformedSource); 129 | expect(transformedSource).toBeTruthy(); 130 | // every truthy override should exist in the transformed source 131 | Object.entries(overrides) 132 | .filter(([, value]) => { 133 | ctx.log(`${value}, ${!!value}`); 134 | return !!value; 135 | }) 136 | .forEach(([override]) => { 137 | expect(transformedSource).toContain(override); 138 | }); 139 | }, 140 | ), 141 | ); 142 | }); 143 | }); 144 | 145 | describe("parse()", () => { 146 | it("should never throw", async () => { 147 | await fc.assert( 148 | fc.asyncProperty( 149 | fc.string({ unit: "grapheme-composite" }), 150 | async (input) => { 151 | expect(await parse(input)).toBeTruthy(); 152 | }, 153 | ), 154 | ); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/components/Svg/overrideSvgAttributes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { SVGAttributes } from "../../types"; 4 | import { type DocumentNode, type Node, parse, render } from "ultrahtml"; 5 | 6 | const EMPTY_STRING_ERR = "`svgSource` must have content"; 7 | const MUST_START_WITH_SVG = "`svgSource` must begin with ` { 13 | if (!svgSource) { 14 | throw new Error(EMPTY_STRING_ERR); 15 | } 16 | if (!svgSource.trimStart().toLowerCase().startsWith(" type === 1 && /svg/i.test(name), 24 | ); 25 | 26 | const mergedAttributes = Object.fromEntries( 27 | Object.entries({ 28 | ...firstSVGNode.attributes, 29 | ...attributeOverrides, 30 | }).filter(([, value]) => !!value), 31 | ); 32 | 33 | const updatedSVG = { 34 | ...firstSVGNode, 35 | attributes: mergedAttributes, 36 | }; 37 | 38 | return render(updatedSVG); 39 | } 40 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @jasikpark/astro-svg-loader 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import README from "../../README.md"; 3 | import Story from "../components/Story.astro"; 4 | import Svg from "../components/Svg/Svg.astro"; 5 | import Layout from "../layouts/Layout.astro"; 6 | --- 7 | 8 | 9 |

Astro SVG Loader

10 | 11 |
14 | 15 |
16 | 17 | `} 28 | > 29 | 34 | 35 | 36 | 43 | Astro 44 | 50 | `} 51 | > 52 |
53 | Astro 54 | 60 |
61 |
62 | 63 | `} 75 | > 76 | 80 | 86 | 87 |
88 | 89 | 97 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type SVGAttributes = Omit< 2 | astroHTML.JSX.SVGAttributes, 3 | "client:list" | "set:text" | "set:html" | "is:raw" 4 | >; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "skipLibCheck": true, 6 | "baseUrl": "." 7 | } 8 | } 9 | --------------------------------------------------------------------------------