feature/font-fetching-again #16
562
.gitea/README.md
562
.gitea/README.md
@@ -1,562 +0,0 @@
|
||||
# Gitea Actions CI/CD Setup
|
||||
|
||||
This document describes the CI/CD pipeline configuration for the GlyphDiff project using Gitea Actions (GitHub Actions compatible).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Workflow Files](#workflow-files)
|
||||
- [Workflow Triggers](#workflow-triggers)
|
||||
- [Setup Instructions](#setup-instructions)
|
||||
- [Self-Hosted Runner Setup](#self-hosted-runner-setup)
|
||||
- [Caching Strategy](#caching-strategy)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Overview
|
||||
|
||||
The CI/CD pipeline consists of four main workflows:
|
||||
|
||||
1. **Lint** - Code quality checks (oxlint, dprint formatting)
|
||||
2. **Test** - Type checking and E2E tests (Playwright)
|
||||
3. **Build** - Production build verification
|
||||
4. **Deploy** - Deployment automation (optional/template)
|
||||
|
||||
All workflows are designed to run on both push and pull request events, with appropriate branch filtering and concurrency controls.
|
||||
|
||||
## Workflow Files
|
||||
|
||||
### `.gitea/workflows/lint.yml`
|
||||
|
||||
**Purpose**: Run code quality checks to ensure code style and formatting standards.
|
||||
|
||||
**Checks performed**:
|
||||
|
||||
- `oxlint` - Fast JavaScript/TypeScript linter
|
||||
- `dprint check` - Code formatting verification
|
||||
|
||||
**Triggers**:
|
||||
|
||||
- Push to `main`, `develop`, `feature/*` branches
|
||||
- Pull requests to `main` or `develop`
|
||||
- Manual workflow dispatch
|
||||
|
||||
**Cache**: Node modules and Yarn cache
|
||||
|
||||
**Concurrency**: Cancels in-progress runs for the same branch when a new commit is pushed.
|
||||
|
||||
---
|
||||
|
||||
### `.gitea/workflows/test.yml`
|
||||
|
||||
**Purpose**: Run type checking and end-to-end tests.
|
||||
|
||||
**Jobs**:
|
||||
|
||||
#### 1. `type-check` job
|
||||
|
||||
- `tsc --noEmit` - TypeScript type checking
|
||||
- `svelte-check --threshold warning` - Svelte component type checking
|
||||
|
||||
#### 2. `e2e-tests` job
|
||||
|
||||
- Installs Playwright browsers with system dependencies
|
||||
- Runs E2E tests using Playwright
|
||||
- Uploads test report artifacts (retained for 7 days)
|
||||
- Uploads screenshots on test failure for debugging
|
||||
|
||||
**Triggers**: Same as lint workflow
|
||||
|
||||
**Cache**: Node modules and Yarn cache
|
||||
|
||||
**Artifacts**:
|
||||
|
||||
- `playwright-report` - Test execution report
|
||||
- `playwright-screenshots` - Screenshots from failed tests
|
||||
|
||||
---
|
||||
|
||||
### `.gitea/workflows/build.yml`
|
||||
|
||||
**Purpose**: Verify that the production build completes successfully.
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. Checkout repository
|
||||
2. Setup Node.js v20 with Yarn caching
|
||||
3. Install dependencies with `--frozen-lockfile`
|
||||
4. Run `svelte-kit sync` to prepare SvelteKit
|
||||
5. Build the project with `NODE_ENV=production`
|
||||
6. Upload build artifacts (`.svelte-kit/output`, `.svelte-kit/build`)
|
||||
7. Run the preview server and verify it responds (health check)
|
||||
|
||||
**Triggers**:
|
||||
|
||||
- Push to `main` or `develop` branches
|
||||
- Pull requests to `main` or `develop`
|
||||
- Manual workflow dispatch
|
||||
|
||||
**Cache**: Node modules and Yarn cache
|
||||
|
||||
**Artifacts**:
|
||||
|
||||
- `build-artifacts` - Compiled SvelteKit output (retained for 7 days)
|
||||
|
||||
---
|
||||
|
||||
### `.gitea/workflows/deploy.yml`
|
||||
|
||||
**Purpose**: Automated deployment pipeline (template configuration).
|
||||
|
||||
**Current state**: Placeholder configuration. Uncomment and customize one of the deployment examples.
|
||||
|
||||
**Pre-deployment checks**:
|
||||
|
||||
- Must pass linting workflow
|
||||
- Must pass testing workflow
|
||||
- Must pass build workflow
|
||||
|
||||
**Deployment examples included**:
|
||||
|
||||
1. **Docker container registry** - Build and push Docker image
|
||||
2. **SSH deployment** - Deploy to server via SSH
|
||||
3. **Vercel** - Deploy to Vercel platform
|
||||
|
||||
**Triggers**:
|
||||
|
||||
- Push to `main` branch
|
||||
- Manual workflow dispatch with environment selection (staging/production)
|
||||
|
||||
**Secrets required** (configure in Gitea):
|
||||
|
||||
- For Docker: `REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`
|
||||
- For SSH: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`
|
||||
- For Vercel: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`
|
||||
|
||||
## Workflow Triggers
|
||||
|
||||
### Branch-Specific Behavior
|
||||
|
||||
| Workflow | Push Triggers | PR Triggers | Runs on Merge |
|
||||
| -------- | ------------------------------ | -------------------- | ------------- |
|
||||
| Lint | `main`, `develop`, `feature/*` | To `main`, `develop` | Yes |
|
||||
| Test | `main`, `develop`, `feature/*` | To `main`, `develop` | Yes |
|
||||
| Build | `main`, `develop` | To `main`, `develop` | Yes |
|
||||
| Deploy | `main` only | None | Yes |
|
||||
|
||||
### Concurrency Strategy
|
||||
|
||||
All workflows use concurrency groups based on the workflow name and branch reference:
|
||||
|
||||
```yaml
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true # or false for deploy workflow
|
||||
```
|
||||
|
||||
This ensures:
|
||||
|
||||
- For lint/test/build: New commits cancel in-progress runs (saves resources)
|
||||
- For deploy: Prevents concurrent deployments (ensures safety)
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Step 1: Verify Gitea Actions is Enabled
|
||||
|
||||
1. Navigate to your Gitea instance
|
||||
2. Go to **Site Administration** → **Actions**
|
||||
3. Ensure Actions is enabled
|
||||
4. Configure default runner settings if needed
|
||||
|
||||
### Step 2: Configure Repository Settings
|
||||
|
||||
1. Go to your repository in Gitea
|
||||
2. Click **Settings** → **Actions**
|
||||
3. Enable Actions for the repository if not already enabled
|
||||
4. Set appropriate permissions for read/write access
|
||||
|
||||
### Step 3: Push Workflows to Repository
|
||||
|
||||
The workflow files are already in `.gitea/workflows/`. Commit and push them:
|
||||
|
||||
```bash
|
||||
git add .gitea/workflows/
|
||||
git commit -m "Add Gitea Actions CI/CD workflows"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Step 4: Verify Workflows Run
|
||||
|
||||
1. Navigate to **Actions** tab in your repository
|
||||
2. You should see the workflows trigger on the next push
|
||||
3. Click into a workflow run to view logs and status
|
||||
|
||||
### Step 5: Configure Secrets (Optional - for deployment)
|
||||
|
||||
1. Go to repository **Settings** → **Secrets** → **Actions**
|
||||
2. Click **Add New Secret**
|
||||
3. Add secrets required for your deployment method
|
||||
|
||||
Example secrets for SSH deployment:
|
||||
|
||||
```
|
||||
DEPLOY_HOST=your-server.com
|
||||
DEPLOY_USER=deploy
|
||||
DEPLOY_SSH_KEY=-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
...
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
```
|
||||
|
||||
## Self-Hosted Runner Setup
|
||||
|
||||
### Option 1: Using Gitea's Built-in Act Runner (Recommended)
|
||||
|
||||
Gitea provides `act_runner` (compatible with GitHub Actions runner).
|
||||
|
||||
#### Install act_runner
|
||||
|
||||
On Linux (Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
wget -O /usr/local/bin/act_runner https://gitea.com/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
|
||||
chmod +x /usr/local/bin/act_runner
|
||||
```
|
||||
|
||||
Verify installation:
|
||||
|
||||
```bash
|
||||
act_runner --version
|
||||
```
|
||||
|
||||
#### Register the Runner
|
||||
|
||||
1. In Gitea, navigate to repository **Settings** → **Actions** → **Runners**
|
||||
2. Click **New Runner**
|
||||
3. Copy the registration token
|
||||
4. Run the registration command:
|
||||
|
||||
```bash
|
||||
act_runner register \
|
||||
--instance https://your-gitea-instance.com \
|
||||
--token YOUR_REGISTRATION_TOKEN \
|
||||
--name "linux-runner-1" \
|
||||
--labels ubuntu-latest,linux,docker \
|
||||
--no-interactive
|
||||
```
|
||||
|
||||
#### Start the Runner as a Service
|
||||
|
||||
Create a systemd service file at `/etc/systemd/system/gitea-runner.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=git
|
||||
WorkingDirectory=/var/lib/gitea-runner
|
||||
ExecStart=/usr/local/bin/act_runner daemon
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable gitea-runner
|
||||
sudo systemctl start gitea-runner
|
||||
```
|
||||
|
||||
#### Check Runner Status
|
||||
|
||||
```bash
|
||||
sudo systemctl status gitea-runner
|
||||
```
|
||||
|
||||
Verify in Gitea: The runner should appear as **Online** with the `ubuntu-latest` label.
|
||||
|
||||
### Option 2: Using Self-Hosted Runners with Docker
|
||||
|
||||
If you prefer Docker-based execution:
|
||||
|
||||
#### Install Docker
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
#### Configure Runner to Use Docker
|
||||
|
||||
Ensure the runner has access to the Docker socket:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker act_runner_user
|
||||
```
|
||||
|
||||
The workflows will now run containers inside the runner's Docker environment.
|
||||
|
||||
### Option 3: Using External Runners (GitHub Actions Runner Compatible)
|
||||
|
||||
If you want to use standard GitHub Actions runners:
|
||||
|
||||
```bash
|
||||
# Download and configure GitHub Actions runner
|
||||
mkdir actions-runner && cd actions-runner
|
||||
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
|
||||
tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz
|
||||
|
||||
# Configure to point to Gitea instance
|
||||
./config.sh --url https://your-gitea-instance.com --token YOUR_TOKEN
|
||||
```
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
### Node.js and Yarn Cache
|
||||
|
||||
All workflows use `actions/setup-node@v4` with built-in caching:
|
||||
|
||||
```yaml
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
```
|
||||
|
||||
This caches:
|
||||
|
||||
- `node_modules` directory
|
||||
- Yarn cache directory (`~/.yarn/cache`)
|
||||
- Reduces installation time from minutes to seconds on subsequent runs
|
||||
|
||||
### Playwright Cache
|
||||
|
||||
Playwright browsers are installed fresh each time. To cache Playwright (optional optimization):
|
||||
|
||||
```yaml
|
||||
- name: Cache Playwright binaries
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Default Environment Variables
|
||||
|
||||
The workflows use the following environment variables:
|
||||
|
||||
```bash
|
||||
NODE_ENV=production # For build workflow
|
||||
NODE_VERSION=20 # Node.js version used across all workflows
|
||||
```
|
||||
|
||||
### Custom Environment Variables
|
||||
|
||||
To add custom environment variables:
|
||||
|
||||
1. Go to repository **Settings** → **Variables** → **Actions**
|
||||
2. Click **Add New Variable**
|
||||
3. Add variable name and value
|
||||
4. Set scope (environment, repository, or organization)
|
||||
|
||||
Example for feature flags:
|
||||
|
||||
```
|
||||
ENABLE_ANALYTICS=false
|
||||
API_URL=https://api.example.com
|
||||
```
|
||||
|
||||
Access in workflow:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
API_URL: ${{ vars.API_URL }}
|
||||
ENABLE_ANALYTICS: ${{ vars.ENABLE_ANALYTICS }}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Workflows Not Running
|
||||
|
||||
**Symptoms**: Workflows don't appear or don't trigger
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify Actions is enabled in Gitea site administration
|
||||
2. Check repository Settings → Actions is enabled
|
||||
3. Verify workflow files are in `.gitea/workflows/` directory
|
||||
4. Check workflow YAML syntax (no indentation errors)
|
||||
|
||||
### Runner Offline
|
||||
|
||||
**Symptoms**: Runner shows as **Offline** or **Idle**
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check runner service status: `sudo systemctl status gitea-runner`
|
||||
2. Review runner logs: `journalctl -u gitea-runner -f`
|
||||
3. Verify network connectivity to Gitea instance
|
||||
4. Restart runner: `sudo systemctl restart gitea-runner`
|
||||
|
||||
### Linting Fails with Formatting Errors
|
||||
|
||||
**Symptoms**: `dprint check` fails on CI but passes locally
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Ensure dprint configuration (`dprint.json`) is committed
|
||||
2. Run `yarn dprint fmt` locally before committing
|
||||
3. Consider adding auto-fix workflow (see below)
|
||||
|
||||
### Playwright Tests Timeout
|
||||
|
||||
**Symptoms**: E2E tests fail with timeout errors
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check `playwright.config.ts` timeout settings
|
||||
2. Ensure preview server starts before tests run (built into config)
|
||||
3. Increase timeout in workflow:
|
||||
```yaml
|
||||
- name: Run Playwright tests
|
||||
run: yarn test:e2e
|
||||
env:
|
||||
PLAYWRIGHT_TIMEOUT: 60000
|
||||
```
|
||||
|
||||
### Build Fails with Out of Memory
|
||||
|
||||
**Symptoms**: Build fails with memory allocation errors
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Increase Node.js memory limit:
|
||||
```yaml
|
||||
- name: Build project
|
||||
run: yarn build
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
```
|
||||
2. Ensure runner has sufficient RAM (minimum 2GB recommended)
|
||||
|
||||
### Permission Denied on Runner
|
||||
|
||||
**Symptoms**: Runner can't access repository or secrets
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify runner has read access to repository
|
||||
2. Check secret names match exactly in workflow
|
||||
3. Ensure runner user has file system permissions
|
||||
|
||||
### Yarn Install Fails with Lockfile Conflict
|
||||
|
||||
**Symptoms**: `yarn install --frozen-lockfile` fails
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Ensure `yarn.lock` is up-to-date locally
|
||||
2. Run `yarn install` and commit updated `yarn.lock`
|
||||
3. Do not use `--frozen-lockfile` if using different platforms (arm64 vs amd64)
|
||||
|
||||
### Slow Workflow Execution
|
||||
|
||||
**Symptoms**: Workflows take too long to complete
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify caching is working (check logs for "Cache restored")
|
||||
2. Use `--frozen-lockfile` for faster dependency resolution
|
||||
3. Consider matrix strategy for parallel execution (not currently used)
|
||||
4. Optimize Playwright tests (reduce test count, increase timeouts only if needed)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Dependencies Updated
|
||||
|
||||
Regularly update action versions:
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@v4 # Update from v3 to v4 when available
|
||||
- uses: actions/setup-node@v4
|
||||
```
|
||||
|
||||
### 2. Use Frozen Lockfile
|
||||
|
||||
Always use `--frozen-lockfile` in CI to ensure reproducible builds:
|
||||
|
||||
```bash
|
||||
yarn install --frozen-lockfile
|
||||
```
|
||||
|
||||
### 3. Monitor Workflow Status
|
||||
|
||||
Set up notifications for workflow failures:
|
||||
|
||||
- Email notifications in Gitea user settings
|
||||
- Integrate with Slack/Mattermost for team alerts
|
||||
- Use status badges in README
|
||||
|
||||
### 4. Test Locally Before Pushing
|
||||
|
||||
Run the same checks locally:
|
||||
|
||||
```bash
|
||||
yarn lint # oxlint
|
||||
yarn dprint check # Formatting check
|
||||
yarn tsc --noEmit # Type check
|
||||
yarn test:e2e # E2E tests
|
||||
yarn build # Build
|
||||
```
|
||||
|
||||
### 5. Leverage Git Hooks
|
||||
|
||||
The project uses lefthook for pre-commit/pre-push checks. This catches issues before they reach CI:
|
||||
|
||||
```bash
|
||||
# Pre-commit: Format code, lint staged files
|
||||
# Pre-push: Full type check, format check, full lint
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Gitea Actions Documentation](https://docs.gitea.com/usage/actions/overview)
|
||||
- [Gitea act_runner Documentation](https://docs.gitea.com/usage/actions/act-runner)
|
||||
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
|
||||
- [SvelteKit Deployment Guide](https://kit.svelte.dev/docs/adapters)
|
||||
- [Playwright CI/CD Guide](https://playwright.dev/docs/ci)
|
||||
|
||||
## Status Badges
|
||||
|
||||
Add status badges to your README.md:
|
||||
|
||||
```markdown
|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Customize deployment**: Modify `deploy.yml` with your deployment strategy
|
||||
2. **Add notifications**: Set up workflow failure notifications
|
||||
3. **Optimize caching**: Add Playwright cache if needed
|
||||
4. **Add badges**: Include status badges in README
|
||||
5. **Schedule tasks**: Add periodic tests or dependency updates (optional)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: December 30, 2025
|
||||
**Version**: 1.0.0
|
||||
@@ -17,38 +17,21 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '25'
|
||||
# We handle caching manually below to ensure
|
||||
# corepack-managed yarn is used correctly.
|
||||
|
||||
- name: Enable Corepack
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare yarn@stable --activate
|
||||
|
||||
# - name: Get yarn cache directory path
|
||||
# id: yarn-cache-dir-path
|
||||
# run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
# - name: Persistent Yarn Cache
|
||||
# uses: actions/cache@v4
|
||||
# id: yarn-cache
|
||||
# with:
|
||||
# path: ${{ github.workspace }}/.yarn/cache
|
||||
# key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
# restore-keys: ${{ runner.os }}-yarn-
|
||||
#
|
||||
- name: Persistent Yarn Cache
|
||||
uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
# In Yarn Berry, the cache is local to the project
|
||||
path: .yarn/cache
|
||||
# Ensure a clean key without hidden newline characters
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install dependencies
|
||||
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build Svelte App
|
||||
|
||||
124
README.md
124
README.md
@@ -1,38 +1,120 @@
|
||||
# sv
|
||||
# GlyphDiff
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
A modern, high-performance font exploration tool for browsing and comparing fonts from Google Fonts and Fontshare.
|
||||
|
||||
## Creating a project
|
||||
## ✨ Features
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
- **Multi-Provider Support**: Access fonts from Google Fonts and Fontshare in one place
|
||||
- **Fast Virtual Scrolling**: Handles thousands of fonts smoothly with custom virtualization
|
||||
- **Advanced Filtering**: Filter by category, provider, and character subsets
|
||||
- **Responsive Design**: Beautiful UI built with shadcn components and Tailwind CSS
|
||||
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
- **Frontend**: Svelte 5 with runes (reactive primitives)
|
||||
- **Styling**: Tailwind CSS v4 + shadcn-svelte components
|
||||
- **Data Fetching**: TanStack Query for caching and state management
|
||||
- **Architecture**: Feature-Sliced Design (FSD) methodology
|
||||
- **Testing**: Playwright (E2E), Vitest (unit), Storybook (components)
|
||||
- **Quality**: Oxlint (linting), Dprint (formatting), Lefthook (git hooks)
|
||||
|
||||
## 📁 Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # App shell, layout, providers
|
||||
├── widgets/ # Composed UI blocks
|
||||
├── features/ # Business features (filters, search)
|
||||
├── entities/ # Domain entities (Font models, stores)
|
||||
├── shared/ # Reusable utilities, UI components, helpers
|
||||
└── routes/ # Page-level components
|
||||
```
|
||||
|
||||
## Developing
|
||||
## 🚀 Quick Start
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
```bash
|
||||
# Install dependencies
|
||||
yarn install
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
# Start development server
|
||||
yarn dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
# Open in browser
|
||||
yarn dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
## 📦 Available Scripts
|
||||
|
||||
To create a production version of your app:
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------- |
|
||||
| `yarn dev` | Start development server |
|
||||
| `yarn build` | Build for production |
|
||||
| `yarn preview` | Preview production build |
|
||||
| `yarn check` | Run Svelte type checking |
|
||||
| `yarn lint` | Run oxlint |
|
||||
| `yarn format` | Format with dprint |
|
||||
| `yarn test` | Run all tests (E2E + unit) |
|
||||
| `yarn test:e2e` | Run Playwright E2E tests |
|
||||
| `yarn test:unit` | Run Vitest unit tests |
|
||||
| `yarn storybook` | Start Storybook dev server |
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
## 🧪 Development
|
||||
|
||||
### Type Checking
|
||||
|
||||
```bash
|
||||
yarn check # Single run
|
||||
yarn check:watch # Watch mode
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
### Testing
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
```bash
|
||||
yarn test:unit # Unit tests
|
||||
yarn test:unit:watch # Watch mode
|
||||
yarn test:unit:ui # Vitest UI
|
||||
yarn test:e2e # E2E tests with Playwright
|
||||
yarn test:e2e --ui # Interactive test runner
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
yarn lint # Lint code
|
||||
yarn format # Format code
|
||||
yarn format:check # Check formatting
|
||||
```
|
||||
|
||||
## 🎯 Key Components
|
||||
|
||||
- **VirtualList**: Custom high-performance list virtualization using Svelte 5 runes
|
||||
- **FontList**: Displays fonts with loading, empty, and error states
|
||||
- **FilterControls**: Multi-filter system for category, provider, and subsets
|
||||
- **TypographyControl**: Dynamic typography adjustment controls
|
||||
|
||||
## 📝 Code Style
|
||||
|
||||
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/`
|
||||
- **Components**: PascalCase (e.g., `CheckboxFilter.svelte`)
|
||||
- **Formatting**: 100 char line width, 4-space indent, single quotes
|
||||
- **Imports**: Auto-sorted by dprint, separated by blank line
|
||||
- **Type Safety**: Strict TypeScript, JSDoc comments for public APIs
|
||||
|
||||
## 🏗️ Building for Production
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
yarn preview
|
||||
```
|
||||
|
||||
## 📚 Learn More
|
||||
|
||||
- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/docs)
|
||||
- [Feature-Sliced Design](https://feature-sliced.design)
|
||||
- [Tailwind CSS v4](https://tailwindcss.com/blog/tailwindcss-v4-alpha)
|
||||
- [shadcn-svelte](https://www.shadcn-svelte.com)
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^6.0.14",
|
||||
"@tanstack/svelte-virtual": "^3.13.17"
|
||||
"@tanstack/svelte-query": "^6.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
|
||||
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
|
||||
import {
|
||||
Content as ItemContent,
|
||||
Root as ItemRoot,
|
||||
@@ -13,28 +12,10 @@ import { VirtualList } from '$shared/ui';
|
||||
* Displays a virtualized list of fonts with loading, empty, and error states.
|
||||
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
|
||||
*/
|
||||
interface FontListProps {
|
||||
/** Font items to display (defaults to filtered fonts from store) */
|
||||
fonts?: UnifiedFont[];
|
||||
/** Show loading state */
|
||||
loading?: boolean;
|
||||
/** Show empty state when no results */
|
||||
showEmpty?: boolean;
|
||||
/** Custom error message to display */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
fonts,
|
||||
loading,
|
||||
showEmpty = true,
|
||||
errorMessage,
|
||||
}: FontListProps = $props();
|
||||
|
||||
// const fontshareStore = getFontshareContext();
|
||||
</script>
|
||||
|
||||
{#each fontshareStore.fonts as font (font.id)}
|
||||
<VirtualList items={fontshareStore.fonts} itemHeight={30}>
|
||||
{#snippet children({ item: font })}
|
||||
<ItemRoot>
|
||||
<ItemContent>
|
||||
<ItemTitle>{font.name}</ItemTitle>
|
||||
@@ -43,4 +24,5 @@ let {
|
||||
</span>
|
||||
</ItemContent>
|
||||
</ItemRoot>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
|
||||
@@ -24,4 +24,4 @@ import {
|
||||
</script>
|
||||
|
||||
<!-- Font List -->
|
||||
<FontList showEmpty={true} />
|
||||
<FontList />
|
||||
|
||||
@@ -1,15 +1,157 @@
|
||||
import {
|
||||
createVirtualizer as coreCreateVirtualizer,
|
||||
observeElementRect,
|
||||
} from '@tanstack/svelte-virtual';
|
||||
import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core';
|
||||
import { get } from 'svelte/store';
|
||||
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
||||
// Reactive State
|
||||
let scrollOffset = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
let measuredSizes = $state<Record<number, number>>({});
|
||||
|
||||
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
|
||||
let elementRef: HTMLElement | null = null;
|
||||
|
||||
// Reactive Options
|
||||
const options = $derived(optionsGetter());
|
||||
|
||||
// Optimized Memoization (The Cache Layer)
|
||||
// Only recalculates when item count or measured sizes change.
|
||||
const offsets = $derived.by(() => {
|
||||
const count = options.count;
|
||||
const result = Array.from<number>({ length: count });
|
||||
let accumulated = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
result[i] = accumulated;
|
||||
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const totalSize = $derived(
|
||||
options.count > 0
|
||||
? offsets[options.count - 1]
|
||||
+ (measuredSizes[options.count - 1] ?? options.estimateSize(options.count - 1))
|
||||
: 0,
|
||||
);
|
||||
|
||||
// Visible Range Calculation
|
||||
// Svelte tracks dependencies automatically here.
|
||||
const items = $derived.by((): VirtualItem[] => {
|
||||
const count = options.count;
|
||||
if (count === 0 || containerHeight === 0) return [];
|
||||
|
||||
const overscan = options.overscan ?? 5;
|
||||
const viewportStart = scrollOffset;
|
||||
const viewportEnd = scrollOffset + containerHeight;
|
||||
|
||||
// Find Start (Linear Scan)
|
||||
let startIdx = 0;
|
||||
while (startIdx < count && offsets[startIdx + 1] < viewportStart) {
|
||||
startIdx++;
|
||||
}
|
||||
|
||||
// Find End
|
||||
let endIdx = startIdx;
|
||||
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||
endIdx++;
|
||||
}
|
||||
|
||||
const start = Math.max(0, startIdx - overscan);
|
||||
const end = Math.min(count, endIdx + overscan);
|
||||
|
||||
const result: VirtualItem[] = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
const size = measuredSizes[i] ?? options.estimateSize(i);
|
||||
result.push({
|
||||
index: i,
|
||||
start: offsets[i],
|
||||
size,
|
||||
end: offsets[i] + size,
|
||||
key: options.getItemKey?.(i) ?? i,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Svelte Actions (The DOM Interface)
|
||||
function container(node: HTMLElement) {
|
||||
elementRef = node;
|
||||
containerHeight = node.offsetHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollOffset = node.scrollTop;
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
if (entry) containerHeight = entry.contentRect.height;
|
||||
});
|
||||
|
||||
node.addEventListener('scroll', handleScroll, { passive: true });
|
||||
resizeObserver.observe(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('scroll', handleScroll);
|
||||
resizeObserver.disconnect();
|
||||
elementRef = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function measureElement(node: HTMLElement) {
|
||||
// Use a ResizeObserver on individual items for dynamic height support
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
if (entry) {
|
||||
const index = parseInt(node.dataset.index || '', 10);
|
||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||
|
||||
// Only update if height actually changed to prevent loops
|
||||
if (!isNaN(index) && measuredSizes[index] !== height) {
|
||||
measuredSizes[index] = height;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(node);
|
||||
return {
|
||||
destroy: () => resizeObserver.disconnect(),
|
||||
};
|
||||
}
|
||||
|
||||
// Programmatic Scroll
|
||||
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
|
||||
if (!elementRef || index < 0 || index >= options.count) return;
|
||||
|
||||
const itemStart = offsets[index];
|
||||
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
||||
let target = itemStart;
|
||||
|
||||
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
||||
|
||||
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
return {
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
get totalSize() {
|
||||
return totalSize;
|
||||
},
|
||||
container,
|
||||
measureElement,
|
||||
scrollToIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VirtualItem {
|
||||
/** Index of the item in the data array */
|
||||
index: number;
|
||||
/** Offset from the top of the list */
|
||||
start: number;
|
||||
/** Height of the item */
|
||||
size: number;
|
||||
/** End position (start + size) */
|
||||
end: number;
|
||||
/** Unique key for the item (for Svelte's {#each} keying) */
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
@@ -26,91 +168,4 @@ export interface VirtualizerOptions {
|
||||
scrollMargin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reactive virtualizer using Svelte 5 runes and TanStack's core library.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const virtualizer = createVirtualizer(() => ({
|
||||
* count: items.length,
|
||||
* estimateSize: () => 80,
|
||||
* overscan: 5,
|
||||
* }));
|
||||
*
|
||||
* // In template:
|
||||
* // <div bind:this={virtualizer.scrollElement}>
|
||||
* // {#each virtualizer.items as item}
|
||||
* // <div style="transform: translateY({item.start}px)">
|
||||
* // {items[item.index]}
|
||||
* // </div>
|
||||
* // {/each}
|
||||
* // </div>
|
||||
* ```
|
||||
*/
|
||||
export function createVirtualizer(
|
||||
optionsGetter: () => VirtualizerOptions,
|
||||
) {
|
||||
let element = $state<HTMLElement | null>(null);
|
||||
|
||||
const internalStore = coreCreateVirtualizer({
|
||||
get count() {
|
||||
return optionsGetter().count;
|
||||
},
|
||||
get estimateSize() {
|
||||
return optionsGetter().estimateSize;
|
||||
},
|
||||
get overscan() {
|
||||
return optionsGetter().overscan ?? 5;
|
||||
},
|
||||
get scrollMargin() {
|
||||
return optionsGetter().scrollMargin;
|
||||
},
|
||||
get getItemKey() {
|
||||
return optionsGetter().getItemKey ?? (i => i);
|
||||
},
|
||||
getScrollElement: () => element,
|
||||
observeElementRect: observeElementRect,
|
||||
});
|
||||
|
||||
const state = $derived(get(internalStore));
|
||||
|
||||
const virtualItems = $derived(
|
||||
state.getVirtualItems().map((item: CoreVirtualItem): VirtualItem => ({
|
||||
index: item.index,
|
||||
start: item.start,
|
||||
size: item.size,
|
||||
end: item.end,
|
||||
key: typeof item.key === 'bigint' ? Number(item.key) : item.key,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
get items() {
|
||||
return virtualItems;
|
||||
},
|
||||
|
||||
get totalSize() {
|
||||
return state.getTotalSize();
|
||||
},
|
||||
|
||||
get scrollOffset() {
|
||||
return state.scrollOffset ?? 0;
|
||||
},
|
||||
|
||||
get scrollElement() {
|
||||
return element;
|
||||
},
|
||||
set scrollElement(el) {
|
||||
element = el;
|
||||
},
|
||||
|
||||
scrollToIndex: (idx: number, opt?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
|
||||
state.scrollToIndex(idx, opt),
|
||||
|
||||
scrollToOffset: (off: number) => state.scrollToOffset(off),
|
||||
|
||||
measureElement: (el: HTMLElement) => state.measureElement(el),
|
||||
};
|
||||
}
|
||||
|
||||
export type Virtualizer = ReturnType<typeof createVirtualizer>;
|
||||
|
||||
@@ -55,53 +55,15 @@ interface Props {
|
||||
|
||||
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
|
||||
|
||||
let activeIndex = $state(0);
|
||||
const itemRefs = new Map<number, HTMLElement>();
|
||||
|
||||
const virtual = createVirtualizer(() => ({
|
||||
const virtualizer = createVirtualizer(() => ({
|
||||
count: items.length,
|
||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||
overscan,
|
||||
}));
|
||||
|
||||
function registerItem(node: HTMLElement, index: number) {
|
||||
itemRefs.set(index, node);
|
||||
return {
|
||||
destroy() {
|
||||
itemRefs.delete(index);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function focusItem(index: number) {
|
||||
activeIndex = index;
|
||||
virtual.scrollToIndex(index, { align: 'auto' });
|
||||
await tick();
|
||||
itemRefs.get(index)?.focus();
|
||||
}
|
||||
|
||||
async function handleKeydown(event: KeyboardEvent) {
|
||||
let nextIndex = activeIndex;
|
||||
if (event.key === 'ArrowDown') nextIndex++;
|
||||
else if (event.key === 'ArrowUp') nextIndex--;
|
||||
else if (event.key === 'Home') nextIndex = 0;
|
||||
else if (event.key === 'End') nextIndex = items.length - 1;
|
||||
else return;
|
||||
|
||||
if (nextIndex >= 0 && nextIndex < items.length) {
|
||||
event.preventDefault();
|
||||
await focusItem(nextIndex);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Scroll container with single tab stop pattern:
|
||||
- tabindex="0" on container, tabindex="-1" on items
|
||||
- Arrow keys navigate within, Tab moves out
|
||||
-->
|
||||
<div
|
||||
bind:this={virtual.scrollElement}
|
||||
use:virtualizer.container
|
||||
class={cn(
|
||||
'relative overflow-auto border rounded-md bg-background',
|
||||
'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
|
||||
@@ -110,29 +72,15 @@ async function handleKeydown(event: KeyboardEvent) {
|
||||
)}
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
onkeydown={handleKeydown}
|
||||
onfocusin={(e => e.target === virtual.scrollElement && focusItem(activeIndex))}
|
||||
>
|
||||
<!-- Total scrollable height placeholder -->
|
||||
{#each virtualizer.items as item (item.key)}
|
||||
<div
|
||||
class="relative w-full"
|
||||
style:height="{virtual.totalSize}px"
|
||||
use:virtualizer.measureElement
|
||||
data-index={item.index}
|
||||
class="absolute top-0 left-0 w-full translate-y-[var(--offset)] will-change-transform"
|
||||
style:--offset="{item.start}px"
|
||||
>
|
||||
{#each virtual.items as row (row.key)}
|
||||
<!-- Individual item positioned absolutely via GPU-accelerated transform -->
|
||||
<div
|
||||
use:registerItem={row.index}
|
||||
data-index={row.index}
|
||||
role="option"
|
||||
aria-selected={activeIndex === row.index}
|
||||
tabindex="-1"
|
||||
onmousedown={() => (activeIndex = row.index)}
|
||||
class="absolute top-0 left-0 w-full outline-none focus:bg-accent focus:text-accent-foreground"
|
||||
style:height="{row.size}px"
|
||||
style:transform="translateY({row.start}px)"
|
||||
>
|
||||
{@render children({ item: items[row.index], index: row.index })}
|
||||
{@render children({ item: items[item.index], index: item.index })}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
19
yarn.lock
19
yarn.lock
@@ -1310,24 +1310,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/svelte-virtual@npm:^3.13.17":
|
||||
version: 3.13.17
|
||||
resolution: "@tanstack/svelte-virtual@npm:3.13.17"
|
||||
dependencies:
|
||||
"@tanstack/virtual-core": "npm:3.13.17"
|
||||
peerDependencies:
|
||||
svelte: ^3.48.0 || ^4.0.0 || ^5.0.0
|
||||
checksum: 10c0/8139a94d8b913c1a3aef0e7cda4cfd8451c3e46455a5bd5bae1df26ab7583bfde785ab93cacefba4f0f45f2e2cd13f43fa8cf672c45cb31d52b3232ffb37e69e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/virtual-core@npm:3.13.17":
|
||||
version: 3.13.17
|
||||
resolution: "@tanstack/virtual-core@npm:3.13.17"
|
||||
checksum: 10c0/a021795b88856eff8518137ecb85b72f875399bc234ad10bea440ecb6ab48e5e72a74c9a712649a7765f0c37bc41b88263f5104d18df8256b3d50f6a97b32c48
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@testing-library/dom@npm:9.x.x || 10.x.x":
|
||||
version: 10.4.1
|
||||
resolution: "@testing-library/dom@npm:10.4.1"
|
||||
@@ -2466,7 +2448,6 @@ __metadata:
|
||||
"@sveltejs/vite-plugin-svelte": "npm:^6.2.1"
|
||||
"@tailwindcss/vite": "npm:^4.1.18"
|
||||
"@tanstack/svelte-query": "npm:^6.0.14"
|
||||
"@tanstack/svelte-virtual": "npm:^3.13.17"
|
||||
"@testing-library/jest-dom": "npm:^6.9.1"
|
||||
"@testing-library/svelte": "npm:^5.3.1"
|
||||
"@tsconfig/svelte": "npm:^5.0.6"
|
||||
|
||||
Reference in New Issue
Block a user