Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ yarn.lock
# Build outputs
dist/
build/
.svelte-kit/
*.tsbuildinfo

# Logs
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ SVG sources live in [`assets/`](./assets), organized by category — each framew
| | Package | Version | Links |
| ------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| <img src=".github/assets/react.svg" width="35" alt="React" /> | `@appica/icons-react` | [![npm](https://img.shields.io/npm/v/@appica/icons-react)](https://www.npmjs.com/package/@appica/icons-react) | [Docs](https://appica.dev/ui/icons) · [Source](packages/react) |
| **Svelte 5** | `@appica/icons-svelte` | [![npm](https://img.shields.io/npm/v/@appica/icons-svelte)](https://www.npmjs.com/package/@appica/icons-svelte) | [Docs](https://appica.dev/ui/icons) · [Source](packages/svelte) |

More variants are planned — webfont, Vue and more.

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"pack:react": "pnpm --filter @appica/icons-react build && pnpm --filter @appica/icons-react exec pnpm pack",
"release": "pnpm --filter @appica/icons-react publish"
"pack:svelte": "pnpm --filter @appica/icons-svelte build && pnpm --filter @appica/icons-svelte exec pnpm pack",
"release": "pnpm --filter @appica/icons-react publish",
"release:svelte": "pnpm --filter @appica/icons-svelte publish"
},
"devDependencies": {
"prettier": "^3.9.4"
Expand Down
77 changes: 77 additions & 0 deletions packages/svelte/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"name": "@appica/icons-svelte",
"version": "1.0.0",
"description": "Tree-shakeable SVG icon components for Svelte 5 — use standalone or as part of the Appica UI.",
"license": "MIT",
"homepage": "https://appica.dev/ui/icons",
"author": {
"name": "Appica",
"url": "https://appica.dev"
},
"keywords": [
"icons",
"icon",
"svg",
"svelte",
"svelte5",
"typescript",
"ui-icons",
"appica",
"ui-components"
],
"type": "module",
"svelte": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": [
"**/*.css"
],
"engines": {
"node": ">=20.0.0"
},
"bugs": {
"url": "https://github.com/appica-dev/appica-icons/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/appica-dev/appica-icons.git",
"directory": "packages/svelte"
},
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"clean": "rm -rf dist src/icons",
"codegen": "tsx scripts/build.ts",
"build": "pnpm run codegen && svelte-package --input src",
"typecheck": "svelte-check --tsconfig ./tsconfig.check.json",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "pnpm run clean && pnpm run build && pnpm run test"
},
"devDependencies": {
"@sveltejs/package": "^2.5.8",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.2.8",
"@types/node": "^26.1.0",
"jsdom": "^29.1.1",
"svelte": "^5.56.10",
"svelte-check": "^4.7.6",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"vitest": "^4.1.10"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}
73 changes: 73 additions & 0 deletions packages/svelte/scripts/__tests__/extractName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import { extractName, toPascalCase } from '../extractName'

describe('extractName', () => {
it('extracts a single-segment name', () => {
expect(extractName('dashboard.svg')).toEqual({
name: 'dashboard',
componentName: 'Dashboard',
aliasName: 'DashboardIcon',
})
})

it('PascalCases multi-segment kebab names', () => {
expect(extractName('arrow-up.svg')).toMatchObject({
name: 'arrow-up',
componentName: 'ArrowUp',
aliasName: 'ArrowUpIcon',
})
expect(extractName('arrow-autofit-content-filled.svg')).toMatchObject({
componentName: 'ArrowAutofitContentFilled',
})
})

it('allows trailing digits in segments', () => {
expect(extractName('trash-2.svg')).toMatchObject({
componentName: 'Trash2',
aliasName: 'Trash2Icon',
})
expect(extractName('h2.svg')).toMatchObject({ componentName: 'H2' })
})

it('rejects filenames without the .svg extension', () => {
expect(() => extractName('dashboard.png')).toThrow(/Expected an .svg/)
})

it('rejects names starting with a digit (invalid JS identifier)', () => {
expect(() => extractName('2arrow.svg')).toThrow(/digit/)
})

it('rejects names containing invalid characters', () => {
expect(() => extractName('Bad_Name.svg')).toThrow(/Invalid icon filename/)
expect(() => extractName('arrow up.svg')).toThrow(/Invalid icon filename/)
expect(() => extractName('-arrow.svg')).toThrow(/Invalid icon filename/)
expect(() => extractName('arrow-.svg')).toThrow(/Invalid icon filename/)
})

it('rejects empty names', () => {
expect(() => extractName('.svg')).toThrow()
})
})

describe('toPascalCase', () => {
it('handles single words', () => {
expect(toPascalCase('star')).toBe('Star')
})

it('handles multi-segment kebab', () => {
expect(toPascalCase('arrow-autofit-down')).toBe('ArrowAutofitDown')
})

it('preserves digits within segments', () => {
expect(toPascalCase('h2')).toBe('H2')
expect(toPascalCase('trash-2')).toBe('Trash2')
})

it('disambiguates adjacent digit-only segments with an underscore', () => {
expect(toPascalCase('ironing-1-1')).toBe('Ironing1_1')
expect(toPascalCase('ironing-11')).toBe('Ironing11')
expect(toPascalCase('a-1-2-3')).toBe('A1_2_3')
expect(toPascalCase('a-1-b')).toBe('A1B')
expect(toPascalCase('a-b-1')).toBe('AB1')
})
})
144 changes: 144 additions & 0 deletions packages/svelte/scripts/__tests__/transformSvg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import { transformSvg } from '../transformSvg'

const wrap = (inner: string, attrs = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"') =>
`<svg ${attrs}>${inner}</svg>`

describe('transformSvg', () => {
it('extracts inner content and returns the viewBox', () => {
const result = transformSvg(wrap('<path d="M0 0h24v24H0z" fill="currentColor"/>'), 'sample')
expect(result.viewBox).toBe('0 0 24 24')
expect(result.innerSvg).toBe('<path d="M0 0h24v24H0z" fill="currentColor"/>')
})

it('lifts presentation attrs from the source <svg> root', () => {
const result = transformSvg(
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" viewBox="0 0 24 24"><path d="M0 0"/></svg>`,
'cat',
)
expect(result.rootAttrs).toEqual({
fill: 'none',
stroke: 'currentColor',
strokeWidth: '1.5',
strokeLinecap: 'round',
strokeLinejoin: 'round',
})
})

it('returns an empty rootAttrs object when the source has no relevant root attrs', () => {
const result = transformSvg(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0" fill="currentColor"/></svg>`,
'sample',
)
expect(result.rootAttrs).toEqual({})
})

it('strips XML prolog, doctype and comments', () => {
const src = `<?xml version="1.0"?>\n<!DOCTYPE svg>\n<!-- a comment -->\n${wrap(
'<path d="M0 0" fill="none"/>',
)}`
const result = transformSvg(src, 'sample')
expect(result.innerSvg).toBe('<path d="M0 0" fill="none"/>')
})

it('throws when the source has no viewBox', () => {
expect(() =>
transformSvg('<svg xmlns="http://www.w3.org/2000/svg"><path/></svg>', 'sample'),
).toThrow(/viewBox/)
})

it('throws when the source is not a single <svg> document', () => {
expect(() => transformSvg('<div></div>', 'sample')).toThrow(/well-formed/)
})

it('preserves alternate viewBoxes', () => {
const result = transformSvg(
wrap('<path fill="currentColor" d="M0 0"/>', 'viewBox="0 0 32 32"'),
'sample',
)
expect(result.viewBox).toBe('0 0 32 32')
})

it('rejects hex fill colors on visible elements by default', () => {
expect(() => transformSvg(wrap('<path fill="#101820" d="M0 0"/>'), 'sample')).toThrow(
/Disallowed color/,
)
})

it('rejects named fill colors (e.g. red) by default', () => {
expect(() => transformSvg(wrap('<path fill="red" d="M0 0"/>'), 'sample')).toThrow(
/Disallowed color/,
)
})

it('allows fill="currentColor", "none", "transparent", "inherit"', () => {
expect(() =>
transformSvg(
wrap('<path fill="currentColor" d="M0 0"/><path fill="none" stroke="currentColor"/>'),
'sample',
),
).not.toThrow()
})

it('ignores fill colors that live inside <clipPath> content (geometry-only)', () => {
const inner =
'<g clip-path="url(#a)"><path fill="currentColor" d="M0 0"/></g>' +
'<defs><clipPath id="a"><path fill="#fff" d="M0 0h24v24H0z"/></clipPath></defs>'
const result = transformSvg(wrap(inner), 'gamepad')
expect(result.innerSvg).toContain('clip-path="url(#appica-gamepad-a)"')
expect(result.innerSvg).toContain('id="appica-gamepad-a"')
})

it('ignores fill colors that live inside <mask> content', () => {
const inner =
'<g mask="url(#m)"><path fill="currentColor" d="M0 0"/></g>' +
'<defs><mask id="m"><path fill="white" d="M0 0h24v24H0z"/></mask></defs>'
expect(() => transformSvg(wrap(inner), 'sample')).not.toThrow()
})

it('honours the data-allow-color opt-out on the <svg> root', () => {
const result = transformSvg(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" data-allow-color="true"><path fill="#101820" d="M0 0"/></svg>`,
'multi',
)
expect(result.innerSvg).toContain('fill="#101820"')
})

it('rewrites ids and url(#…) references to a per-icon prefix', () => {
const inner =
'<g><path fill="url(#g1)" d="M0 0"/><circle fill="currentColor"/></g>' +
'<defs><linearGradient id="g1"><stop/></linearGradient></defs>'
const result = transformSvg(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" data-allow-color>${inner}</svg>`,
'star',
)
expect(result.innerSvg).toContain('id="appica-star-g1"')
expect(result.innerSvg).toContain('url(#appica-star-g1)')
expect(result.innerSvg).not.toMatch(/id="g1"/)
expect(result.innerSvg).not.toMatch(/url\(#g1\)/)
})

it('rewrites href="#…" and xlink:href="#…" id references', () => {
const inner =
'<use href="#shape"/><use xlink:href="#shape"/>' +
'<defs><path id="shape" d="M0 0" fill="currentColor"/></defs>'
const result = transformSvg(wrap(inner), 'reuse')
expect(result.innerSvg).toContain('href="#appica-reuse-shape"')
expect(result.innerSvg).toContain('xlink:href="#appica-reuse-shape"')
expect(result.innerSvg).toContain('id="appica-reuse-shape"')
})

it('collapses runs of whitespace between elements', () => {
const inner = `\n <path fill="currentColor" d="M0 0"/>\n <path fill="currentColor" d="M1 1"/>\n`
const result = transformSvg(wrap(inner), 'sample')
expect(result.innerSvg).toBe(
'<path fill="currentColor" d="M0 0"/><path fill="currentColor" d="M1 1"/>',
)
})

it('throws when the inner content is empty', () => {
expect(() =>
transformSvg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>`, 'sample'),
).toThrow(/inner content/)
})
})
Loading