Compare commits
491 Commits
9cf91e0992
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 30bbfa7e11 | |||
|
|
eff3979372 | ||
|
|
da79dd2e35 | ||
|
|
9d1f59d819 | ||
|
|
935b065843 | ||
|
|
d15b2ffe3f | ||
|
|
51ea8a9902 | ||
|
|
e81cadb32a | ||
|
|
1c3908f89e | ||
|
|
206e609a2d | ||
|
|
ff71d1c8c9 | ||
|
|
24ca2f6c41 | ||
|
|
3abe5723c7 | ||
| 4f181d1d92 | |||
|
|
aa4796079a | ||
|
|
f18454f9b3 | ||
|
|
e3924d43d8 | ||
|
|
0f6a4d6587 | ||
|
|
8f4faa3328 | ||
|
|
5867028be6 | ||
|
|
b8d019b824 | ||
|
|
45ed0d5601 | ||
|
|
9f91fed692 | ||
|
|
201280093f | ||
|
|
55b27973a2 | ||
|
|
5fa79e06e9 | ||
|
|
ee0749e828 | ||
|
|
5dae5fb7ea | ||
|
|
20f65ee396 | ||
|
|
010b8ad04b | ||
|
|
ce1dcd92ab | ||
|
|
ce609728c3 | ||
|
|
147df04c22 | ||
|
|
f356851d97 | ||
|
|
411dbfefcb | ||
|
|
a65d692139 | ||
|
|
3330f13228 | ||
|
|
ad6e1da292 | ||
|
|
ac8f0456b0 | ||
|
|
77668f507c | ||
|
|
23831efbe6 | ||
|
|
42854b4950 | ||
|
|
c45429f38d | ||
|
|
4d57f2084c | ||
|
|
bee529dff8 | ||
|
|
1f793278d1 | ||
|
|
4f76a03e33 | ||
|
|
940e20515b | ||
|
|
f15114a78b | ||
|
|
6ba37c9e4a | ||
|
|
858daff860 | ||
|
|
b7f54b503c | ||
|
|
17de544bdb | ||
|
|
a0ac52a348 | ||
|
|
99966d2de9 | ||
|
|
72334a3d05 | ||
|
|
8780b6932c | ||
|
|
5d2c05e192 | ||
|
|
1031b96ec5 | ||
|
|
4fdc99a15a | ||
|
|
9e74a2c2c6 | ||
|
|
aa3f467821 | ||
|
|
6001f50cf5 | ||
|
|
c2d0992015 | ||
|
|
bc56265717 | ||
|
|
2f45dc3620 | ||
|
|
d282448c53 | ||
|
|
f2e8de1d1d | ||
|
|
cee2a80c41 | ||
|
|
8b02333c01 | ||
|
|
0e85851cfd | ||
|
|
7dce7911c0 | ||
|
|
5e3929575d | ||
|
|
d3297d519f | ||
|
|
21d8273967 | ||
|
|
cdb2c355c0 | ||
|
|
3423eebf77 | ||
|
|
08d474289b | ||
|
|
2e6fc0e858 | ||
|
|
173816b5c0 | ||
|
|
d749f86edc | ||
|
|
8aad8942fc | ||
|
|
0eebe03bf8 | ||
|
|
2508168a3e | ||
|
|
a557e15759 | ||
|
|
a5b9238306 | ||
|
|
f01299f3d1 | ||
| 223dff2cda | |||
|
|
945132b6f5 | ||
|
|
e1117667d2 | ||
|
|
1c2fca784f | ||
|
|
3f0761aca7 | ||
|
|
0db13404e2 | ||
|
|
e39ed86a04 | ||
|
|
b43aa99f3e | ||
|
|
0a52bd6f6b | ||
|
|
4734b1120a | ||
|
|
7aa9fbd394 | ||
| 1eef9eff07 | |||
|
|
aefe03d811 | ||
|
|
e90b2bede5 | ||
|
|
bb8d2d685c | ||
|
|
c8d249d6ce | ||
| e3050097c6 | |||
|
|
faf9b8570b | ||
|
|
1fc9572f3d | ||
|
|
d006c662a9 | ||
|
|
422363d329 | ||
|
|
61c67acfb8 | ||
|
|
6945169279 | ||
|
|
055b02f720 | ||
|
|
7018b6a836 | ||
|
|
5d8869b3f2 | ||
|
|
cb740df1b2 | ||
|
|
d40170cfad | ||
|
|
3787ae260f | ||
|
|
a8858f6199 | ||
|
|
b1de03106f | ||
|
|
f3e9777267 | ||
|
|
c4abe84b0a | ||
|
|
1bd996659e | ||
|
|
e810135fc5 | ||
|
|
fc5a5c44e7 | ||
| d64de6f06b | |||
|
|
10788cf754 | ||
|
|
8eca240982 | ||
|
|
6f840fbad8 | ||
|
|
a7d08a9329 | ||
|
|
df2d6bae3b | ||
|
|
ce9665a842 | ||
|
|
b4e97da3a0 | ||
|
|
b3c0898735 | ||
|
|
f4875d7324 | ||
|
|
b16928ac80 | ||
|
|
7f01a9cc85 | ||
|
|
a1bc359c7f | ||
|
|
662d4ac626 | ||
|
|
4d7ae6c1c6 | ||
|
|
cb0e89b257 | ||
|
|
204aa75959 | ||
|
|
b72ec8afdf | ||
|
|
fa08986d60 | ||
|
|
359617212d | ||
|
|
beff194e5b | ||
|
|
f24c93c105 | ||
|
|
c16ef4acbf | ||
|
|
c91ced3617 | ||
|
|
a48c9bce0c | ||
|
|
152be85e34 | ||
|
|
b09b89f4fc | ||
|
|
1a23ec2f28 | ||
|
|
86ea9cd887 | ||
|
|
10919a9881 | ||
|
|
180abd150d | ||
|
|
c4bfb1db56 | ||
|
|
98a94e91ed | ||
|
|
a1b7f78fc4 | ||
|
|
41c5ceb848 | ||
|
|
780d76dced | ||
|
|
49f5564cc9 | ||
|
|
0ff8aec8f9 | ||
|
|
597ff7ec90 | ||
|
|
46a3c3e8fc | ||
|
|
4891cd3bbd | ||
|
|
70f2f82df0 | ||
|
|
0d572708c0 | ||
|
|
492c3573d0 | ||
|
|
a1080d3b34 | ||
|
|
fedf3f88e7 | ||
|
|
a26bcbecff | ||
|
|
352f30a558 | ||
|
|
8580884896 | ||
|
|
84417e440f | ||
| 8fda47ed57 | |||
|
|
1b9fe14f01 | ||
|
|
3537f6f62c | ||
|
|
88f4cd97f9 | ||
|
|
9167629616 | ||
|
|
b304e841de | ||
|
|
3ed63562b7 | ||
|
|
4b440496ba | ||
|
|
e4aacf609e | ||
|
|
51c2b6b5da | ||
|
|
195ae09fa2 | ||
|
|
b9eccbf627 | ||
|
|
63888e510c | ||
| cf8d3dffb9 | |||
|
|
1e2daa410c | ||
|
|
adf6dc93ea | ||
|
|
596a023d24 | ||
|
|
8195e9baa8 | ||
|
|
0554fcada7 | ||
|
|
9a794b626b | ||
|
|
40346aa9aa | ||
|
|
2b7f21711b | ||
|
|
69ae955131 | ||
|
|
12844432ac | ||
| a9aba10f09 | |||
|
|
778839d35e | ||
|
|
92fb314615 | ||
|
|
6f0b69ff45 | ||
|
|
2cd38797b9 | ||
|
|
6f231999e0 | ||
|
|
31a72d90ea | ||
|
|
072690270f | ||
|
|
eaf9d069c5 | ||
|
|
4a94f7bd09 | ||
|
|
918e792e41 | ||
|
|
c9c8b9abfc | ||
|
|
a392b575cc | ||
|
|
961475dea0 | ||
|
|
5496fd2680 | ||
|
|
f90f1e39e0 | ||
|
|
ca161dfbd4 | ||
|
|
ac2d0c32a4 | ||
|
|
54d22d650d | ||
|
|
a9c63f2544 | ||
|
|
70f57283a8 | ||
|
|
d43c873dc9 | ||
|
|
9501dbf281 | ||
|
|
0ac6acd174 | ||
|
|
5bb41c7e4c | ||
|
|
eed3339b0d | ||
|
|
d94e3cefb2 | ||
|
|
cfb586f539 | ||
|
|
6e975e5f8e | ||
|
|
142e4f0a19 | ||
|
|
59b85eead0 | ||
|
|
010643e398 | ||
|
|
27f637531b | ||
|
|
91fa08074b | ||
|
|
c246f70fe9 | ||
|
|
b1ce734f19 | ||
|
|
3add50a190 | ||
|
|
ef48d9815c | ||
|
|
818dfdb55e | ||
|
|
42e1271647 | ||
|
|
8ef9226dd2 | ||
|
|
f0c0a9de45 | ||
|
|
730eba138d | ||
|
|
18f265974e | ||
|
|
705723b009 | ||
|
|
75ea5ab382 | ||
|
|
f07b699926 | ||
|
|
b031e560af | ||
|
|
fbaf596fef | ||
|
|
1a2c44fb97 | ||
|
|
04602f0372 | ||
|
|
433fd2f7e6 | ||
|
|
87c4e04458 | ||
|
|
fb843c87af | ||
|
|
b2af3683bc | ||
|
|
90f11d8d16 | ||
|
|
a3f9bc12a0 | ||
|
|
6634f6df1e | ||
|
|
3f7ce63736 | ||
|
|
c665a579be | ||
|
|
ac7f094d13 | ||
|
|
c06aad1a8a | ||
|
|
471e186e70 | ||
|
|
dc72b9e048 | ||
|
|
07a37af71a | ||
|
|
d6607e5705 | ||
|
|
10801a641a | ||
|
|
98eab35615 | ||
|
|
7fbeef68e2 | ||
|
|
7078cb6f8c | ||
|
|
0b0489fa26 | ||
|
|
2022213921 | ||
|
|
6725a3b391 | ||
|
|
2eddb656a9 | ||
|
|
5973d241aa | ||
|
|
75a9c16070 | ||
|
|
31e4c64193 | ||
|
|
48e25fffa7 | ||
|
|
407c741349 | ||
|
|
13e114fafe | ||
|
|
1484ea024e | ||
|
|
67db6e22a7 | ||
|
|
192ce2d34a | ||
|
|
2b820230bc | ||
|
|
9b8ebed1c3 | ||
|
|
3d11f7317d | ||
|
|
c07800cc96 | ||
|
|
b49bf0d397 | ||
|
|
ed4ee8bb44 | ||
|
|
8a2059ac4a | ||
|
|
7ffc5d6a34 | ||
|
|
08cccc5ede | ||
|
|
71266f8b22 | ||
|
|
d5221ad449 | ||
|
|
873b697e8c | ||
|
|
3dce409034 | ||
|
|
cf08f7adfa | ||
|
|
4b01b1592d | ||
|
|
ecb4bea642 | ||
|
|
e89c6369cb | ||
|
|
18a311c6b1 | ||
|
|
732f77f504 | ||
|
|
b7992ca138 | ||
|
|
32b1367877 | ||
|
|
59b0d9c620 | ||
|
|
be13a5c8a0 | ||
|
|
80efa49ad0 | ||
|
|
7e9675be80 | ||
|
|
ac979c816c | ||
|
|
272c2c2d22 | ||
|
|
a9e2898945 | ||
|
|
1712134f64 | ||
|
|
52111ee941 | ||
|
|
e4970e43ba | ||
|
|
b41c48da67 | ||
|
|
1d0ca31262 | ||
|
|
a5380333eb | ||
|
|
46de3c6e87 | ||
|
|
91300bdc25 | ||
|
|
2ee66316f7 | ||
|
|
c6d20aae3d | ||
|
|
a0f184665d | ||
|
|
d4d2d68d9a | ||
|
|
55a560b785 | ||
|
|
c2542026a4 | ||
|
|
3f8fd357d8 | ||
|
|
1bd2a4f2f8 | ||
|
|
746a377038 | ||
|
|
1b76284237 | ||
|
|
b5ad3249ae | ||
| fb190f82b9 | |||
|
|
c0eed67618 | ||
|
|
e7f4304391 | ||
|
|
488857e0ec | ||
|
|
cca69a73ce | ||
|
|
2444e05bb7 | ||
|
|
72cc441c6f | ||
|
|
06cb155b47 | ||
|
|
50c7511698 | ||
| 993c63a39d | |||
|
|
8591985f62 | ||
|
|
9cbf4fdc48 | ||
|
|
8356e99382 | ||
|
|
7ca45c2e63 | ||
|
|
20f6e193f2 | ||
|
|
c04518300b | ||
|
|
ee074036f6 | ||
|
|
ba883ef9a8 | ||
|
|
28a71452d1 | ||
|
|
b7ce100407 | ||
|
|
96b26fb055 | ||
|
|
5ef8d609ab | ||
|
|
f457e5116f | ||
|
|
e0e0d929bb | ||
|
|
37ab7f795e | ||
|
|
af2ef77c30 | ||
|
|
ad18a19c4b | ||
|
|
ef259c6fce | ||
|
|
5d23a2af55 | ||
|
|
df8eca6ef2 | ||
|
|
7e62acce49 | ||
|
|
86e7b2c1ec | ||
|
|
da0612942c | ||
|
|
0444f8c114 | ||
|
|
6b4e0dbbd0 | ||
|
|
7389ec779d | ||
|
|
4d04761d88 | ||
|
|
32da012b26 | ||
|
|
71d320535e | ||
|
|
71c068bad2 | ||
|
|
247b683c87 | ||
|
|
8c0c91deb7 | ||
|
|
261c19db69 | ||
|
|
a85b3cf217 | ||
|
|
f02b19eff5 | ||
|
|
4dbf91f600 | ||
|
|
0daf0bf3bf | ||
|
|
14f9b87680 | ||
|
|
3cd9b36411 | ||
|
|
4c8b5764b3 | ||
|
|
62ae0799cc | ||
|
|
53c71a437f | ||
|
|
1976affdff | ||
|
|
f3de6c49a3 | ||
|
|
42e941083a | ||
|
|
86adec01a0 | ||
|
|
b0812ff606 | ||
|
|
deaf38f8ec | ||
| fefaf3f4c7 | |||
|
|
56e6e450e8 | ||
|
|
824581551f | ||
|
|
f97904f165 | ||
|
|
6129ad61f4 | ||
|
|
462abdd2bc | ||
|
|
429a9a0877 | ||
|
|
925d2eec3e | ||
|
|
211ed073e6 | ||
|
|
976672ce5e | ||
|
|
83397f3786 | ||
|
|
a72c0e8136 | ||
|
|
61dd62af2d | ||
|
|
147ddd226a | ||
|
|
c6b18f6dd3 | ||
| c10bbb681a | |||
|
|
7678ab271d | ||
| 3302e4a012 | |||
|
|
f730dbc782 | ||
|
|
8b704f1f82 | ||
|
|
36ed19e195 | ||
|
|
b209e051e5 | ||
|
|
f49e116408 | ||
|
|
8d1d1cd60f | ||
|
|
fb5c15ec32 | ||
|
|
955cc66916 | ||
|
|
a9cdd15787 | ||
|
|
76172aaa6b | ||
|
|
7146328982 | ||
|
|
52ecb9e304 | ||
|
|
30cb9ada1a | ||
|
|
4eeb43fa34 | ||
|
|
ad6ba4f0a0 | ||
|
|
170c8546d3 | ||
|
|
2f15148cdb | ||
|
|
a29b80efbb | ||
|
|
91451f7886 | ||
|
|
99d4b4e29a | ||
|
|
d9d45bf9fb | ||
|
|
4810c2b228 | ||
|
|
4c9b9f631f | ||
|
|
5fcb381b11 | ||
|
|
e098da2dbb | ||
|
|
1a76e9387a | ||
|
|
0f1eb489ed | ||
|
|
6e8376b8fc | ||
|
|
d81af0a77b | ||
|
|
77de829b04 | ||
|
|
7630802363 | ||
|
|
43175fd52a | ||
|
|
9598d8c3e4 | ||
|
|
c863bea2dc | ||
|
|
ea1f46f780 | ||
|
|
bdb67157fd | ||
|
|
e198e967ab | ||
|
|
e1af950442 | ||
|
|
13509a4145 | ||
|
|
09111a7c61 | ||
|
|
b13c0d268b | ||
|
|
1990860717 | ||
|
|
6f7e863b13 | ||
|
|
8ad29fd3a8 | ||
|
|
de2688de5a | ||
|
|
1ebab2d77b | ||
|
|
fc00717359 | ||
|
|
36a326817d | ||
|
|
f4c2a38873 | ||
|
|
614d6b0673 | ||
|
|
f26f56ddef | ||
|
|
76f27a64b2 | ||
|
|
baff3b9e27 | ||
|
|
d15b90cfcb | ||
|
|
893bb02459 | ||
|
|
f7b19bd97f | ||
|
|
2c4bfaba41 | ||
|
|
9fd98aca5d | ||
|
|
0692711726 | ||
|
|
86898bf83c | ||
|
|
1950cd4095 | ||
|
|
7a9f7e238c | ||
|
|
1f19e964ca | ||
|
|
eb10d58128 | ||
|
|
c78ab826a2 | ||
|
|
931a2df1ee | ||
|
|
bea3f7ae7f | ||
|
|
d1f035a6ad | ||
|
|
c0ccf4baff | ||
|
|
10b7457f21 | ||
|
|
2c666646cb | ||
|
|
be14a62e83 | ||
|
|
db814f0b93 | ||
|
|
9f8b840e7a | ||
|
|
9abec4210c | ||
|
|
29d1cc0cdc | ||
| 7d2fe49e9c | |||
|
|
aa087c5c3e | ||
|
|
943e6e77d3 | ||
|
|
809611cb10 | ||
|
|
ffad76a4c0 | ||
| 3a3d6ec577 | |||
|
|
ca1077df2f | ||
| 2e4a711a67 | |||
|
|
73419799ae | ||
|
|
917b303240 | ||
| 9e4667faf0 | |||
|
|
4705e40f92 |
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
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: Build Svelte App
|
||||
run: yarn build
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Deploy Pipeline
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Target'
|
||||
required: true
|
||||
default: 'production'
|
||||
type: choice
|
||||
options: [staging, production]
|
||||
|
||||
jobs:
|
||||
pipeline:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: Validation
|
||||
run: |
|
||||
yarn oxlint .
|
||||
yarn svelte-check
|
||||
|
||||
- name: Build for Production
|
||||
run: yarn build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: Deploy Step
|
||||
run: |
|
||||
echo "Deploying dist/ to ${{ github.event.inputs.environment || 'production' }}..."
|
||||
# EXAMPLE: rsync -avz dist/ user@your-vps:/var/www/html/
|
||||
@@ -1,48 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- feature/*
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint Code
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
|
||||
- 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: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Test
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, "feature/*"]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Svelte Checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: Type Check
|
||||
run: yarn svelte-check --threshold warning
|
||||
|
||||
- name: Lint
|
||||
run: yarn oxlint .
|
||||
|
||||
# e2e-tests:
|
||||
# name: E2E Tests (Playwright)
|
||||
# runs-on: ubuntu-latest
|
||||
#
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v4
|
||||
#
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20'
|
||||
# cache: 'yarn'
|
||||
#
|
||||
# - name: Install dependencies
|
||||
# run: yarn install --frozen-lockfile
|
||||
#
|
||||
# - name: Install Playwright browsers
|
||||
# run: yarn playwright install --with-deps
|
||||
#
|
||||
# - name: Run Playwright tests
|
||||
# run: yarn test:e2e
|
||||
#
|
||||
# - name: Upload Playwright report
|
||||
# if: always()
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: playwright-report
|
||||
# path: playwright-report/
|
||||
# retention-days: 7
|
||||
#
|
||||
# - name: Upload Playwright screenshots (on failure)
|
||||
# if: failure()
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: playwright-screenshots
|
||||
# path: test-results/
|
||||
# retention-days: 7
|
||||
#
|
||||
# Note: E2E tests are disabled until Playwright setup is complete.
|
||||
# Uncomment this job section when Playwright tests are ready to run.
|
||||
60
.gitea/workflows/workflow.yml
Normal file
60
.gitea/workflows/workflow.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Workflow
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '25'
|
||||
|
||||
- name: Enable Corepack
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare yarn@stable --activate
|
||||
|
||||
- name: Persistent Yarn Cache
|
||||
uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: .yarn/cache
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build Svelte App
|
||||
run: yarn build
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Type Check
|
||||
run: yarn check:shadcn-excluded
|
||||
|
||||
publish:
|
||||
needs: build # Only runs if tests/lint pass
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' # Only deploy from main branch
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
run: echo "${{ secrets.CI_DEPLOY_TOKEN }}" | docker login git.allmy.work -u ${{ gitea.repository_owner }} --password-stdin
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
run: |
|
||||
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
|
||||
docker push git.allmy.work/${{ gitea.repository }}:latest
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -23,6 +23,7 @@ Thumbs.db
|
||||
# Yarn
|
||||
.yarn
|
||||
.yarn/**
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Zed
|
||||
@@ -34,3 +35,11 @@ vite.config.ts.timestamp-*
|
||||
|
||||
/docs
|
||||
AGENTS.md
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# Tests
|
||||
coverage/
|
||||
|
||||
29
.storybook/Decorator.svelte
Normal file
29
.storybook/Decorator.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<!--
|
||||
Component: Decorator
|
||||
Global Storybook decorator that wraps all stories with necessary providers.
|
||||
|
||||
This provides:
|
||||
- ResponsiveManager context for breakpoint tracking
|
||||
- TooltipProvider for shadcn Tooltip components
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createResponsiveManager } from '$shared/lib';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||
import { setContext } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// Create and provide responsive context
|
||||
const responsiveManager = createResponsiveManager();
|
||||
$effect(() => responsiveManager.init());
|
||||
setContext<ResponsiveManager>('responsive', responsiveManager);
|
||||
</script>
|
||||
|
||||
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
|
||||
{@render children()}
|
||||
</TooltipProvider>
|
||||
16
.storybook/StoryStage.svelte
Normal file
16
.storybook/StoryStage.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
children: import('svelte').Snippet;
|
||||
width?: string; // Optional width override
|
||||
}
|
||||
|
||||
let { children, width = 'max-w-3xl' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
|
||||
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {width}">
|
||||
<div class="relative flex justify-center items-center text-foreground">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
47
.storybook/main.ts
Normal file
47
.storybook/main.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { StorybookConfig } from '@storybook/svelte-vite';
|
||||
import {
|
||||
dirname,
|
||||
resolve,
|
||||
} from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
loadConfigFromFile,
|
||||
mergeConfig,
|
||||
} from 'vite';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const config: StorybookConfig = {
|
||||
'stories': [
|
||||
'../src/**/*.mdx',
|
||||
'../src/**/*.stories.@(js|ts|svelte)',
|
||||
],
|
||||
'addons': [
|
||||
{
|
||||
name: '@storybook/addon-svelte-csf',
|
||||
options: {
|
||||
// Use modern template syntax for better performance
|
||||
legacyTemplate: false,
|
||||
},
|
||||
},
|
||||
'@chromatic-com/storybook',
|
||||
'@storybook/addon-vitest',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-docs',
|
||||
],
|
||||
'framework': '@storybook/svelte-vite',
|
||||
async viteFinal(config) {
|
||||
// This attempts to find your actual vite.config.ts
|
||||
const { config: userConfig } = await loadConfigFromFile(
|
||||
{ command: 'serve', mode: 'development' },
|
||||
resolve(__dirname, '../vite.config.ts'),
|
||||
) || {};
|
||||
|
||||
return mergeConfig(config, {
|
||||
// Merge only the resolve/alias parts if you want to be safe
|
||||
resolve: userConfig?.resolve || {},
|
||||
});
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
13
.storybook/preview-head.html
Normal file
13
.storybook/preview-head.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
/>
|
||||
<style>
|
||||
body {
|
||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
65
.storybook/preview.ts
Normal file
65
.storybook/preview.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Preview } from '@storybook/svelte-vite';
|
||||
import Decorator from './Decorator.svelte';
|
||||
import StoryStage from './StoryStage.svelte';
|
||||
import '../src/app/styles/app.css';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
|
||||
docs: {
|
||||
story: {
|
||||
// This sets the default height for the iframe in Autodocs
|
||||
iframeHeight: '400px',
|
||||
},
|
||||
},
|
||||
|
||||
head: `
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
/>
|
||||
<style>
|
||||
body {
|
||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
`,
|
||||
},
|
||||
|
||||
decorators: [
|
||||
// Wrap with providers (TooltipProvider, ResponsiveManager)
|
||||
story => ({
|
||||
Component: Decorator,
|
||||
props: {
|
||||
children: story(),
|
||||
},
|
||||
}),
|
||||
// Wrap with StoryStage for presentation styling
|
||||
story => ({
|
||||
Component: StoryStage,
|
||||
props: {
|
||||
children: story(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
7
.storybook/vitest.setup.ts
Normal file
7
.storybook/vitest.setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
|
||||
import { setProjectAnnotations } from '@storybook/svelte-vite';
|
||||
import * as projectAnnotations from './preview';
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||
Binary file not shown.
5
Caddyfile
Normal file
5
Caddyfile
Normal file
@@ -0,0 +1,5 @@
|
||||
:3000 {
|
||||
root * /usr/share/caddy
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
}
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
# Enable Corepack so we can use Yarn v4
|
||||
RUN corepack enable && corepack prepare yarn@stable --activate
|
||||
# Force Yarn to use node_modules instead of PnP
|
||||
ENV YARN_NODE_LINKER=node-modules
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --immutable
|
||||
COPY . .
|
||||
RUN yarn build
|
||||
|
||||
# Production stage - Caddy
|
||||
FROM caddy:2-alpine
|
||||
|
||||
WORKDIR /usr/share/caddy
|
||||
|
||||
# Copy built static files from the builder stage
|
||||
COPY --from=builder /app/dist .
|
||||
|
||||
# Copy our local Caddyfile config
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Start caddy using the config file
|
||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||
84
README.md
84
README.md
@@ -1,38 +1,78 @@
|
||||
# sv
|
||||
# GlyphDiff
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
A modern font exploration and comparison tool for browsing fonts from Google Fonts and Fontshare with real-time visual comparisons, advanced filtering, and customizable typography.
|
||||
|
||||
## Creating a project
|
||||
## Features
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
- **Multi-Provider Catalog**: Browse fonts from Google Fonts and Fontshare in one place
|
||||
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
|
||||
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
|
||||
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts
|
||||
- **Responsive UI**: Beautiful interface 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
|
||||
- **Framework**: Svelte 5 with reactive primitives (runes)
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **Components**: shadcn-svelte (via bits-ui)
|
||||
- **State Management**: TanStack Query for async data
|
||||
- **Architecture**: Feature-Sliced Design (FSD)
|
||||
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # App shell, layout, providers
|
||||
├── widgets/ # Composed UI blocks (ComparisonSlider, SampleList, FontSearch)
|
||||
├── features/ # Business features (filters, search, display)
|
||||
├── entities/ # Domain models and stores (Font, Breadcrumb)
|
||||
├── 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
|
||||
# Build for production
|
||||
yarn build
|
||||
|
||||
# Preview production build
|
||||
yarn preview
|
||||
```
|
||||
|
||||
## 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 code with dprint |
|
||||
| `yarn test:unit` | Run unit tests |
|
||||
| `yarn test:unit:ui` | Run Vitest UI |
|
||||
| `yarn storybook` | Start Storybook dev server |
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
## Code Style
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/`
|
||||
- **Components**: PascalCase (e.g., `ComparisonSlider.svelte`)
|
||||
- **Formatting**: 100 char line width, 4-space indent, single quotes
|
||||
- **Type Safety**: Strict TypeScript with JSDoc comments for public APIs
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
## Architecture Notes
|
||||
|
||||
This project follows the Feature-Sliced Design (FSD) methodology for clean separation of concerns. The application uses Svelte 5's new runes system (`$state`, `$derived`, `$effect`) for reactive state management.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
||||
],
|
||||
"typescript": {
|
||||
"lineWidth": 100,
|
||||
"lineWidth": 120,
|
||||
"indentWidth": 4,
|
||||
"useTabs": false,
|
||||
"semiColons": "prefer",
|
||||
@@ -41,7 +41,7 @@
|
||||
"lineWidth": 100
|
||||
},
|
||||
"markup": {
|
||||
"printWidth": 100,
|
||||
"printWidth": 120,
|
||||
"indentWidth": 4,
|
||||
"useTabs": false,
|
||||
"quotes": "double",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
} from '@playwright/test';
|
||||
|
||||
test('home page has expected h1', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
@@ -17,7 +17,7 @@ pre-push:
|
||||
run: yarn tsc --noEmit
|
||||
|
||||
svelte-check:
|
||||
run: yarn svelte-check --threshold warning
|
||||
run: yarn check:shadcn-excluded --threshold warning
|
||||
|
||||
format-check:
|
||||
glob: "*.{ts,js,svelte,json,md}"
|
||||
|
||||
37
package.json
37
package.json
@@ -2,32 +2,57 @@
|
||||
"name": "glyphdiff",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"packageManager": "yarn@4.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check": "svelte-check",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
|
||||
"lint": "oxlint",
|
||||
"format": "dprint fmt",
|
||||
"format:check": "dprint check",
|
||||
"test:e2e": "playwright test",
|
||||
"test": "npm run test:e2e"
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:unit:ui": "vitest --ui",
|
||||
"test:unit:coverage": "vitest run --coverage",
|
||||
"test:component": "vitest run --config vitest.config.component.ts",
|
||||
"test:component:browser": "vitest run --config vitest.config.browser.ts",
|
||||
"test:component:browser:watch": "vitest --config vitest.config.browser.ts",
|
||||
"test": "yarn run test:unit",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@storybook/addon-a11y": "^10.1.11",
|
||||
"@storybook/addon-docs": "^10.1.11",
|
||||
"@storybook/addon-svelte-csf": "^5.0.10",
|
||||
"@storybook/addon-vitest": "^10.1.11",
|
||||
"@storybook/svelte-vite": "^10.1.11",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@tsconfig/svelte": "^5.0.6",
|
||||
"@types/jsdom": "^27",
|
||||
"@vitest/browser-playwright": "^4.0.16",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"bits-ui": "^2.14.4",
|
||||
"clsx": "^2.1.1",
|
||||
"dprint": "^0.50.2",
|
||||
"jsdom": "^27.4.0",
|
||||
"lefthook": "^2.0.13",
|
||||
"oxlint": "^1.35.0",
|
||||
"playwright": "^1.57.0",
|
||||
"storybook": "^10.1.11",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-language-server": "^0.17.23",
|
||||
@@ -36,6 +61,12 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^6.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
webServer: { command: 'yarn build && yarn preview', port: 4173 },
|
||||
webServer: {
|
||||
command: 'yarn build && yarn preview',
|
||||
port: 4173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
testDir: 'e2e',
|
||||
});
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
* layout shell. This is the root component mounted by the application.
|
||||
*
|
||||
* Structure:
|
||||
* - QueryProvider provides TanStack Query client for data fetching
|
||||
* - Layout provides sidebar, header/footer, and page container
|
||||
* - Page renders the current route content
|
||||
*/
|
||||
import Page from '$routes/Page.svelte';
|
||||
import { QueryProvider } from './providers';
|
||||
import Layout from './ui/Layout.svelte';
|
||||
</script>
|
||||
|
||||
<Layout>
|
||||
<Page />
|
||||
</Layout>
|
||||
<QueryProvider>
|
||||
<Layout>
|
||||
<Page />
|
||||
</Layout>
|
||||
</QueryProvider>
|
||||
|
||||
22
src/app/providers/QueryProvider.svelte
Normal file
22
src/app/providers/QueryProvider.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
Component: QueryProvider
|
||||
Provides a QueryClientProvider for child components.
|
||||
|
||||
All components that use useQueryClient() or createQuery() must be
|
||||
descendants of this provider.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{@render children?.()}
|
||||
</QueryClientProvider>
|
||||
1
src/app/providers/index.ts
Normal file
1
src/app/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as QueryProvider } from './QueryProvider.svelte';
|
||||
@@ -37,6 +37,28 @@
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
|
||||
--background-20: oklch(1 0 0 / 20%);
|
||||
--background-40: oklch(1 0 0 / 40%);
|
||||
--background-60: oklch(1 0 0 / 60%);
|
||||
--background-80: oklch(1 0 0 / 80%);
|
||||
--background-95: oklch(1 0 0 / 95%);
|
||||
--background-subtle: oklch(0.98 0 0);
|
||||
--background-muted: oklch(0.97 0.002 286.375);
|
||||
|
||||
--text-muted: oklch(0.552 0.016 285.938);
|
||||
--text-subtle: oklch(0.705 0.015 286.067);
|
||||
--text-soft: oklch(0.5 0.01 286);
|
||||
|
||||
--border-subtle: oklch(0.95 0.003 286.32);
|
||||
--border-muted: oklch(0.92 0.004 286.32);
|
||||
--border-soft: oklch(0.88 0.005 286.32);
|
||||
|
||||
--gradient-from: oklch(0.98 0.002 286.32);
|
||||
--gradient-via: oklch(1 0 0);
|
||||
--gradient-to: oklch(0.98 0.002 286.32);
|
||||
|
||||
--font-mono: 'Major Mono Display';
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -71,6 +93,26 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
|
||||
--background-20: oklch(0.21 0.006 285.885 / 20%);
|
||||
--background-40: oklch(0.21 0.006 285.885 / 40%);
|
||||
--background-60: oklch(0.21 0.006 285.885 / 60%);
|
||||
--background-80: oklch(0.21 0.006 285.885 / 80%);
|
||||
--background-95: oklch(0.21 0.006 285.885 / 95%);
|
||||
--background-subtle: oklch(0.18 0.005 285.823);
|
||||
--background-muted: oklch(0.274 0.006 286.033);
|
||||
|
||||
--text-muted: oklch(0.705 0.015 286.067);
|
||||
--text-subtle: oklch(0.552 0.016 285.938);
|
||||
--text-soft: oklch(0.8 0.01 286);
|
||||
|
||||
--border-subtle: oklch(1 0 0 / 8%);
|
||||
--border-muted: oklch(1 0 0 / 10%);
|
||||
--border-soft: oklch(1 0 0 / 15%);
|
||||
|
||||
--gradient-from: oklch(0.25 0.005 285.885);
|
||||
--gradient-via: oklch(0.21 0.006 285.885);
|
||||
--gradient-to: oklch(0.25 0.005 285.885);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -109,6 +151,24 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-background-20: var(--background-20);
|
||||
--color-background-40: var(--background-40);
|
||||
--color-background-60: var(--background-60);
|
||||
--color-background-80: var(--background-80);
|
||||
--color-background-95: var(--background-95);
|
||||
--color-background-subtle: var(--background-subtle);
|
||||
--color-background-muted: var(--background-muted);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-text-subtle: var(--text-subtle);
|
||||
--color-text-soft: var(--text-soft);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-muted: var(--border-muted);
|
||||
--color-border-soft: var(--border-soft);
|
||||
--color-gradient-from: var(--gradient-from);
|
||||
--color-gradient-via: var(--gradient-via);
|
||||
--color-gradient-to: var(--gradient-to);
|
||||
--font-mono: 'Major Mono Display', monospace;
|
||||
--font-sans: 'Karla', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -117,6 +177,8 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,3 +200,108 @@
|
||||
.peer:focus-visible ~ * {
|
||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes nudge {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1) rotate(0deg);
|
||||
}
|
||||
2% {
|
||||
transform: translateY(-2px) scale(1.1) rotate(-1deg);
|
||||
}
|
||||
4% {
|
||||
transform: translateY(0) scale(1) rotate(1deg);
|
||||
}
|
||||
6% {
|
||||
transform: translateY(-2px) scale(1.1) rotate(0deg);
|
||||
}
|
||||
8% {
|
||||
transform: translateY(0) scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-nudge {
|
||||
animation: nudge 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.barlow {
|
||||
font-family: "Barlow", system-ui, Inter, Roboto, "Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(0 0% 70% / 0.4) transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: hsl(0 0% 40% / 0.5) transparent;
|
||||
}
|
||||
|
||||
/* ---- Webkit / Blink ---- */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 70% / 0);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
/* Show thumb when container is hovered or actively scrolling */
|
||||
:hover > ::-webkit-scrollbar-thumb,
|
||||
::-webkit-scrollbar-thumb:hover,
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 70% / 0.4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 50% / 0.6);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: hsl(0 0% 40% / 0.8);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 40% / 0);
|
||||
}
|
||||
|
||||
.dark :hover > ::-webkit-scrollbar-thumb,
|
||||
.dark ::-webkit-scrollbar-thumb:hover,
|
||||
.dark *:hover::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 40% / 0.5);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 55% / 0.6);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:active {
|
||||
background: hsl(0 0% 65% / 0.7);
|
||||
}
|
||||
|
||||
/* ---- Behavior ---- */
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
13
src/app/types/ambient.d.ts
vendored
13
src/app/types/ambient.d.ts
vendored
@@ -35,3 +35,16 @@ declare module '*.jpg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly DEV: boolean;
|
||||
readonly PROD: boolean;
|
||||
readonly MODE: string;
|
||||
// Add other env variables you use
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -3,45 +3,105 @@
|
||||
* Layout Component
|
||||
*
|
||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
||||
* sidebar provider initialization, and renders child routes with consistent structure.
|
||||
* toolbar provider initialization, and renders child routes with consistent structure.
|
||||
*
|
||||
* Layout structure:
|
||||
* - Header area (currently empty, reserved for future use)
|
||||
* - Collapsible sidebar with main content area
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*
|
||||
* Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior
|
||||
* throughout the application.
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*/
|
||||
import favicon from '$shared/assets/favicon.svg';
|
||||
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
|
||||
import { FiltersSidebar } from '$widgets/FiltersSidebar';
|
||||
import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte';
|
||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||
import GD from '$shared/assets/GD.svg';
|
||||
import { ResponsiveProvider } from '$shared/lib';
|
||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||
import {
|
||||
type Snippet,
|
||||
onMount,
|
||||
} from 'svelte';
|
||||
|
||||
/** Slot content for route pages to render */
|
||||
let { children } = $props();
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
let fontsReady = $state(false);
|
||||
|
||||
/**
|
||||
* Sets fontsReady flag to true when font for the page logo is loaded.
|
||||
*/
|
||||
onMount(async () => {
|
||||
if (!('fonts' in document)) {
|
||||
fontsReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const required = ['100'];
|
||||
|
||||
const missing = required.filter(
|
||||
w => !document.fonts.check(`${w} 1em Barlow`),
|
||||
);
|
||||
|
||||
if (missing.length > 0) {
|
||||
await Promise.all(
|
||||
missing.map(w => document.fonts.load(`${w} 1em Barlow`)),
|
||||
);
|
||||
}
|
||||
fontsReady = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="icon" href={GD} />
|
||||
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://cdn.fontshare.com"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
as="style"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
media="print"
|
||||
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
/>
|
||||
</noscript>
|
||||
<title>Compare Typography & Typefaces | GlyphDiff</title>
|
||||
</svelte:head>
|
||||
|
||||
<div id="app-root">
|
||||
<header></header>
|
||||
<ResponsiveProvider>
|
||||
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||
<header>
|
||||
<BreadcrumbHeader />
|
||||
</header>
|
||||
|
||||
<Sidebar.Provider>
|
||||
<FiltersSidebar />
|
||||
<main class="w-dvw">
|
||||
<TypographyMenu />
|
||||
{@render children?.()}
|
||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||
<main class="flex-1 w-full mx-auto px-4 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative">
|
||||
<TooltipProvider>
|
||||
{#if fontsReady}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</TooltipProvider>
|
||||
</main>
|
||||
</Sidebar.Provider>
|
||||
<footer></footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#app-root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<!-- </ScrollArea> -->
|
||||
<footer></footer>
|
||||
</div>
|
||||
</ResponsiveProvider>
|
||||
|
||||
2
src/entities/Breadcrumb/index.ts
Normal file
2
src/entities/Breadcrumb/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { scrollBreadcrumbsStore } from './model';
|
||||
export { BreadcrumbHeader } from './ui';
|
||||
1
src/entities/Breadcrumb/model/index.ts
Normal file
1
src/entities/Breadcrumb/model/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
/**
|
||||
* Index of the item to display
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* ID of the item to navigate to
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Title snippet to render
|
||||
*/
|
||||
title: Snippet<[{ className?: string }]>;
|
||||
}
|
||||
|
||||
class ScrollBreadcrumbsStore {
|
||||
#items = $state<BreadcrumbItem[]>([]);
|
||||
|
||||
get items() {
|
||||
// Keep them sorted by index for Swiss orderliness
|
||||
return this.#items.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
add(item: BreadcrumbItem) {
|
||||
if (!this.#items.find(i => i.index === item.index)) {
|
||||
this.#items.push(item);
|
||||
}
|
||||
}
|
||||
remove(index: number) {
|
||||
this.#items = this.#items.filter(i => i.index !== index);
|
||||
}
|
||||
}
|
||||
|
||||
export function createScrollBreadcrumbsStore() {
|
||||
return new ScrollBreadcrumbsStore();
|
||||
}
|
||||
|
||||
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
||||
@@ -0,0 +1,78 @@
|
||||
<!--
|
||||
Component: BreadcrumbHeader
|
||||
Fixed header for breadcrumbs navigation for sections in the page
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { smoothScroll } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
fly,
|
||||
slide,
|
||||
} from 'svelte/transition';
|
||||
import { scrollBreadcrumbsStore } from '../../model';
|
||||
</script>
|
||||
|
||||
{#if scrollBreadcrumbsStore.items.length > 0}
|
||||
<div
|
||||
transition:slide={{ duration: 200 }}
|
||||
class="
|
||||
fixed top-0 left-0 right-0 z-100
|
||||
backdrop-blur-lg bg-background-20
|
||||
border-b border-border-muted
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
h-10 sm:h-12
|
||||
"
|
||||
>
|
||||
<div class="max-w-8xl mx-auto px-4 sm:px-6 h-full flex items-center gap-2 sm:gap-4">
|
||||
<h1 class={cn('barlow font-extralight text-sm sm:text-base')}>
|
||||
GLYPHDIFF
|
||||
</h1>
|
||||
|
||||
<div class="h-3.5 sm:h-4 w-px bg-border-subtle hidden sm:block"></div>
|
||||
|
||||
<nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
||||
<div
|
||||
in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }}
|
||||
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
|
||||
class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<span class="font-mono text-[8px] sm:text-[9px] text-text-muted tracking-wider">
|
||||
{String(item.index).padStart(2, '0')}
|
||||
</span>
|
||||
<a href={`#${item.id}`} use:smoothScroll>
|
||||
{@render item.title({
|
||||
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-foreground',
|
||||
})}</a>
|
||||
|
||||
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||
<div class="flex items-center gap-0.5 opacity-40">
|
||||
<div class="w-1 h-px bg-text-muted"></div>
|
||||
<div class="w-1 h-px bg-text-muted"></div>
|
||||
<div class="w-1 h-px bg-text-muted"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
|
||||
<div class="w-px h-2 sm:h-2.5 bg-border-subtle hidden sm:block"></div>
|
||||
<span class="font-mono text-[7px] sm:text-[8px] text-text-muted tracking-wider">
|
||||
[{scrollBreadcrumbsStore.items.length}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
3
src/entities/Breadcrumb/ui/index.ts
Normal file
3
src/entities/Breadcrumb/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
||||
|
||||
export { BreadcrumbHeader };
|
||||
161
src/entities/Font/api/fontshare/fontshare.ts
Normal file
161
src/entities/Font/api/fontshare/fontshare.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Fontshare API client
|
||||
*
|
||||
* Handles API requests to Fontshare API for fetching font metadata.
|
||||
* Provides error handling, pagination support, and type-safe responses.
|
||||
*
|
||||
* Pagination: The Fontshare API DOES support pagination via `page` and `limit` parameters.
|
||||
* However, the current implementation uses `fetchAllFontshareFonts()` to fetch all fonts upfront.
|
||||
* For future optimization, consider implementing incremental pagination for large datasets.
|
||||
*
|
||||
* @see https://fontshare.com
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
import type {
|
||||
FontshareApiModel,
|
||||
FontshareFont,
|
||||
} from '../../model/types/fontshare';
|
||||
|
||||
/**
|
||||
* Fontshare API parameters
|
||||
*/
|
||||
export interface FontshareParams extends QueryParams {
|
||||
/**
|
||||
* Filter by categories (e.g., ["Sans", "Serif", "Display"])
|
||||
*/
|
||||
categories?: string[];
|
||||
/**
|
||||
* Filter by tags (e.g., ["Magazines", "Branding", "Logos"])
|
||||
*/
|
||||
tags?: string[];
|
||||
/**
|
||||
* Page number for pagination (1-indexed)
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Number of items per page
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Search query to filter fonts
|
||||
*/
|
||||
q?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fontshare API response wrapper
|
||||
* Re-exported from model/types/fontshare for backward compatibility
|
||||
*/
|
||||
export type FontshareResponse = FontshareApiModel;
|
||||
|
||||
/**
|
||||
* Fetch fonts from Fontshare API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Fontshare API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all Sans category fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* categories: ['Sans'],
|
||||
* limit: 50
|
||||
* });
|
||||
*
|
||||
* // Fetch fonts with specific tags
|
||||
* const response = await fetchFontshareFonts({
|
||||
* tags: ['Branding', 'Logos']
|
||||
* });
|
||||
*
|
||||
* // Search fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* search: 'Satoshi'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `https://api.fontshare.com/v2/fonts${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<FontshareResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by slug
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param slug - Font slug (e.g., "satoshi", "general-sans")
|
||||
* @returns Promise resolving to Fontshare font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const satoshi = await fetchFontshareFontBySlug('satoshi');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFontBySlug(
|
||||
slug: string,
|
||||
): Promise<FontshareFont | undefined> {
|
||||
const response = await fetchFontshareFonts();
|
||||
return response.fonts.find(font => font.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all fonts from Fontshare
|
||||
* Convenience function for fetching all available fonts
|
||||
* Uses pagination to get all items
|
||||
*
|
||||
* @returns Promise resolving to all Fontshare fonts
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const allFonts = await fetchAllFontshareFonts();
|
||||
* console.log(`Found ${allFonts.fonts.length} fonts`);
|
||||
* ```
|
||||
*/
|
||||
export async function fetchAllFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const allFonts: FontshareFont[] = [];
|
||||
let page = 1;
|
||||
const limit = 100; // Max items per page
|
||||
|
||||
while (true) {
|
||||
const response = await fetchFontshareFonts({
|
||||
...params,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
allFonts.push(...response.fonts);
|
||||
|
||||
// Check if we've fetched all items
|
||||
if (response.fonts.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
// Return first response with all items combined
|
||||
const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit });
|
||||
|
||||
return {
|
||||
...firstResponse,
|
||||
fonts: allFonts,
|
||||
};
|
||||
}
|
||||
127
src/entities/Font/api/google/googleFonts.ts
Normal file
127
src/entities/Font/api/google/googleFonts.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Google Fonts API client
|
||||
*
|
||||
* Handles API requests to Google Fonts API for fetching font metadata.
|
||||
* Provides error handling, retry logic, and type-safe responses.
|
||||
*
|
||||
* Pagination: The Google Fonts API does NOT support pagination parameters.
|
||||
* All fonts matching the query are returned in a single response.
|
||||
* Use category, subset, or sort filters to reduce the result set if needed.
|
||||
*
|
||||
* @see https://developers.google.com/fonts/docs/developer_api
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
import type {
|
||||
FontItem,
|
||||
GoogleFontsApiModel,
|
||||
} from '../../model/types/google';
|
||||
|
||||
/**
|
||||
* Google Fonts API parameters
|
||||
*/
|
||||
export interface GoogleFontsParams extends QueryParams {
|
||||
/**
|
||||
* Google Fonts API key (required for Google Fonts API v1)
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* Font family name (to fetch specific font)
|
||||
*/
|
||||
family?: string;
|
||||
/**
|
||||
* Font category filter (e.g., "sans-serif", "serif", "display")
|
||||
*/
|
||||
category?: string;
|
||||
/**
|
||||
* Character subset filter (e.g., "latin", "latin-ext", "cyrillic")
|
||||
*/
|
||||
subset?: string;
|
||||
/**
|
||||
* Sort order for results
|
||||
*/
|
||||
sort?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending';
|
||||
/**
|
||||
* Cap the number of fonts returned
|
||||
*/
|
||||
capability?: 'VF' | 'WOFF2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Fonts API response wrapper
|
||||
* Re-exported from model/types/google for backward compatibility
|
||||
*/
|
||||
export type GoogleFontsResponse = GoogleFontsApiModel;
|
||||
|
||||
/**
|
||||
* Simplified font item from Google Fonts API
|
||||
* Re-exported from model/types/google for backward compatibility
|
||||
*/
|
||||
export type GoogleFontItem = FontItem;
|
||||
|
||||
/**
|
||||
* Google Fonts API base URL
|
||||
* Note: Google Fonts API v1 requires an API key. For development/testing without a key,
|
||||
* fonts may not load properly.
|
||||
*/
|
||||
const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts' as const;
|
||||
|
||||
/**
|
||||
* Fetch fonts from Google Fonts API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Google Fonts API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all sans-serif fonts sorted by popularity
|
||||
* const response = await fetchGoogleFonts({
|
||||
* category: 'sans-serif',
|
||||
* sort: 'popularity'
|
||||
* });
|
||||
*
|
||||
* // Fetch specific font family
|
||||
* const robotoResponse = await fetchGoogleFonts({
|
||||
* family: 'Roboto'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFonts(
|
||||
params: GoogleFontsParams = {},
|
||||
): Promise<GoogleFontsResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${GOOGLE_FONTS_API_URL}${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<GoogleFontsResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Google Fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by family name
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param family - Font family name (e.g., "Roboto")
|
||||
* @returns Promise resolving to Google Font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = await fetchGoogleFontFamily('Roboto');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFontFamily(
|
||||
family: string,
|
||||
): Promise<GoogleFontItem | undefined> {
|
||||
const response = await fetchGoogleFonts({ family });
|
||||
return response.items.find(item => item.family === family);
|
||||
}
|
||||
38
src/entities/Font/api/index.ts
Normal file
38
src/entities/Font/api/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Font API clients exports
|
||||
*
|
||||
* Exports API clients and normalization utilities
|
||||
*/
|
||||
|
||||
// Proxy API (PRIMARY - NEW)
|
||||
export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
} from './proxy/proxyFonts';
|
||||
export type {
|
||||
ProxyFontsParams,
|
||||
ProxyFontsResponse,
|
||||
} from './proxy/proxyFonts';
|
||||
|
||||
// Google Fonts API (DEPRECATED - kept for backward compatibility)
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
} from './google/googleFonts';
|
||||
export type {
|
||||
GoogleFontItem,
|
||||
GoogleFontsParams,
|
||||
GoogleFontsResponse,
|
||||
} from './google/googleFonts';
|
||||
|
||||
// Fontshare API (DEPRECATED - kept for backward compatibility)
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
fetchFontshareFonts,
|
||||
} from './fontshare/fontshare';
|
||||
export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './fontshare/fontshare';
|
||||
279
src/entities/Font/api/proxy/proxyFonts.ts
Normal file
279
src/entities/Font/api/proxy/proxyFonts.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Proxy API client
|
||||
*
|
||||
* Handles API requests to GlyphDiff proxy API for fetching font metadata.
|
||||
* Provides error handling, pagination support, and type-safe responses.
|
||||
*
|
||||
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
|
||||
* unified format, eliminating the need for client-side normalization.
|
||||
*
|
||||
* Fallback: If proxy API fails, falls back to Fontshare API for development.
|
||||
*
|
||||
* @see https://api.glyphdiff.com/api/v1/fonts
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontSubset,
|
||||
} from '../../model/types';
|
||||
|
||||
/**
|
||||
* Proxy API base URL
|
||||
*/
|
||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
||||
|
||||
/**
|
||||
* Whether to use proxy API (true) or fallback (false)
|
||||
*
|
||||
* Set to true when your proxy API is ready:
|
||||
* const USE_PROXY_API = true;
|
||||
*
|
||||
* Set to false to use Fontshare API as fallback during development:
|
||||
* const USE_PROXY_API = false;
|
||||
*
|
||||
* The app will automatically fall back to Fontshare API if the proxy fails.
|
||||
*/
|
||||
const USE_PROXY_API = true;
|
||||
|
||||
/**
|
||||
* Proxy API parameters
|
||||
*
|
||||
* Maps directly to the proxy API query parameters
|
||||
*/
|
||||
export interface ProxyFontsParams extends QueryParams {
|
||||
/**
|
||||
* Font provider filter ("google" or "fontshare")
|
||||
* Omit to fetch from both providers
|
||||
*/
|
||||
provider?: 'google' | 'fontshare';
|
||||
|
||||
/**
|
||||
* Font category filter
|
||||
*/
|
||||
category?: FontCategory;
|
||||
|
||||
/**
|
||||
* Character subset filter
|
||||
*/
|
||||
subset?: FontSubset;
|
||||
|
||||
/**
|
||||
* Search query (e.g., "roboto", "satoshi")
|
||||
*/
|
||||
q?: string;
|
||||
|
||||
/**
|
||||
* Sort order for results
|
||||
* "name" - Alphabetical by font name
|
||||
* "popularity" - Most popular first
|
||||
* "lastModified" - Recently updated first
|
||||
*/
|
||||
sort?: 'name' | 'popularity' | 'lastModified';
|
||||
|
||||
/**
|
||||
* Number of items to return (pagination)
|
||||
*/
|
||||
limit?: number;
|
||||
|
||||
/**
|
||||
* Number of items to skip (pagination)
|
||||
* Use for pagination: offset = (page - 1) * limit
|
||||
*/
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy API response
|
||||
*
|
||||
* Includes pagination metadata alongside font data
|
||||
*/
|
||||
export interface ProxyFontsResponse {
|
||||
/** Array of unified font objects */
|
||||
fonts: UnifiedFont[];
|
||||
|
||||
/** Total number of fonts matching the query */
|
||||
total: number;
|
||||
|
||||
/** Limit used for this request */
|
||||
limit: number;
|
||||
|
||||
/** Offset used for this request */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fonts from proxy API
|
||||
*
|
||||
* If proxy API fails or is unavailable, falls back to Fontshare API for development.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Promise resolving to proxy API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all sans-serif fonts from Google
|
||||
* const response = await fetchProxyFonts({
|
||||
* provider: 'google',
|
||||
* category: 'sans-serif',
|
||||
* limit: 50,
|
||||
* offset: 0
|
||||
* });
|
||||
*
|
||||
* // Search fonts across all providers
|
||||
* const searchResponse = await fetchProxyFonts({
|
||||
* q: 'roboto',
|
||||
* limit: 20
|
||||
* });
|
||||
*
|
||||
* // Fetch fonts with pagination
|
||||
* const page1 = await fetchProxyFonts({ limit: 50, offset: 0 });
|
||||
* const page2 = await fetchProxyFonts({ limit: 50, offset: 50 });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchProxyFonts(
|
||||
params: ProxyFontsParams = {},
|
||||
): Promise<ProxyFontsResponse> {
|
||||
// Try proxy API first if enabled
|
||||
if (USE_PROXY_API) {
|
||||
try {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${PROXY_API_URL}${queryString}`;
|
||||
|
||||
console.log('[fetchProxyFonts] Fetching from proxy API', { params, url });
|
||||
|
||||
const response = await api.get<ProxyFontsResponse>(url);
|
||||
|
||||
// Validate response has fonts array
|
||||
if (!response.data || !Array.isArray(response.data.fonts)) {
|
||||
console.error('[fetchProxyFonts] Invalid response from proxy API', response.data);
|
||||
throw new Error('Proxy API returned invalid response');
|
||||
}
|
||||
|
||||
console.log('[fetchProxyFonts] Proxy API success', {
|
||||
count: response.data.fonts.length,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.warn('[fetchProxyFonts] Proxy API failed, using fallback', error);
|
||||
|
||||
// Check if it's a network error or proxy not available
|
||||
const isNetworkError = error instanceof Error
|
||||
&& (error.message.includes('Failed to fetch')
|
||||
|| error.message.includes('Network')
|
||||
|| error.message.includes('404')
|
||||
|| error.message.includes('500'));
|
||||
|
||||
if (isNetworkError) {
|
||||
// Fall back to Fontshare API
|
||||
console.log('[fetchProxyFonts] Using Fontshare API as fallback');
|
||||
return await fetchFontshareFallback(params);
|
||||
}
|
||||
|
||||
// Re-throw other errors
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch fonts from proxy API: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Use Fontshare API directly
|
||||
console.log('[fetchProxyFonts] Using Fontshare API (proxy disabled)');
|
||||
return await fetchFontshareFallback(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to Fontshare API when proxy is unavailable
|
||||
*
|
||||
* Maps proxy API params to Fontshare API params and normalizes response
|
||||
*/
|
||||
async function fetchFontshareFallback(
|
||||
params: ProxyFontsParams,
|
||||
): Promise<ProxyFontsResponse> {
|
||||
// Import dynamically to avoid circular dependency
|
||||
const { fetchFontshareFonts } = await import('$entities/Font/api/fontshare/fontshare');
|
||||
const { normalizeFontshareFonts } = await import('$entities/Font/lib/normalize/normalize');
|
||||
|
||||
// Map proxy params to Fontshare params
|
||||
const fontshareParams = {
|
||||
q: params.q,
|
||||
categories: params.category ? [params.category] : undefined,
|
||||
page: params.offset ? Math.floor(params.offset / (params.limit || 50)) + 1 : undefined,
|
||||
limit: params.limit,
|
||||
};
|
||||
|
||||
const response = await fetchFontshareFonts(fontshareParams);
|
||||
const normalizedFonts = normalizeFontshareFonts(response.fonts);
|
||||
|
||||
return {
|
||||
fonts: normalizedFonts,
|
||||
total: response.count_total,
|
||||
limit: params.limit || response.count,
|
||||
offset: params.offset || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by ID
|
||||
*
|
||||
* Convenience function for fetching a single font by ID
|
||||
* Note: This fetches a page and filters client-side, which is not ideal
|
||||
* For production, consider adding a dedicated endpoint to the proxy API
|
||||
*
|
||||
* @param id - Font ID (family name for Google, slug for Fontshare)
|
||||
* @returns Promise resolving to font or undefined
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = await fetchProxyFontById('Roboto');
|
||||
* const satoshi = await fetchProxyFontById('satoshi');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchProxyFontById(
|
||||
id: string,
|
||||
): Promise<UnifiedFont | undefined> {
|
||||
const response = await fetchProxyFonts({ limit: 1000, q: id });
|
||||
|
||||
if (!response || !response.fonts) {
|
||||
console.error('[fetchProxyFontById] No fonts in response', { response });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return response.fonts.find(font => font.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple fonts by their IDs
|
||||
*
|
||||
* @param ids - Array of font IDs to fetch
|
||||
* @returns Promise resolving to an array of fonts
|
||||
*/
|
||||
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
// Use proxy API if enabled
|
||||
if (USE_PROXY_API) {
|
||||
const queryString = ids.join(',');
|
||||
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<UnifiedFont[]>(url);
|
||||
return response.data ?? [];
|
||||
} catch (error) {
|
||||
console.warn('[fetchFontsByIds] Proxy API batch fetch failed, falling back', error);
|
||||
// Fallthrough to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Fetch individually (not efficient but functional for fallback)
|
||||
const results = await Promise.all(
|
||||
ids.map(id => fetchProxyFontById(id)),
|
||||
);
|
||||
|
||||
return results.filter((f): f is UnifiedFont => !!f);
|
||||
}
|
||||
@@ -1,22 +1,136 @@
|
||||
// Proxy API (PRIMARY)
|
||||
export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
} from './api/proxy/proxyFonts';
|
||||
export type {
|
||||
ProxyFontsParams,
|
||||
ProxyFontsResponse,
|
||||
} from './api/proxy/proxyFonts';
|
||||
|
||||
// Fontshare API (DEPRECATED)
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
fetchFontshareFonts,
|
||||
} from './api/fontshare/fontshare';
|
||||
export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './api/fontshare/fontshare';
|
||||
|
||||
// Google Fonts API (DEPRECATED)
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
} from './api/google/googleFonts';
|
||||
export type {
|
||||
GoogleFontItem,
|
||||
GoogleFontsParams,
|
||||
GoogleFontsResponse,
|
||||
} from './api/google/googleFonts';
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './lib/normalize/normalize';
|
||||
export type {
|
||||
// Domain types
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
// Store types
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFiles,
|
||||
FontItem,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from './model/font';
|
||||
export type {
|
||||
// Fontshare API types
|
||||
FontshareApiModel,
|
||||
FontshareAxis,
|
||||
FontshareDesigner,
|
||||
FontshareFeature,
|
||||
FontshareFont,
|
||||
FontshareLink,
|
||||
FontsharePublisher,
|
||||
FontshareStyle,
|
||||
FontshareStyleProperties,
|
||||
FontshareTag,
|
||||
FontshareWeight,
|
||||
} from './model/fontshare_fonts';
|
||||
export type {
|
||||
FontFiles,
|
||||
FontItem,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
// Google Fonts API types
|
||||
GoogleFontsApiModel,
|
||||
} from './model/google_fonts';
|
||||
// Normalization types
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createUnifiedFontStore,
|
||||
unifiedFontStore,
|
||||
} from './model';
|
||||
|
||||
// Mock data helpers for Storybook and testing
|
||||
export {
|
||||
createCategoriesFilter,
|
||||
createErrorState,
|
||||
createGenericFilter,
|
||||
createLoadingState,
|
||||
createMockComparisonStore,
|
||||
// Filter mocks
|
||||
createMockFilter,
|
||||
createMockFontApiResponse,
|
||||
createMockFontStoreState,
|
||||
// Store mocks
|
||||
createMockQueryState,
|
||||
createMockReactiveState,
|
||||
createMockStore,
|
||||
createProvidersFilter,
|
||||
createSubsetsFilter,
|
||||
createSuccessState,
|
||||
FONTHARE_FONTS,
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
generatePaginatedFonts,
|
||||
generateSequentialFilter,
|
||||
GENERIC_FILTERS,
|
||||
getAllMockFonts,
|
||||
getFontsByCategory,
|
||||
getFontsByProvider,
|
||||
GOOGLE_FONTS,
|
||||
MOCK_FILTERS,
|
||||
MOCK_FILTERS_ALL_SELECTED,
|
||||
MOCK_FILTERS_EMPTY,
|
||||
MOCK_FILTERS_SELECTED,
|
||||
MOCK_FONT_STORE_STATES,
|
||||
MOCK_STORES,
|
||||
type MockFilterOptions,
|
||||
type MockFilters,
|
||||
mockFontshareFont,
|
||||
type MockFontshareFontOptions,
|
||||
type MockFontStoreState,
|
||||
// Font mocks
|
||||
mockGoogleFont,
|
||||
// Types
|
||||
type MockGoogleFontOptions,
|
||||
type MockQueryObserverResult,
|
||||
type MockQueryState,
|
||||
mockUnifiedFont,
|
||||
type MockUnifiedFontOptions,
|
||||
UNIFIED_FONTS,
|
||||
} from './lib/mocks';
|
||||
|
||||
// UI elements
|
||||
export {
|
||||
FontApplicator,
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
} from './ui';
|
||||
|
||||
592
src/entities/Font/lib/getFontUrl/getFontUrl.test.ts
Normal file
592
src/entities/Font/lib/getFontUrl/getFontUrl.test.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
import { getFontUrl } from './getFontUrl';
|
||||
|
||||
/**
|
||||
* Helper function to create a minimal UnifiedFont mock for testing
|
||||
*/
|
||||
function createMockFont(
|
||||
overrides: Partial<UnifiedFont> = {},
|
||||
): UnifiedFont {
|
||||
const baseFont: UnifiedFont = {
|
||||
id: 'test-font',
|
||||
name: 'Test Font',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: [],
|
||||
styles: {},
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
features: {
|
||||
isVariable: false,
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
|
||||
return { ...baseFont, ...overrides };
|
||||
}
|
||||
|
||||
describe('getFontUrl', () => {
|
||||
describe('basic logic', () => {
|
||||
it('returns URL for exact weight match in variants', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBe('https://example.com/font-400.woff2');
|
||||
});
|
||||
|
||||
it('returns URL for weight 700', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-700.woff2');
|
||||
});
|
||||
|
||||
it('returns URL for weight 100 (lightest)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'100': 'https://example.com/font-100.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 100);
|
||||
|
||||
expect(result).toBe('https://example.com/font-100.woff2');
|
||||
});
|
||||
|
||||
it('returns URL for weight 900 (boldest)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'900': 'https://example.com/font-900.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 900);
|
||||
|
||||
expect(result).toBe('https://example.com/font-900.woff2');
|
||||
});
|
||||
|
||||
it('returns URL for variable font (backend maps weight to VF URL)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-variable.woff2',
|
||||
'700': 'https://example.com/font-variable.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
|
||||
expect(result400).toBe('https://example.com/font-variable.woff2');
|
||||
expect(result700).toBe('https://example.com/font-variable.woff2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback logic', () => {
|
||||
it('falls back to regular when exact weight not found', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
regular: 'https://example.com/font-regular.woff2',
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('falls back to variant 400 when exact weight and regular not found', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-400.woff2');
|
||||
});
|
||||
|
||||
it('falls back to variant regular when exact weight, regular, and 400 not found', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
'regular': 'https://example.com/font-regular.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('prefers regular over variants.400 for fallback', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
regular: 'https://example.com/font-regular.woff2',
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('returns undefined when no fallback options available', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for font with empty styles', () => {
|
||||
const font = createMockFont({
|
||||
styles: {},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws error for font with undefined styles (invalid font data)', () => {
|
||||
const font = createMockFont({
|
||||
styles: undefined as any,
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 400)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles font with only regular URL (legacy format)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
regular: 'https://example.com/font-regular.woff2',
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('handles font with only variants object', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
|
||||
expect(result400).toBe('https://example.com/font-400.woff2');
|
||||
expect(result700).toBe('https://example.com/font-700.woff2');
|
||||
});
|
||||
|
||||
it('handles font with variants but no requested weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-400.woff2');
|
||||
});
|
||||
|
||||
it('handles Google Fonts style with legacy URLs', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
bold: 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2');
|
||||
});
|
||||
|
||||
it('handles Fontshare fonts with multiple weights', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'100': 'https://cdn.fontshare.com/wf/font-100.woff2',
|
||||
'200': 'https://cdn.fontshare.com/wf/font-200.woff2',
|
||||
'300': 'https://cdn.fontshare.com/wf/font-300.woff2',
|
||||
'400': 'https://cdn.fontshare.com/wf/font-400.woff2',
|
||||
'500': 'https://cdn.fontshare.com/wf/font-500.woff2',
|
||||
'600': 'https://cdn.fontshare.com/wf/font-600.woff2',
|
||||
'700': 'https://cdn.fontshare.com/wf/font-700.woff2',
|
||||
'800': 'https://cdn.fontshare.com/wf/font-800.woff2',
|
||||
'900': 'https://cdn.fontshare.com/wf/font-900.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Test all valid weights
|
||||
for (const weight of [100, 200, 300, 400, 500, 600, 700, 800, 900]) {
|
||||
const result = getFontUrl(font, weight);
|
||||
expect(result).toBe(`https://cdn.fontshare.com/wf/font-${weight}.woff2`);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles font with partial weight coverage', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-regular.woff2',
|
||||
'700': 'https://example.com/font-bold.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
const result500 = getFontUrl(font, 500);
|
||||
|
||||
expect(result400).toBe('https://example.com/font-regular.woff2');
|
||||
expect(result700).toBe('https://example.com/font-bold.woff2');
|
||||
expect(result500).toBe('https://example.com/font-regular.woff2'); // Fallback
|
||||
});
|
||||
|
||||
it('handles font with variants.regular as fallback', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'700': 'https://example.com/font-bold.woff2',
|
||||
'regular': 'https://example.com/font-regular.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('handles empty variants object', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when variant URL is null and no fallback available', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': null as any,
|
||||
'700': 'https://example.com/font-bold.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
// null is falsy, so it falls back to regular, 400, and then regular variant
|
||||
// All are undefined, so returns undefined
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundary tests', () => {
|
||||
it('handles lowest valid weight (100)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'100': 'https://example.com/font-100.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 100);
|
||||
|
||||
expect(result).toBe('https://example.com/font-100.woff2');
|
||||
});
|
||||
|
||||
it('handles highest valid weight (900)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'900': 'https://example.com/font-900.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 900);
|
||||
|
||||
expect(result).toBe('https://example.com/font-900.woff2');
|
||||
});
|
||||
|
||||
it('handles middle weight (500)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'500': 'https://example.com/font-500.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 500);
|
||||
|
||||
expect(result).toBe('https://example.com/font-500.woff2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid weights', () => {
|
||||
it('throws error for weight below 100', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 99)).toThrow('Invalid weight: 99');
|
||||
});
|
||||
|
||||
it('throws error for weight above 900', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 901)).toThrow('Invalid weight: 901');
|
||||
});
|
||||
|
||||
it('throws error for weight 0', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 0)).toThrow('Invalid weight: 0');
|
||||
});
|
||||
|
||||
it('throws error for negative weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, -100)).toThrow('Invalid weight: -100');
|
||||
});
|
||||
|
||||
it('throws error for non-numeric weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore - Testing invalid input type
|
||||
expect(() => getFontUrl(font, '400' as any)).toThrow('Invalid weight: 400');
|
||||
});
|
||||
|
||||
it('throws error for decimal weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 450.5)).toThrow('Invalid weight: 450.5');
|
||||
});
|
||||
|
||||
it('throws error for weight with step of 50 (not supported)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 450)).toThrow('Invalid weight: 450');
|
||||
});
|
||||
|
||||
it('throws error for weight with step of 10 (not supported)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 410)).toThrow('Invalid weight: 410');
|
||||
});
|
||||
|
||||
it('throws error for NaN weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, NaN)).toThrow('Invalid weight: NaN');
|
||||
});
|
||||
|
||||
it('throws error for Infinity weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, Infinity)).toThrow('Invalid weight: Infinity');
|
||||
});
|
||||
|
||||
it('throws descriptive error message', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
getFontUrl(font, 999);
|
||||
expect.fail('Expected function to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('Invalid weight: 999');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider-specific tests', () => {
|
||||
it('handles Google Fonts with variable fonts', () => {
|
||||
const font = createMockFont({
|
||||
provider: 'google',
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
|
||||
// Variable fonts return the same URL for all weights
|
||||
expect(result400).toBe(result700);
|
||||
});
|
||||
|
||||
it('handles Fontshare fonts with static weights', () => {
|
||||
const font = createMockFont({
|
||||
provider: 'fontshare',
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://cdn.fontshare.com/wf/satoshi-regular.woff2',
|
||||
'700': 'https://cdn.fontshare.com/wf/satoshi-bold.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
|
||||
expect(result400).toBe('https://cdn.fontshare.com/wf/satoshi-regular.woff2');
|
||||
expect(result700).toBe('https://cdn.fontshare.com/wf/satoshi-bold.woff2');
|
||||
expect(result400).not.toBe(result700);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all valid weights test', () => {
|
||||
it('handles all valid weight values', () => {
|
||||
const validWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
|
||||
validWeights.forEach(weight => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
[weight.toString()]: `https://example.com/font-${weight}.woff2`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, weight);
|
||||
expect(result).toBe(`https://example.com/font-${weight}.woff2`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
29
src/entities/Font/lib/getFontUrl/getFontUrl.ts
Normal file
29
src/entities/Font/lib/getFontUrl/getFontUrl.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
FontWeight,
|
||||
UnifiedFont,
|
||||
} from '../../model';
|
||||
|
||||
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
|
||||
/**
|
||||
* Constructs a URL for a font based on the provided font and weight.
|
||||
* @param font - The font object.
|
||||
* @param weight - The weight of the font.
|
||||
* @returns The URL for the font.
|
||||
*/
|
||||
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
|
||||
if (!SIZES.includes(weight)) {
|
||||
throw new Error(`Invalid weight: ${weight}`);
|
||||
}
|
||||
|
||||
const weightKey = weight.toString() as FontWeight;
|
||||
|
||||
// 1. Try exact match (Backend now maps "100".."900" to VF URL if variable)
|
||||
if (font.styles.variants?.[weightKey]) {
|
||||
return font.styles.variants[weightKey];
|
||||
}
|
||||
|
||||
// 2. Fallbacks for Static Fonts (if exact weight missing)
|
||||
// Try 'regular' or '400' as safe defaults
|
||||
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
|
||||
}
|
||||
58
src/entities/Font/lib/index.ts
Normal file
58
src/entities/Font/lib/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './normalize/normalize';
|
||||
|
||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
||||
|
||||
// Mock data helpers for Storybook and testing
|
||||
export {
|
||||
createCategoriesFilter,
|
||||
createErrorState,
|
||||
createGenericFilter,
|
||||
createLoadingState,
|
||||
createMockComparisonStore,
|
||||
// Filter mocks
|
||||
createMockFilter,
|
||||
createMockFontApiResponse,
|
||||
createMockFontStoreState,
|
||||
// Store mocks
|
||||
createMockQueryState,
|
||||
createMockReactiveState,
|
||||
createMockStore,
|
||||
createProvidersFilter,
|
||||
createSubsetsFilter,
|
||||
createSuccessState,
|
||||
FONTHARE_FONTS,
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
generatePaginatedFonts,
|
||||
generateSequentialFilter,
|
||||
GENERIC_FILTERS,
|
||||
getAllMockFonts,
|
||||
getFontsByCategory,
|
||||
getFontsByProvider,
|
||||
GOOGLE_FONTS,
|
||||
MOCK_FILTERS,
|
||||
MOCK_FILTERS_ALL_SELECTED,
|
||||
MOCK_FILTERS_EMPTY,
|
||||
MOCK_FILTERS_SELECTED,
|
||||
MOCK_FONT_STORE_STATES,
|
||||
MOCK_STORES,
|
||||
type MockFilterOptions,
|
||||
type MockFilters,
|
||||
mockFontshareFont,
|
||||
type MockFontshareFontOptions,
|
||||
type MockFontStoreState,
|
||||
// Font mocks
|
||||
mockGoogleFont,
|
||||
// Types
|
||||
type MockGoogleFontOptions,
|
||||
type MockQueryObserverResult,
|
||||
type MockQueryState,
|
||||
mockUnifiedFont,
|
||||
type MockUnifiedFontOptions,
|
||||
UNIFIED_FONTS,
|
||||
} from './mocks';
|
||||
348
src/entities/Font/lib/mocks/filters.mock.ts
Normal file
348
src/entities/Font/lib/mocks/filters.mock.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK FONT FILTER DATA
|
||||
* ============================================================================
|
||||
*
|
||||
* Factory functions and preset mock data for font-related filters.
|
||||
* Used in Storybook stories for font filtering components.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```ts
|
||||
* import {
|
||||
* createMockFilter,
|
||||
* MOCK_FILTERS,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
*
|
||||
* // Create a custom filter
|
||||
* const customFilter = createMockFilter({
|
||||
* properties: [
|
||||
* { id: 'option1', name: 'Option 1', value: 'option1' },
|
||||
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
|
||||
* ],
|
||||
* });
|
||||
*
|
||||
* // Use preset filters
|
||||
* const categoriesFilter = MOCK_FILTERS.categories;
|
||||
* const subsetsFilter = MOCK_FILTERS.subsets;
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '$entities/Font/model/types';
|
||||
import type { Property } from '$shared/lib';
|
||||
import { createFilter } from '$shared/lib';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Options for creating a mock filter
|
||||
*/
|
||||
export interface MockFilterOptions {
|
||||
/** Filter properties */
|
||||
properties: Property<string>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset mock filters for font filtering
|
||||
*/
|
||||
export interface MockFilters {
|
||||
/** Provider filter (Google, Fontshare) */
|
||||
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
|
||||
/** Category filter (sans-serif, serif, display, etc.) */
|
||||
categories: ReturnType<typeof createFilter<FontCategory>>;
|
||||
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
|
||||
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FONT CATEGORIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Google Fonts categories
|
||||
*/
|
||||
export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [
|
||||
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||
{ id: 'display', name: 'Display', value: 'display' },
|
||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Fontshare categories (mapped to common naming)
|
||||
*/
|
||||
export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [
|
||||
{ id: 'sans', name: 'Sans', value: 'sans' },
|
||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||
{ id: 'slab', name: 'Slab', value: 'slab' },
|
||||
{ id: 'display', name: 'Display', value: 'display' },
|
||||
{ id: 'handwritten', name: 'Handwritten', value: 'handwritten' },
|
||||
{ id: 'script', name: 'Script', value: 'script' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Unified categories (combines both providers)
|
||||
*/
|
||||
export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
||||
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||
{ id: 'display', name: 'Display', value: 'display' },
|
||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// FONT SUBSETS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common font subsets
|
||||
*/
|
||||
export const FONT_SUBSETS: Property<FontSubset>[] = [
|
||||
{ id: 'latin', name: 'Latin', value: 'latin' },
|
||||
{ id: 'latin-ext', name: 'Latin Extended', value: 'latin-ext' },
|
||||
{ id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' },
|
||||
{ id: 'greek', name: 'Greek', value: 'greek' },
|
||||
{ id: 'arabic', name: 'Arabic', value: 'arabic' },
|
||||
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// FONT PROVIDERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Font providers
|
||||
*/
|
||||
export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
||||
{ id: 'google', name: 'Google Fonts', value: 'google' },
|
||||
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// FILTER FACTORIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock filter from properties
|
||||
*/
|
||||
export function createMockFilter<TValue extends string>(
|
||||
options: MockFilterOptions & { properties: Property<TValue>[] },
|
||||
) {
|
||||
return createFilter<TValue>(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock filter for categories
|
||||
*/
|
||||
export function createCategoriesFilter(options?: { selected?: FontCategory[] }) {
|
||||
const properties = UNIFIED_CATEGORIES.map(cat => ({
|
||||
...cat,
|
||||
selected: options?.selected?.includes(cat.value) ?? false,
|
||||
}));
|
||||
return createFilter<FontCategory>({ properties });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock filter for subsets
|
||||
*/
|
||||
export function createSubsetsFilter(options?: { selected?: FontSubset[] }) {
|
||||
const properties = FONT_SUBSETS.map(subset => ({
|
||||
...subset,
|
||||
selected: options?.selected?.includes(subset.value) ?? false,
|
||||
}));
|
||||
return createFilter<FontSubset>({ properties });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock filter for providers
|
||||
*/
|
||||
export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
|
||||
const properties = FONT_PROVIDERS.map(provider => ({
|
||||
...provider,
|
||||
selected: options?.selected?.includes(provider.value) ?? false,
|
||||
}));
|
||||
return createFilter<FontProvider>({ properties });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PRESET FILTERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Preset mock filters - use these directly in stories
|
||||
*/
|
||||
export const MOCK_FILTERS: MockFilters = {
|
||||
providers: createFilter({
|
||||
properties: FONT_PROVIDERS,
|
||||
}),
|
||||
categories: createFilter({
|
||||
properties: UNIFIED_CATEGORIES,
|
||||
}),
|
||||
subsets: createFilter({
|
||||
properties: FONT_SUBSETS,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Preset filters with some items selected
|
||||
*/
|
||||
export const MOCK_FILTERS_SELECTED: MockFilters = {
|
||||
providers: createFilter({
|
||||
properties: [
|
||||
{ ...FONT_PROVIDERS[0], selected: true },
|
||||
{ ...FONT_PROVIDERS[1] },
|
||||
],
|
||||
}),
|
||||
categories: createFilter({
|
||||
properties: [
|
||||
{ ...UNIFIED_CATEGORIES[0], selected: true },
|
||||
{ ...UNIFIED_CATEGORIES[1], selected: true },
|
||||
{ ...UNIFIED_CATEGORIES[2] },
|
||||
{ ...UNIFIED_CATEGORIES[3] },
|
||||
{ ...UNIFIED_CATEGORIES[4] },
|
||||
],
|
||||
}),
|
||||
subsets: createFilter({
|
||||
properties: [
|
||||
{ ...FONT_SUBSETS[0], selected: true },
|
||||
{ ...FONT_SUBSETS[1] },
|
||||
{ ...FONT_SUBSETS[2] },
|
||||
{ ...FONT_SUBSETS[3] },
|
||||
{ ...FONT_SUBSETS[4] },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Empty filters (all properties, none selected)
|
||||
*/
|
||||
export const MOCK_FILTERS_EMPTY: MockFilters = {
|
||||
providers: createFilter({
|
||||
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: false })),
|
||||
}),
|
||||
categories: createFilter({
|
||||
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: false })),
|
||||
}),
|
||||
subsets: createFilter({
|
||||
properties: FONT_SUBSETS.map(s => ({ ...s, selected: false })),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* All selected filters
|
||||
*/
|
||||
export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
|
||||
providers: createFilter({
|
||||
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: true })),
|
||||
}),
|
||||
categories: createFilter({
|
||||
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: true })),
|
||||
}),
|
||||
subsets: createFilter({
|
||||
properties: FONT_SUBSETS.map(s => ({ ...s, selected: true })),
|
||||
}),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GENERIC FILTER MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock filter with generic string properties
|
||||
* Useful for testing generic filter components
|
||||
*/
|
||||
export function createGenericFilter(
|
||||
items: Array<{ id: string; name: string; selected?: boolean }>,
|
||||
options?: { selected?: string[] },
|
||||
) {
|
||||
const properties = items.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
value: item.id,
|
||||
selected: options?.selected?.includes(item.id) ?? item.selected ?? false,
|
||||
}));
|
||||
return createFilter({ properties });
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset generic filters for testing
|
||||
*/
|
||||
export const GENERIC_FILTERS = {
|
||||
/** Small filter with 3 items */
|
||||
small: createFilter({
|
||||
properties: [
|
||||
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
|
||||
{ id: 'option-2', name: 'Option 2', value: 'option-2' },
|
||||
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
|
||||
],
|
||||
}),
|
||||
/** Medium filter with 6 items */
|
||||
medium: createFilter({
|
||||
properties: [
|
||||
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
|
||||
{ id: 'beta', name: 'Beta', value: 'beta' },
|
||||
{ id: 'gamma', name: 'Gamma', value: 'gamma' },
|
||||
{ id: 'delta', name: 'Delta', value: 'delta' },
|
||||
{ id: 'epsilon', name: 'Epsilon', value: 'epsilon' },
|
||||
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
|
||||
],
|
||||
}),
|
||||
/** Large filter with 12 items */
|
||||
large: createFilter({
|
||||
properties: [
|
||||
{ id: 'jan', name: 'January', value: 'jan' },
|
||||
{ id: 'feb', name: 'February', value: 'feb' },
|
||||
{ id: 'mar', name: 'March', value: 'mar' },
|
||||
{ id: 'apr', name: 'April', value: 'apr' },
|
||||
{ id: 'may', name: 'May', value: 'may' },
|
||||
{ id: 'jun', name: 'June', value: 'jun' },
|
||||
{ id: 'jul', name: 'July', value: 'jul' },
|
||||
{ id: 'aug', name: 'August', value: 'aug' },
|
||||
{ id: 'sep', name: 'September', value: 'sep' },
|
||||
{ id: 'oct', name: 'October', value: 'oct' },
|
||||
{ id: 'nov', name: 'November', value: 'nov' },
|
||||
{ id: 'dec', name: 'December', value: 'dec' },
|
||||
],
|
||||
}),
|
||||
/** Filter with some pre-selected items */
|
||||
partial: createFilter({
|
||||
properties: [
|
||||
{ id: 'red', name: 'Red', value: 'red', selected: true },
|
||||
{ id: 'blue', name: 'Blue', value: 'blue', selected: false },
|
||||
{ id: 'green', name: 'Green', value: 'green', selected: true },
|
||||
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
|
||||
],
|
||||
}),
|
||||
/** Filter with all items selected */
|
||||
allSelected: createFilter({
|
||||
properties: [
|
||||
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
|
||||
{ id: 'dog', name: 'Dog', value: 'dog', selected: true },
|
||||
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
|
||||
],
|
||||
}),
|
||||
/** Empty filter (no items) */
|
||||
empty: createFilter({
|
||||
properties: [],
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a filter with sequential items
|
||||
*/
|
||||
export function generateSequentialFilter(count: number, prefix = 'Item ') {
|
||||
const properties = Array.from({ length: count }, (_, i) => ({
|
||||
id: `item-${i + 1}`,
|
||||
name: `${prefix}${i + 1}`,
|
||||
value: `item-${i + 1}`,
|
||||
}));
|
||||
return createFilter({ properties });
|
||||
}
|
||||
630
src/entities/Font/lib/mocks/fonts.mock.ts
Normal file
630
src/entities/Font/lib/mocks/fonts.mock.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK FONT DATA
|
||||
* ============================================================================
|
||||
*
|
||||
* Factory functions and preset mock data for fonts.
|
||||
* Used in Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```ts
|
||||
* import {
|
||||
* mockGoogleFont,
|
||||
* mockFontshareFont,
|
||||
* mockUnifiedFont,
|
||||
* GOOGLE_FONTS,
|
||||
* FONTHARE_FONTS,
|
||||
* UNIFIED_FONTS,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
*
|
||||
* // Create a mock Google Font
|
||||
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
||||
*
|
||||
* // Create a mock Fontshare font
|
||||
* const satoshi = mockFontshareFont({ name: 'Satoshi', slug: 'satoshi' });
|
||||
*
|
||||
* // Create a mock UnifiedFont
|
||||
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
||||
*
|
||||
* // Use preset fonts
|
||||
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
} from '$entities/Font/model/types';
|
||||
import type {
|
||||
FontItem,
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
} from '$entities/Font/model/types';
|
||||
import type {
|
||||
FontFeatures,
|
||||
FontMetadata,
|
||||
FontStyleUrls,
|
||||
UnifiedFont,
|
||||
} from '$entities/Font/model/types';
|
||||
|
||||
// ============================================================================
|
||||
// GOOGLE FONTS MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Options for creating a mock Google Font
|
||||
*/
|
||||
export interface MockGoogleFontOptions {
|
||||
/** Font family name (default: 'Mock Font') */
|
||||
family?: string;
|
||||
/** Font category (default: 'sans-serif') */
|
||||
category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
||||
variants?: FontVariant[];
|
||||
/** Font subsets (default: ['latin']) */
|
||||
subsets?: string[];
|
||||
/** Font version (default: 'v30') */
|
||||
version?: string;
|
||||
/** Last modified date (default: current ISO date) */
|
||||
lastModified?: string;
|
||||
/** Custom file URLs (if not provided, mock URLs are generated) */
|
||||
files?: Partial<Record<FontVariant, string>>;
|
||||
/** Popularity rank (1 = most popular) */
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mock Google Font
|
||||
*/
|
||||
export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem {
|
||||
const {
|
||||
family = 'Mock Font',
|
||||
category = 'sans-serif',
|
||||
variants = ['regular', '700', 'italic', '700italic'],
|
||||
subsets = ['latin'],
|
||||
version = 'v30',
|
||||
lastModified = new Date().toISOString().split('T')[0],
|
||||
files,
|
||||
popularity = 1,
|
||||
} = options;
|
||||
|
||||
const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`;
|
||||
|
||||
return {
|
||||
family,
|
||||
category,
|
||||
variants: variants as FontVariant[],
|
||||
subsets,
|
||||
version,
|
||||
lastModified,
|
||||
files: files ?? {
|
||||
regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`,
|
||||
'700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`,
|
||||
italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`,
|
||||
'700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`,
|
||||
},
|
||||
menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset Google Font mocks
|
||||
*/
|
||||
export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
|
||||
roboto: mockGoogleFont({
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
||||
popularity: 1,
|
||||
}),
|
||||
openSans: mockGoogleFont({
|
||||
family: 'Open Sans',
|
||||
category: 'sans-serif',
|
||||
variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
||||
popularity: 2,
|
||||
}),
|
||||
lato: mockGoogleFont({
|
||||
family: 'Lato',
|
||||
category: 'sans-serif',
|
||||
variants: ['100', '300', '400', '700', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
popularity: 3,
|
||||
}),
|
||||
playfairDisplay: mockGoogleFont({
|
||||
family: 'Playfair Display',
|
||||
category: 'serif',
|
||||
variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic'],
|
||||
popularity: 10,
|
||||
}),
|
||||
montserrat: mockGoogleFont({
|
||||
family: 'Montserrat',
|
||||
category: 'sans-serif',
|
||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||
popularity: 4,
|
||||
}),
|
||||
sourceSansPro: mockGoogleFont({
|
||||
family: 'Source Sans Pro',
|
||||
category: 'sans-serif',
|
||||
variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
||||
popularity: 5,
|
||||
}),
|
||||
merriweather: mockGoogleFont({
|
||||
family: 'Merriweather',
|
||||
category: 'serif',
|
||||
variants: ['300', '400', '700', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||
popularity: 15,
|
||||
}),
|
||||
robotoSlab: mockGoogleFont({
|
||||
family: 'Roboto Slab',
|
||||
category: 'serif',
|
||||
variants: ['100', '300', '400', '500', '700', '900'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
||||
popularity: 8,
|
||||
}),
|
||||
oswald: mockGoogleFont({
|
||||
family: 'Oswald',
|
||||
category: 'sans-serif',
|
||||
variants: ['200', '300', '400', '500', '600', '700'],
|
||||
subsets: ['latin', 'latin-ext', 'vietnamese'],
|
||||
popularity: 6,
|
||||
}),
|
||||
raleway: mockGoogleFont({
|
||||
family: 'Raleway',
|
||||
category: 'sans-serif',
|
||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||
popularity: 7,
|
||||
}),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// FONTHARE MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Options for creating a mock Fontshare font
|
||||
*/
|
||||
export interface MockFontshareFontOptions {
|
||||
/** Font name (default: 'Mock Font') */
|
||||
name?: string;
|
||||
/** URL-friendly slug (default: derived from name) */
|
||||
slug?: string;
|
||||
/** Font category (default: 'sans') */
|
||||
category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono';
|
||||
/** Script (default: 'latin') */
|
||||
script?: string;
|
||||
/** Whether this is a variable font (default: false) */
|
||||
isVariable?: boolean;
|
||||
/** Font version (default: '1.0') */
|
||||
version?: string;
|
||||
/** Popularity/views count (default: 1000) */
|
||||
views?: number;
|
||||
/** Usage tags */
|
||||
tags?: string[];
|
||||
/** Font weights available */
|
||||
weights?: number[];
|
||||
/** Publisher name */
|
||||
publisher?: string;
|
||||
/** Designer name */
|
||||
designer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Fontshare style
|
||||
*/
|
||||
function mockFontshareStyle(
|
||||
weight: number,
|
||||
isItalic: boolean,
|
||||
isVariable: boolean,
|
||||
slug: string,
|
||||
): FontshareFont['styles'][number] {
|
||||
const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString();
|
||||
const suffix = isItalic ? 'italic' : '';
|
||||
const variablePrefix = isVariable ? 'variable-' : '';
|
||||
|
||||
return {
|
||||
id: `style-${weight}${isItalic ? '-italic' : ''}`,
|
||||
default: weight === 400 && !isItalic,
|
||||
file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`,
|
||||
is_italic: isItalic,
|
||||
is_variable: isVariable,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel,
|
||||
name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel,
|
||||
native_name: null,
|
||||
number: isVariable ? 0 : weight,
|
||||
weight: isVariable ? 0 : weight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mock Fontshare font
|
||||
*/
|
||||
export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont {
|
||||
const {
|
||||
name = 'Mock Font',
|
||||
slug = name.toLowerCase().replace(/\s+/g, '-'),
|
||||
category = 'sans',
|
||||
script = 'latin',
|
||||
isVariable = false,
|
||||
version = '1.0',
|
||||
views = 1000,
|
||||
tags = [],
|
||||
weights = [400, 700],
|
||||
publisher = 'Mock Foundry',
|
||||
designer = 'Mock Designer',
|
||||
} = options;
|
||||
|
||||
// Generate styles based on weights and variable setting
|
||||
const styles: FontshareFont['styles'] = isVariable
|
||||
? [
|
||||
mockFontshareStyle(0, false, true, slug),
|
||||
mockFontshareStyle(0, true, true, slug),
|
||||
]
|
||||
: weights.flatMap(weight => [
|
||||
mockFontshareStyle(weight, false, false, slug),
|
||||
mockFontshareStyle(weight, true, false, slug),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: `mock-${slug}`,
|
||||
name,
|
||||
native_name: null,
|
||||
slug,
|
||||
category,
|
||||
script,
|
||||
publisher: {
|
||||
bio: `Mock publisher bio for ${publisher}`,
|
||||
email: null,
|
||||
id: `pub-${slug}`,
|
||||
links: [],
|
||||
name: publisher,
|
||||
},
|
||||
designers: [
|
||||
{
|
||||
bio: `Mock designer bio for ${designer}`,
|
||||
links: [],
|
||||
name: designer,
|
||||
},
|
||||
],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: true,
|
||||
show_latin_metrics: false,
|
||||
license_type: 'ofl',
|
||||
languages: 'English, Spanish, French, German',
|
||||
inserted_at: '2021-03-12T20:49:05Z',
|
||||
story: `<p>A mock font story for ${name}.</p>`,
|
||||
version,
|
||||
views,
|
||||
views_recent: Math.floor(views * 0.1),
|
||||
is_hot: views > 5000,
|
||||
is_new: views < 500,
|
||||
is_shortlisted: null,
|
||||
is_top: views > 10000,
|
||||
axes: isVariable
|
||||
? [
|
||||
{
|
||||
name: 'Weight',
|
||||
property: 'wght',
|
||||
range_default: 400,
|
||||
range_left: 300,
|
||||
range_right: 700,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
font_tags: tags.map(name => ({ name })),
|
||||
features: [],
|
||||
styles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset Fontshare font mocks
|
||||
*/
|
||||
export const FONTHARE_FONTS: Record<string, FontshareFont> = {
|
||||
satoshi: mockFontshareFont({
|
||||
name: 'Satoshi',
|
||||
slug: 'satoshi',
|
||||
category: 'sans',
|
||||
isVariable: true,
|
||||
views: 15000,
|
||||
tags: ['Branding', 'Logos', 'Editorial'],
|
||||
publisher: 'Indian Type Foundry',
|
||||
designer: 'Denis Shelabovets',
|
||||
}),
|
||||
generalSans: mockFontshareFont({
|
||||
name: 'General Sans',
|
||||
slug: 'general-sans',
|
||||
category: 'sans',
|
||||
isVariable: true,
|
||||
views: 12000,
|
||||
tags: ['UI', 'Branding', 'Display'],
|
||||
publisher: 'Indestructible Type',
|
||||
designer: 'Eugene Tantsur',
|
||||
}),
|
||||
clashDisplay: mockFontshareFont({
|
||||
name: 'Clash Display',
|
||||
slug: 'clash-display',
|
||||
category: 'display',
|
||||
isVariable: false,
|
||||
views: 8000,
|
||||
tags: ['Headlines', 'Posters', 'Branding'],
|
||||
weights: [400, 500, 600, 700],
|
||||
publisher: 'Letterogika',
|
||||
designer: 'Matěj Trnka',
|
||||
}),
|
||||
fonta: mockFontshareFont({
|
||||
name: 'Fonta',
|
||||
slug: 'fonta',
|
||||
category: 'serif',
|
||||
isVariable: false,
|
||||
views: 5000,
|
||||
tags: ['Editorial', 'Books', 'Magazines'],
|
||||
weights: [300, 400, 500, 600, 700],
|
||||
publisher: 'Fonta',
|
||||
designer: 'Alexei Vanyashin',
|
||||
}),
|
||||
aileron: mockFontshareFont({
|
||||
name: 'Aileron',
|
||||
slug: 'aileron',
|
||||
category: 'sans',
|
||||
isVariable: false,
|
||||
views: 3000,
|
||||
tags: ['Display', 'Headlines'],
|
||||
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
publisher: 'Sorkin Type',
|
||||
designer: 'Sorkin Type',
|
||||
}),
|
||||
beVietnamPro: mockFontshareFont({
|
||||
name: 'Be Vietnam Pro',
|
||||
slug: 'be-vietnam-pro',
|
||||
category: 'sans',
|
||||
isVariable: true,
|
||||
views: 20000,
|
||||
tags: ['UI', 'App', 'Web'],
|
||||
publisher: 'ildefox',
|
||||
designer: 'Manh Nguyen',
|
||||
}),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// UNIFIED FONT MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Options for creating a mock UnifiedFont
|
||||
*/
|
||||
export interface MockUnifiedFontOptions {
|
||||
/** Unique identifier (default: derived from name) */
|
||||
id?: string;
|
||||
/** Font display name (default: 'Mock Font') */
|
||||
name?: string;
|
||||
/** Font provider (default: 'google') */
|
||||
provider?: FontProvider;
|
||||
/** Font category (default: 'sans-serif') */
|
||||
category?: FontCategory;
|
||||
/** Font subsets (default: ['latin']) */
|
||||
subsets?: FontSubset[];
|
||||
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
||||
variants?: FontVariant[];
|
||||
/** Style URLs (if not provided, mock URLs are generated) */
|
||||
styles?: FontStyleUrls;
|
||||
/** Metadata overrides */
|
||||
metadata?: Partial<FontMetadata>;
|
||||
/** Features overrides */
|
||||
features?: Partial<FontFeatures>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mock UnifiedFont
|
||||
*/
|
||||
export function mockUnifiedFont(options: MockUnifiedFontOptions = {}): UnifiedFont {
|
||||
const {
|
||||
id,
|
||||
name = 'Mock Font',
|
||||
provider = 'google',
|
||||
category = 'sans-serif',
|
||||
subsets = ['latin'],
|
||||
variants = ['regular', '700', 'italic', '700italic'],
|
||||
styles,
|
||||
metadata,
|
||||
features,
|
||||
} = options;
|
||||
|
||||
const fontId = id ?? name.toLowerCase().replace(/\s+/g, '');
|
||||
const baseUrl = provider === 'google'
|
||||
? `https://fonts.gstatic.com/s/${fontId}/v30`
|
||||
: `//cdn.fontshare.com/wf/${fontId}`;
|
||||
|
||||
return {
|
||||
id: fontId,
|
||||
name,
|
||||
provider,
|
||||
category,
|
||||
subsets,
|
||||
variants: variants as FontVariant[],
|
||||
styles: styles ?? {
|
||||
regular: `${baseUrl}/regular.woff2`,
|
||||
bold: `${baseUrl}/bold.woff2`,
|
||||
italic: `${baseUrl}/italic.woff2`,
|
||||
boldItalic: `${baseUrl}/bolditalic.woff2`,
|
||||
},
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: '1.0',
|
||||
lastModified: new Date().toISOString().split('T')[0],
|
||||
popularity: 1,
|
||||
...metadata,
|
||||
},
|
||||
features: {
|
||||
isVariable: false,
|
||||
...features,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset UnifiedFont mocks
|
||||
*/
|
||||
export const UNIFIED_FONTS: Record<string, UnifiedFont> = {
|
||||
roboto: mockUnifiedFont({
|
||||
id: 'roboto',
|
||||
name: 'Roboto',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
variants: ['100', '300', '400', '500', '700', '900'],
|
||||
metadata: { popularity: 1 },
|
||||
}),
|
||||
openSans: mockUnifiedFont({
|
||||
id: 'open-sans',
|
||||
name: 'Open Sans',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
variants: ['300', '400', '500', '600', '700', '800'],
|
||||
metadata: { popularity: 2 },
|
||||
}),
|
||||
lato: mockUnifiedFont({
|
||||
id: 'lato',
|
||||
name: 'Lato',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
variants: ['100', '300', '400', '700', '900'],
|
||||
metadata: { popularity: 3 },
|
||||
}),
|
||||
playfairDisplay: mockUnifiedFont({
|
||||
id: 'playfair-display',
|
||||
name: 'Playfair Display',
|
||||
provider: 'google',
|
||||
category: 'serif',
|
||||
subsets: ['latin'],
|
||||
variants: ['400', '700', '900'],
|
||||
metadata: { popularity: 10 },
|
||||
}),
|
||||
montserrat: mockUnifiedFont({
|
||||
id: 'montserrat',
|
||||
name: 'Montserrat',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||
metadata: { popularity: 4 },
|
||||
}),
|
||||
satoshi: mockUnifiedFont({
|
||||
id: 'satoshi',
|
||||
name: 'Satoshi',
|
||||
provider: 'fontshare',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
|
||||
features: { isVariable: true, axes: [{ name: 'wght', property: 'wght', default: 400, min: 300, max: 700 }] },
|
||||
metadata: { popularity: 15000 },
|
||||
}),
|
||||
generalSans: mockUnifiedFont({
|
||||
id: 'general-sans',
|
||||
name: 'General Sans',
|
||||
provider: 'fontshare',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
|
||||
features: { isVariable: true },
|
||||
metadata: { popularity: 12000 },
|
||||
}),
|
||||
clashDisplay: mockUnifiedFont({
|
||||
id: 'clash-display',
|
||||
name: 'Clash Display',
|
||||
provider: 'fontshare',
|
||||
category: 'display',
|
||||
subsets: ['latin'],
|
||||
variants: ['regular', '500', '600', 'bold'] as FontVariant[],
|
||||
features: { tags: ['Headlines', 'Posters', 'Branding'] },
|
||||
metadata: { popularity: 8000 },
|
||||
}),
|
||||
oswald: mockUnifiedFont({
|
||||
id: 'oswald',
|
||||
name: 'Oswald',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: ['200', '300', '400', '500', '600', '700'],
|
||||
metadata: { popularity: 6 },
|
||||
}),
|
||||
raleway: mockUnifiedFont({
|
||||
id: 'raleway',
|
||||
name: 'Raleway',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||
metadata: { popularity: 7 },
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an array of all preset UnifiedFonts
|
||||
*/
|
||||
export function getAllMockFonts(): UnifiedFont[] {
|
||||
return Object.values(UNIFIED_FONTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fonts by provider
|
||||
*/
|
||||
export function getFontsByProvider(provider: FontProvider): UnifiedFont[] {
|
||||
return getAllMockFonts().filter(font => font.provider === provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fonts by category
|
||||
*/
|
||||
export function getFontsByCategory(category: FontCategory): UnifiedFont[] {
|
||||
return getAllMockFonts().filter(font => font.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an array of mock fonts with sequential naming
|
||||
*/
|
||||
export function generateMockFonts(count: number, options?: Omit<MockUnifiedFontOptions, 'id' | 'name'>): UnifiedFont[] {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
mockUnifiedFont({
|
||||
...options,
|
||||
id: `mock-font-${i + 1}`,
|
||||
name: `Mock Font ${i + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an array of mock fonts with different categories
|
||||
*/
|
||||
export function generateMixedCategoryFonts(countPerCategory: number = 2): UnifiedFont[] {
|
||||
const categories: FontCategory[] = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];
|
||||
const fonts: UnifiedFont[] = [];
|
||||
|
||||
categories.forEach(category => {
|
||||
for (let i = 0; i < countPerCategory; i++) {
|
||||
fonts.push(
|
||||
mockUnifiedFont({
|
||||
id: `${category}-${i + 1}`,
|
||||
name: `${category.replace('-', ' ')} ${i + 1}`,
|
||||
category,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return fonts;
|
||||
}
|
||||
84
src/entities/Font/lib/mocks/index.ts
Normal file
84
src/entities/Font/lib/mocks/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK DATA HELPERS - MAIN EXPORT
|
||||
* ============================================================================
|
||||
*
|
||||
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Quick Start
|
||||
*
|
||||
* ```ts
|
||||
* import {
|
||||
* mockUnifiedFont,
|
||||
* UNIFIED_FONTS,
|
||||
* MOCK_FILTERS,
|
||||
* createMockFontStoreState,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
*
|
||||
* // Use in stories
|
||||
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
||||
* const presets = UNIFIED_FONTS;
|
||||
* const filter = MOCK_FILTERS.categories;
|
||||
* ```
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
// Font mocks
|
||||
export {
|
||||
FONTHARE_FONTS,
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
getAllMockFonts,
|
||||
getFontsByCategory,
|
||||
getFontsByProvider,
|
||||
GOOGLE_FONTS,
|
||||
mockFontshareFont,
|
||||
type MockFontshareFontOptions,
|
||||
mockGoogleFont,
|
||||
type MockGoogleFontOptions,
|
||||
mockUnifiedFont,
|
||||
type MockUnifiedFontOptions,
|
||||
UNIFIED_FONTS,
|
||||
} from './fonts.mock';
|
||||
|
||||
// Filter mocks
|
||||
export {
|
||||
createCategoriesFilter,
|
||||
createGenericFilter,
|
||||
createMockFilter,
|
||||
createProvidersFilter,
|
||||
createSubsetsFilter,
|
||||
FONT_PROVIDERS,
|
||||
FONT_SUBSETS,
|
||||
FONTHARE_CATEGORIES,
|
||||
generateSequentialFilter,
|
||||
GENERIC_FILTERS,
|
||||
GOOGLE_CATEGORIES,
|
||||
MOCK_FILTERS,
|
||||
MOCK_FILTERS_ALL_SELECTED,
|
||||
MOCK_FILTERS_EMPTY,
|
||||
MOCK_FILTERS_SELECTED,
|
||||
type MockFilterOptions,
|
||||
type MockFilters,
|
||||
UNIFIED_CATEGORIES,
|
||||
} from './filters.mock';
|
||||
|
||||
// Store mocks
|
||||
export {
|
||||
createErrorState,
|
||||
createLoadingState,
|
||||
createMockComparisonStore,
|
||||
createMockFontApiResponse,
|
||||
createMockFontStoreState,
|
||||
createMockQueryState,
|
||||
createMockReactiveState,
|
||||
createMockStore,
|
||||
createSuccessState,
|
||||
generatePaginatedFonts,
|
||||
MOCK_FONT_STORE_STATES,
|
||||
MOCK_STORES,
|
||||
type MockFontStoreState,
|
||||
type MockQueryObserverResult,
|
||||
type MockQueryState,
|
||||
} from './stores.mock';
|
||||
590
src/entities/Font/lib/mocks/stores.mock.ts
Normal file
590
src/entities/Font/lib/mocks/stores.mock.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK FONT STORE HELPERS
|
||||
* ============================================================================
|
||||
*
|
||||
* Factory functions and preset mock data for TanStack Query stores and state management.
|
||||
* Used in Storybook stories for components that use reactive stores.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```ts
|
||||
* import {
|
||||
* createMockQueryState,
|
||||
* MOCK_STORES,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
*
|
||||
* // Create a mock query state
|
||||
* const loadingState = createMockQueryState({ status: 'pending' });
|
||||
* const errorState = createMockQueryState({ status: 'error', error: 'Failed to load' });
|
||||
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
||||
*
|
||||
* // Use preset stores
|
||||
* const mockFontStore = MOCK_STORES.unifiedFontStore();
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||
import type {
|
||||
QueryKey,
|
||||
QueryObserverResult,
|
||||
QueryStatus,
|
||||
} from '@tanstack/svelte-query';
|
||||
import {
|
||||
UNIFIED_FONTS,
|
||||
generateMockFonts,
|
||||
} from './fonts.mock';
|
||||
|
||||
// ============================================================================
|
||||
// TANSTACK QUERY MOCK TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mock TanStack Query state
|
||||
*/
|
||||
export interface MockQueryState<TData = unknown, TError = Error> {
|
||||
status: QueryStatus;
|
||||
data?: TData;
|
||||
error?: TError;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
isSuccess?: boolean;
|
||||
isError?: boolean;
|
||||
isPending?: boolean;
|
||||
dataUpdatedAt?: number;
|
||||
errorUpdatedAt?: number;
|
||||
failureCount?: number;
|
||||
failureReason?: TError;
|
||||
errorUpdateCount?: number;
|
||||
isRefetching?: boolean;
|
||||
isRefetchError?: boolean;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock TanStack Query observer result
|
||||
*/
|
||||
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
|
||||
status?: QueryStatus;
|
||||
data?: TData;
|
||||
error?: TError;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
isSuccess?: boolean;
|
||||
isError?: boolean;
|
||||
isPending?: boolean;
|
||||
dataUpdatedAt?: number;
|
||||
errorUpdatedAt?: number;
|
||||
failureCount?: number;
|
||||
failureReason?: TError;
|
||||
errorUpdateCount?: number;
|
||||
isRefetching?: boolean;
|
||||
isRefetchError?: boolean;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TANSTACK QUERY MOCK FACTORIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock query state for TanStack Query
|
||||
*/
|
||||
export function createMockQueryState<TData = unknown, TError = Error>(
|
||||
options: MockQueryState<TData, TError>,
|
||||
): MockQueryObserverResult<TData, TError> {
|
||||
const {
|
||||
status,
|
||||
data,
|
||||
error,
|
||||
} = options;
|
||||
|
||||
return {
|
||||
status: status ?? 'success',
|
||||
data,
|
||||
error,
|
||||
isLoading: status === 'pending' ? true : false,
|
||||
isFetching: status === 'pending' ? true : false,
|
||||
isSuccess: status === 'success',
|
||||
isError: status === 'error',
|
||||
isPending: status === 'pending',
|
||||
dataUpdatedAt: status === 'success' ? Date.now() : undefined,
|
||||
errorUpdatedAt: status === 'error' ? Date.now() : undefined,
|
||||
failureCount: status === 'error' ? 1 : 0,
|
||||
failureReason: status === 'error' ? error : undefined,
|
||||
errorUpdateCount: status === 'error' ? 1 : 0,
|
||||
isRefetching: false,
|
||||
isRefetchError: false,
|
||||
isPaused: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a loading query state
|
||||
*/
|
||||
export function createLoadingState<TData = unknown>(): MockQueryObserverResult<TData> {
|
||||
return createMockQueryState<TData>({ status: 'pending', data: undefined, error: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error query state
|
||||
*/
|
||||
export function createErrorState<TError = Error>(
|
||||
error: TError,
|
||||
): MockQueryObserverResult<unknown, TError> {
|
||||
return createMockQueryState<unknown, TError>({ status: 'error', data: undefined, error });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success query state
|
||||
*/
|
||||
export function createSuccessState<TData>(data: TData): MockQueryObserverResult<TData> {
|
||||
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FONT STORE MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mock UnifiedFontStore state
|
||||
*/
|
||||
export interface MockFontStoreState {
|
||||
/** All cached fonts */
|
||||
fonts: Record<string, UnifiedFont>;
|
||||
/** Current page */
|
||||
page: number;
|
||||
/** Total pages available */
|
||||
totalPages: number;
|
||||
/** Items per page */
|
||||
limit: number;
|
||||
/** Total font count */
|
||||
total: number;
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
/** Error state */
|
||||
error: Error | null;
|
||||
/** Search query */
|
||||
searchQuery: string;
|
||||
/** Selected provider */
|
||||
provider: 'google' | 'fontshare' | 'all';
|
||||
/** Selected category */
|
||||
category: string | null;
|
||||
/** Selected subset */
|
||||
subset: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock font store state
|
||||
*/
|
||||
export function createMockFontStoreState(
|
||||
options: Partial<MockFontStoreState> = {},
|
||||
): MockFontStoreState {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 24,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
searchQuery = '',
|
||||
provider = 'all',
|
||||
category = null,
|
||||
subset = null,
|
||||
} = options;
|
||||
|
||||
// Generate mock fonts if not provided
|
||||
const mockFonts = options.fonts ?? Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS).map(font => [font.id, font]),
|
||||
);
|
||||
|
||||
const fontArray = Object.values(mockFonts);
|
||||
const total = options.total ?? fontArray.length;
|
||||
const totalPages = options.totalPages ?? Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
fonts: mockFonts,
|
||||
page,
|
||||
totalPages,
|
||||
limit,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
searchQuery,
|
||||
provider,
|
||||
category,
|
||||
subset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset font store states
|
||||
*/
|
||||
export const MOCK_FONT_STORE_STATES = {
|
||||
/** Initial loading state */
|
||||
loading: createMockFontStoreState({
|
||||
isLoading: true,
|
||||
fonts: {},
|
||||
total: 0,
|
||||
page: 1,
|
||||
}),
|
||||
|
||||
/** Empty state (no fonts found) */
|
||||
empty: createMockFontStoreState({
|
||||
fonts: {},
|
||||
total: 0,
|
||||
page: 1,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** First page with fonts */
|
||||
firstPage: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
|
||||
),
|
||||
total: 50,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 5,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** Second page with fonts */
|
||||
secondPage: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
|
||||
),
|
||||
total: 50,
|
||||
page: 2,
|
||||
limit: 10,
|
||||
totalPages: 5,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** Last page with fonts */
|
||||
lastPage: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
|
||||
),
|
||||
total: 25,
|
||||
page: 3,
|
||||
limit: 10,
|
||||
totalPages: 3,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** Error state */
|
||||
error: createMockFontStoreState({
|
||||
fonts: {},
|
||||
error: new Error('Failed to load fonts'),
|
||||
total: 0,
|
||||
page: 1,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
/** With search query */
|
||||
withSearch: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
|
||||
),
|
||||
total: 3,
|
||||
page: 1,
|
||||
isLoading: false,
|
||||
searchQuery: 'Roboto',
|
||||
}),
|
||||
|
||||
/** Filtered by category */
|
||||
filteredByCategory: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS)
|
||||
.filter(f => f.category === 'serif')
|
||||
.slice(0, 5)
|
||||
.map(font => [font.id, font]),
|
||||
),
|
||||
total: 5,
|
||||
page: 1,
|
||||
isLoading: false,
|
||||
category: 'serif',
|
||||
}),
|
||||
|
||||
/** Filtered by provider */
|
||||
filteredByProvider: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
Object.values(UNIFIED_FONTS)
|
||||
.filter(f => f.provider === 'google')
|
||||
.slice(0, 5)
|
||||
.map(font => [font.id, font]),
|
||||
),
|
||||
total: 5,
|
||||
page: 1,
|
||||
isLoading: false,
|
||||
provider: 'google',
|
||||
}),
|
||||
|
||||
/** Large dataset */
|
||||
largeDataset: createMockFontStoreState({
|
||||
fonts: Object.fromEntries(
|
||||
generateMockFonts(50).map(font => [font.id, font]),
|
||||
),
|
||||
total: 500,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalPages: 10,
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MOCK STORE OBJECT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock store object that mimics TanStack Query behavior
|
||||
* Useful for components that subscribe to store properties
|
||||
*/
|
||||
export function createMockStore<T>(config: {
|
||||
data?: T;
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: Error;
|
||||
isFetching?: boolean;
|
||||
}) {
|
||||
const {
|
||||
data,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
error,
|
||||
isFetching = false,
|
||||
} = config;
|
||||
|
||||
return {
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get isError() {
|
||||
return isError;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get isFetching() {
|
||||
return isFetching;
|
||||
},
|
||||
get isSuccess() {
|
||||
return !isLoading && !isError && data !== undefined;
|
||||
},
|
||||
get status() {
|
||||
if (isLoading) return 'pending';
|
||||
if (isError) return 'error';
|
||||
return 'success';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset mock stores
|
||||
*/
|
||||
export const MOCK_STORES = {
|
||||
/** Font store in loading state */
|
||||
loadingFontStore: createMockStore<UnifiedFont[]>({
|
||||
isLoading: true,
|
||||
data: undefined,
|
||||
}),
|
||||
|
||||
/** Font store with fonts loaded */
|
||||
successFontStore: createMockStore<UnifiedFont[]>({
|
||||
data: Object.values(UNIFIED_FONTS),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
|
||||
/** Font store with error */
|
||||
errorFontStore: createMockStore<UnifiedFont[]>({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Failed to load fonts'),
|
||||
}),
|
||||
|
||||
/** Font store with empty results */
|
||||
emptyFontStore: createMockStore<UnifiedFont[]>({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a mock UnifiedFontStore-like object
|
||||
* Note: This is a simplified mock for Storybook use
|
||||
*/
|
||||
unifiedFontStore: (state: Partial<MockFontStoreState> = {}) => {
|
||||
const mockState = createMockFontStoreState(state);
|
||||
return {
|
||||
// State properties
|
||||
get fonts() {
|
||||
return mockState.fonts;
|
||||
},
|
||||
get page() {
|
||||
return mockState.page;
|
||||
},
|
||||
get totalPages() {
|
||||
return mockState.totalPages;
|
||||
},
|
||||
get limit() {
|
||||
return mockState.limit;
|
||||
},
|
||||
get total() {
|
||||
return mockState.total;
|
||||
},
|
||||
get isLoading() {
|
||||
return mockState.isLoading;
|
||||
},
|
||||
get error() {
|
||||
return mockState.error;
|
||||
},
|
||||
get searchQuery() {
|
||||
return mockState.searchQuery;
|
||||
},
|
||||
get provider() {
|
||||
return mockState.provider;
|
||||
},
|
||||
get category() {
|
||||
return mockState.category;
|
||||
},
|
||||
get subset() {
|
||||
return mockState.subset;
|
||||
},
|
||||
// Methods (no-op for Storybook)
|
||||
nextPage: () => {},
|
||||
prevPage: () => {},
|
||||
goToPage: (_page: number) => {},
|
||||
setLimit: (_limit: number) => {},
|
||||
setProvider: (_provider: typeof mockState.provider) => {},
|
||||
setCategory: (_category: string | null) => {},
|
||||
setSubset: (_subset: string | null) => {},
|
||||
setSearch: (_query: string) => {},
|
||||
resetFilters: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// REACTIVE STATE MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a reactive state object using Svelte 5 runes pattern
|
||||
* Useful for stories that need reactive state
|
||||
*
|
||||
* Note: This uses plain JavaScript objects since Svelte runes
|
||||
* only work in .svelte files. For Storybook, this provides
|
||||
* a similar API for testing.
|
||||
*/
|
||||
export function createMockReactiveState<T>(initialValue: T) {
|
||||
let value = initialValue;
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(newValue: T) {
|
||||
value = newValue;
|
||||
},
|
||||
update(fn: (current: T) => T) {
|
||||
value = fn(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock comparison store for ComparisonSlider component
|
||||
*/
|
||||
export function createMockComparisonStore(config: {
|
||||
fontA?: UnifiedFont;
|
||||
fontB?: UnifiedFont;
|
||||
text?: string;
|
||||
} = {}) {
|
||||
const { fontA, fontB, text = 'The quick brown fox jumps over the lazy dog.' } = config;
|
||||
|
||||
return {
|
||||
get fontA() {
|
||||
return fontA ?? UNIFIED_FONTS.roboto;
|
||||
},
|
||||
get fontB() {
|
||||
return fontB ?? UNIFIED_FONTS.openSans;
|
||||
},
|
||||
get text() {
|
||||
return text;
|
||||
},
|
||||
// Methods (no-op for Storybook)
|
||||
setFontA: (_font: UnifiedFont | undefined) => {},
|
||||
setFontB: (_font: UnifiedFont | undefined) => {},
|
||||
setText: (_text: string) => {},
|
||||
swapFonts: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOCK DATA GENERATORS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate paginated font data
|
||||
*/
|
||||
export function generatePaginatedFonts(
|
||||
totalCount: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
): {
|
||||
fonts: UnifiedFont[];
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
} {
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = Math.min(startIndex + limit, totalCount);
|
||||
|
||||
return {
|
||||
fonts: generateMockFonts(endIndex - startIndex).map((font, i) => ({
|
||||
...font,
|
||||
id: `font-${startIndex + i + 1}`,
|
||||
name: `Font ${startIndex + i + 1}`,
|
||||
})),
|
||||
page,
|
||||
totalPages,
|
||||
total: totalCount,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock API response for fonts
|
||||
*/
|
||||
export function createMockFontApiResponse(config: {
|
||||
fonts?: UnifiedFont[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
const fonts = config.fonts ?? Object.values(UNIFIED_FONTS);
|
||||
const total = config.total ?? fonts.length;
|
||||
const page = config.page ?? 1;
|
||||
const limit = config.limit ?? fonts.length;
|
||||
|
||||
return {
|
||||
data: fonts,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasNextPage: page < Math.ceil(total / limit),
|
||||
hasPrevPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
582
src/entities/Font/lib/normalize/normalize.test.ts
Normal file
582
src/entities/Font/lib/normalize/normalize.test.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type {
|
||||
FontItem,
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
} from '../../model/types';
|
||||
import {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './normalize';
|
||||
|
||||
describe('Font Normalization', () => {
|
||||
describe('normalizeGoogleFont', () => {
|
||||
const mockGoogleFont: GoogleFontItem = {
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular', '700', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
files: {
|
||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
||||
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
||||
},
|
||||
version: 'v30',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
||||
};
|
||||
|
||||
it('normalizes Google Font to unified model', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.id).toBe('Roboto');
|
||||
expect(result.name).toBe('Roboto');
|
||||
expect(result.provider).toBe('google');
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('maps font variants correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.variants).toEqual(['regular', '700', 'italic', '700italic']);
|
||||
});
|
||||
|
||||
it('maps subsets correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.subsets).toContain('latin');
|
||||
expect(result.subsets).toContain('latin-ext');
|
||||
expect(result.subsets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('maps style URLs correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.styles.regular).toBeDefined();
|
||||
expect(result.styles.bold).toBeDefined();
|
||||
expect(result.styles.italic).toBeDefined();
|
||||
expect(result.styles.boldItalic).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes metadata', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.metadata.cachedAt).toBeDefined();
|
||||
expect(result.metadata.version).toBe('v30');
|
||||
expect(result.metadata.lastModified).toBe('2022-01-01');
|
||||
});
|
||||
|
||||
it('marks Google Fonts as non-variable', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.features.isVariable).toBe(false);
|
||||
expect(result.features.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles sans-serif category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'sans-serif' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('handles serif category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'serif' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('serif');
|
||||
});
|
||||
|
||||
it('handles display category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'display' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('display');
|
||||
});
|
||||
|
||||
it('handles handwriting category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'handwriting' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('handles cursive category (maps to handwriting)', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'cursive' as any };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('handles monospace category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'monospace' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('monospace');
|
||||
});
|
||||
|
||||
it('filters invalid subsets', () => {
|
||||
const font = {
|
||||
...mockGoogleFont,
|
||||
subsets: ['latin', 'latin-ext', 'invalid-subset'],
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.subsets).not.toContain('invalid-subset');
|
||||
expect(result.subsets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('maps variant weights correctly', () => {
|
||||
const font: GoogleFontItem = {
|
||||
...mockGoogleFont,
|
||||
variants: ['regular', '100', '400', '700', '900'] as any,
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.variants).toContain('regular');
|
||||
expect(result.variants).toContain('100');
|
||||
expect(result.variants).toContain('400');
|
||||
expect(result.variants).toContain('700');
|
||||
expect(result.variants).toContain('900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFontshareFont', () => {
|
||||
const mockFontshareFont: FontshareFont = {
|
||||
id: '20e9fcdc-1e41-4559-a43d-1ede0adc8896',
|
||||
name: 'Satoshi',
|
||||
native_name: null,
|
||||
slug: 'satoshi',
|
||||
category: 'Sans',
|
||||
script: 'latin',
|
||||
publisher: {
|
||||
bio: 'Indian Type Foundry',
|
||||
email: null,
|
||||
id: 'test-id',
|
||||
links: [],
|
||||
name: 'Indian Type Foundry',
|
||||
},
|
||||
designers: [
|
||||
{
|
||||
bio: 'Designer bio',
|
||||
links: [],
|
||||
name: 'Designer Name',
|
||||
},
|
||||
],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: true,
|
||||
show_latin_metrics: false,
|
||||
license_type: 'itf_ffl',
|
||||
languages: 'Afar, Afrikaans',
|
||||
inserted_at: '2021-03-12T20:49:05Z',
|
||||
story: '<p>Font story</p>',
|
||||
version: '1.0',
|
||||
views: 10000,
|
||||
views_recent: 500,
|
||||
is_hot: true,
|
||||
is_new: false,
|
||||
is_shortlisted: false,
|
||||
is_top: true,
|
||||
axes: [],
|
||||
font_tags: [
|
||||
{ name: 'Branding' },
|
||||
{ name: 'Logos' },
|
||||
],
|
||||
features: [
|
||||
{
|
||||
name: 'Alternate t',
|
||||
on_by_default: false,
|
||||
tag: 'ss01',
|
||||
},
|
||||
],
|
||||
styles: [
|
||||
{
|
||||
id: 'style-id-1',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/satoshi.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-2',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-bold.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Bold',
|
||||
name: 'Bold',
|
||||
native_name: null,
|
||||
number: 700,
|
||||
weight: 700,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-3',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-italic.woff2',
|
||||
is_italic: true,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-4',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
||||
is_italic: true,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Bold',
|
||||
name: 'Bold',
|
||||
native_name: null,
|
||||
number: 700,
|
||||
weight: 700,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('normalizes Fontshare font to unified model', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.id).toBe('satoshi');
|
||||
expect(result.name).toBe('Satoshi');
|
||||
expect(result.provider).toBe('fontshare');
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('uses slug as unique identifier', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.id).toBe('satoshi');
|
||||
});
|
||||
|
||||
it('extracts variant names from styles', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.variants).toContain('Regular');
|
||||
expect(result.variants).toContain('Bold');
|
||||
expect(result.variants).toContain('Regularitalic');
|
||||
expect(result.variants).toContain('Bolditalic');
|
||||
});
|
||||
|
||||
it('maps Fontshare Sans to sans-serif category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Sans' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('maps Fontshare Serif to serif category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Serif' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('serif');
|
||||
});
|
||||
|
||||
it('maps Fontshare Display to display category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Display' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('display');
|
||||
});
|
||||
|
||||
it('maps Fontshare Script to handwriting category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Script' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('maps Fontshare Mono to monospace category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Mono' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('monospace');
|
||||
});
|
||||
|
||||
it('maps style URLs correctly', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.styles.regular).toBe('//cdn.fontshare.com/wf/satoshi.woff2');
|
||||
expect(result.styles.bold).toBe('//cdn.fontshare.com/wf/satoshi-bold.woff2');
|
||||
expect(result.styles.italic).toBe('//cdn.fontshare.com/wf/satoshi-italic.woff2');
|
||||
expect(result.styles.boldItalic).toBe(
|
||||
'//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles variable fonts', () => {
|
||||
const variableFont: FontshareFont = {
|
||||
...mockFontshareFont,
|
||||
axes: [
|
||||
{
|
||||
name: 'wght',
|
||||
property: 'wght',
|
||||
range_default: 400,
|
||||
range_left: 300,
|
||||
range_right: 900,
|
||||
},
|
||||
],
|
||||
styles: [
|
||||
{
|
||||
id: 'var-style',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-variable.woff2',
|
||||
is_italic: false,
|
||||
is_variable: true,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Variable',
|
||||
name: 'Variable',
|
||||
native_name: null,
|
||||
number: 0,
|
||||
weight: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = normalizeFontshareFont(variableFont);
|
||||
|
||||
expect(result.features.isVariable).toBe(true);
|
||||
expect(result.features.axes).toHaveLength(1);
|
||||
expect(result.features.axes?.[0].name).toBe('wght');
|
||||
});
|
||||
|
||||
it('extracts font tags', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.features.tags).toContain('Branding');
|
||||
expect(result.features.tags).toContain('Logos');
|
||||
expect(result.features.tags).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('includes popularity from views', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.metadata.popularity).toBe(10000);
|
||||
});
|
||||
|
||||
it('includes metadata', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.metadata.cachedAt).toBeDefined();
|
||||
expect(result.metadata.version).toBe('1.0');
|
||||
expect(result.metadata.lastModified).toBe('2021-03-12T20:49:05Z');
|
||||
});
|
||||
|
||||
it('handles missing subsets gracefully', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
script: 'invalid-script',
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.subsets).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty tags', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
font_tags: [],
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.features.tags).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty axes', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
axes: [],
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.features.isVariable).toBe(false);
|
||||
expect(result.features.axes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGoogleFonts', () => {
|
||||
it('normalizes array of Google Fonts', () => {
|
||||
const fonts: GoogleFontItem[] = [
|
||||
{
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
||||
},
|
||||
{
|
||||
family: 'Open Sans',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Open+Sans',
|
||||
},
|
||||
];
|
||||
|
||||
const result = normalizeGoogleFonts(fonts);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Roboto');
|
||||
expect(result[1].name).toBe('Open Sans');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const result = normalizeGoogleFonts([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFontshareFonts', () => {
|
||||
it('normalizes array of Fontshare fonts', () => {
|
||||
const fonts: FontshareFont[] = [
|
||||
{
|
||||
...mockMinimalFontshareFont('font1', 'Font 1'),
|
||||
},
|
||||
{
|
||||
...mockMinimalFontshareFont('font2', 'Font 2'),
|
||||
},
|
||||
];
|
||||
|
||||
const result = normalizeFontshareFonts(fonts);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Font 1');
|
||||
expect(result[1].name).toBe('Font 2');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const result = normalizeFontshareFonts([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles Google Font with missing optional fields', () => {
|
||||
const font: Partial<GoogleFontItem> = {
|
||||
family: 'Test Font',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
};
|
||||
|
||||
const result = normalizeGoogleFont(font as GoogleFontItem);
|
||||
|
||||
expect(result.id).toBe('Test Font');
|
||||
expect(result.metadata.version).toBeUndefined();
|
||||
expect(result.metadata.lastModified).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles Fontshare font with minimal data', () => {
|
||||
const result = normalizeFontshareFont(mockMinimalFontshareFont('slug', 'Name'));
|
||||
|
||||
expect(result.id).toBe('slug');
|
||||
expect(result.name).toBe('Name');
|
||||
expect(result.provider).toBe('fontshare');
|
||||
});
|
||||
|
||||
it('handles unknown Fontshare category', () => {
|
||||
const font = {
|
||||
...mockMinimalFontshareFont('slug', 'Name'),
|
||||
category: 'Unknown Category',
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif'); // fallback
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to create minimal Fontshare font mock
|
||||
*/
|
||||
function mockMinimalFontshareFont(slug: string, name: string): FontshareFont {
|
||||
return {
|
||||
id: 'test-id',
|
||||
name,
|
||||
native_name: null,
|
||||
slug,
|
||||
category: 'Sans',
|
||||
script: 'latin',
|
||||
publisher: {
|
||||
bio: '',
|
||||
email: null,
|
||||
id: '',
|
||||
links: [],
|
||||
name: '',
|
||||
},
|
||||
designers: [],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: false,
|
||||
show_latin_metrics: false,
|
||||
license_type: '',
|
||||
languages: '',
|
||||
inserted_at: '',
|
||||
story: '',
|
||||
version: '1.0',
|
||||
views: 0,
|
||||
views_recent: 0,
|
||||
is_hot: false,
|
||||
is_new: false,
|
||||
is_shortlisted: null,
|
||||
is_top: false,
|
||||
axes: [],
|
||||
font_tags: [],
|
||||
features: [],
|
||||
styles: [
|
||||
{
|
||||
id: 'style-id',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/test.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
275
src/entities/Font/lib/normalize/normalize.ts
Normal file
275
src/entities/Font/lib/normalize/normalize.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Normalize fonts from Google Fonts and Fontshare to unified model
|
||||
*
|
||||
* Transforms provider-specific font data into a common interface
|
||||
* for consistent handling across the application.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from '../../model/types';
|
||||
|
||||
/**
|
||||
* Map Google Fonts category to unified FontCategory
|
||||
*/
|
||||
function mapGoogleCategory(category: string): FontCategory {
|
||||
const normalized = category.toLowerCase();
|
||||
if (normalized.includes('sans-serif')) {
|
||||
return 'sans-serif';
|
||||
}
|
||||
if (normalized.includes('serif')) {
|
||||
return 'serif';
|
||||
}
|
||||
if (normalized.includes('display')) {
|
||||
return 'display';
|
||||
}
|
||||
if (normalized.includes('handwriting') || normalized.includes('cursive')) {
|
||||
return 'handwriting';
|
||||
}
|
||||
if (normalized.includes('monospace')) {
|
||||
return 'monospace';
|
||||
}
|
||||
// Default fallback
|
||||
return 'sans-serif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Fontshare category to unified FontCategory
|
||||
*/
|
||||
function mapFontshareCategory(category: string): FontCategory {
|
||||
const normalized = category.toLowerCase();
|
||||
if (normalized === 'sans' || normalized === 'sans-serif') {
|
||||
return 'sans-serif';
|
||||
}
|
||||
if (normalized === 'serif') {
|
||||
return 'serif';
|
||||
}
|
||||
if (normalized === 'display') {
|
||||
return 'display';
|
||||
}
|
||||
if (normalized === 'script') {
|
||||
return 'handwriting';
|
||||
}
|
||||
if (normalized === 'mono' || normalized === 'monospace') {
|
||||
return 'monospace';
|
||||
}
|
||||
// Default fallback
|
||||
return 'sans-serif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google subset to unified FontSubset
|
||||
*/
|
||||
function mapGoogleSubset(subset: string): FontSubset | null {
|
||||
const validSubsets: FontSubset[] = [
|
||||
'latin',
|
||||
'latin-ext',
|
||||
'cyrillic',
|
||||
'greek',
|
||||
'arabic',
|
||||
'devanagari',
|
||||
];
|
||||
return validSubsets.includes(subset as FontSubset)
|
||||
? (subset as FontSubset)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Fontshare script to unified FontSubset
|
||||
*/
|
||||
function mapFontshareScript(script: string): FontSubset | null {
|
||||
const normalized = script.toLowerCase();
|
||||
const mapping: Record<string, FontSubset | null> = {
|
||||
latin: 'latin',
|
||||
'latin-ext': 'latin-ext',
|
||||
cyrillic: 'cyrillic',
|
||||
greek: 'greek',
|
||||
arabic: 'arabic',
|
||||
devanagari: 'devanagari',
|
||||
};
|
||||
return mapping[normalized] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Google Font to unified model
|
||||
*
|
||||
* @param apiFont - Font item from Google Fonts API
|
||||
* @returns Unified font model
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = normalizeGoogleFont({
|
||||
* family: 'Roboto',
|
||||
* category: 'sans-serif',
|
||||
* variants: ['regular', '700'],
|
||||
* subsets: ['latin', 'latin-ext'],
|
||||
* files: { regular: '...', '700': '...' }
|
||||
* });
|
||||
*
|
||||
* console.log(roboto.id); // 'Roboto'
|
||||
* console.log(roboto.provider); // 'google'
|
||||
* ```
|
||||
*/
|
||||
export function normalizeGoogleFont(apiFont: GoogleFontItem): UnifiedFont {
|
||||
const category = mapGoogleCategory(apiFont.category);
|
||||
const subsets = apiFont.subsets
|
||||
.map(mapGoogleSubset)
|
||||
.filter((subset): subset is FontSubset => subset !== null);
|
||||
|
||||
// Map variant files to style URLs
|
||||
const styles: FontStyleUrls = {};
|
||||
for (const [variant, url] of Object.entries(apiFont.files)) {
|
||||
const urlString = url as string; // Type assertion for Record<string, string>
|
||||
if (variant === 'regular' || variant === '400') {
|
||||
styles.regular = urlString;
|
||||
} else if (variant === 'italic' || variant === '400italic') {
|
||||
styles.italic = urlString;
|
||||
} else if (variant === 'bold' || variant === '700') {
|
||||
styles.bold = urlString;
|
||||
} else if (variant === 'bolditalic' || variant === '700italic') {
|
||||
styles.boldItalic = urlString;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiFont.family,
|
||||
name: apiFont.family,
|
||||
provider: 'google',
|
||||
category,
|
||||
subsets,
|
||||
variants: apiFont.variants,
|
||||
styles,
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: apiFont.version,
|
||||
lastModified: apiFont.lastModified,
|
||||
},
|
||||
features: {
|
||||
isVariable: false, // Google Fonts doesn't expose variable font info
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Fontshare font to unified model
|
||||
*
|
||||
* @param apiFont - Font item from Fontshare API
|
||||
* @returns Unified font model
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const satoshi = normalizeFontshareFont({
|
||||
* id: 'uuid',
|
||||
* name: 'Satoshi',
|
||||
* slug: 'satoshi',
|
||||
* category: 'Sans',
|
||||
* script: 'latin',
|
||||
* styles: [ ... ]
|
||||
* });
|
||||
*
|
||||
* console.log(satoshi.id); // 'satoshi'
|
||||
* console.log(satoshi.provider); // 'fontshare'
|
||||
* ```
|
||||
*/
|
||||
export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
|
||||
const category = mapFontshareCategory(apiFont.category);
|
||||
const subset = mapFontshareScript(apiFont.script);
|
||||
const subsets = subset ? [subset] : [];
|
||||
|
||||
// Extract variant names from styles
|
||||
const variants = apiFont.styles.map(style => {
|
||||
const weightLabel = style.weight.label;
|
||||
const isItalic = style.is_italic;
|
||||
return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant;
|
||||
});
|
||||
|
||||
// Map styles to URLs
|
||||
const styles: FontStyleUrls = {};
|
||||
for (const style of apiFont.styles) {
|
||||
if (style.is_variable) {
|
||||
// Variable font - store as primary variant
|
||||
styles.regular = style.file;
|
||||
break;
|
||||
}
|
||||
|
||||
const weight = style.weight.number;
|
||||
const isItalic = style.is_italic;
|
||||
|
||||
if (weight === 400 && !isItalic) {
|
||||
styles.regular = style.file;
|
||||
} else if (weight === 400 && isItalic) {
|
||||
styles.italic = style.file;
|
||||
} else if (weight >= 700 && !isItalic) {
|
||||
styles.bold = style.file;
|
||||
} else if (weight >= 700 && isItalic) {
|
||||
styles.boldItalic = style.file;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract variable font axes
|
||||
const axes = apiFont.axes.map(axis => ({
|
||||
name: axis.name,
|
||||
property: axis.property,
|
||||
default: axis.range_default,
|
||||
min: axis.range_left,
|
||||
max: axis.range_right,
|
||||
}));
|
||||
|
||||
// Extract tags
|
||||
const tags = apiFont.font_tags.map(tag => tag.name);
|
||||
|
||||
return {
|
||||
id: apiFont.slug,
|
||||
name: apiFont.name,
|
||||
provider: 'fontshare',
|
||||
category,
|
||||
subsets,
|
||||
variants,
|
||||
styles,
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: apiFont.version,
|
||||
lastModified: apiFont.inserted_at,
|
||||
popularity: apiFont.views,
|
||||
},
|
||||
features: {
|
||||
isVariable: apiFont.axes.length > 0,
|
||||
axes: axes.length > 0 ? axes : undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize multiple Google Fonts to unified model
|
||||
*
|
||||
* @param apiFonts - Array of Google Font items
|
||||
* @returns Array of unified fonts
|
||||
*/
|
||||
export function normalizeGoogleFonts(
|
||||
apiFonts: GoogleFontItem[],
|
||||
): UnifiedFont[] {
|
||||
return apiFonts.map(normalizeGoogleFont);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize multiple Fontshare fonts to unified model
|
||||
*
|
||||
* @param apiFonts - Array of Fontshare font items
|
||||
* @returns Array of unified fonts
|
||||
*/
|
||||
export function normalizeFontshareFonts(
|
||||
apiFonts: FontshareFont[],
|
||||
): UnifiedFont[] {
|
||||
return apiFonts.map(normalizeFontshareFont);
|
||||
}
|
||||
|
||||
// Re-export UnifiedFont for backward compatibility
|
||||
export type { UnifiedFont } from '../../model/types/normalize';
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Font category
|
||||
*/
|
||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||
|
||||
/**
|
||||
* Font provider
|
||||
*/
|
||||
export type FontProvider = 'google' | 'fontshare';
|
||||
|
||||
/**
|
||||
* Font subset
|
||||
*/
|
||||
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
||||
43
src/entities/Font/model/index.ts
Normal file
43
src/entities/Font/model/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type {
|
||||
// Domain types
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
// Store types
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFiles,
|
||||
FontItem,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
// Fontshare API types
|
||||
FontshareApiModel,
|
||||
FontshareAxis,
|
||||
FontshareDesigner,
|
||||
FontshareFeature,
|
||||
FontshareFont,
|
||||
FontshareLink,
|
||||
FontsharePublisher,
|
||||
FontshareStyle,
|
||||
FontshareStyleProperties,
|
||||
FontshareTag,
|
||||
FontshareWeight,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
// Google Fonts API types
|
||||
GoogleFontsApiModel,
|
||||
// Normalization types
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createUnifiedFontStore,
|
||||
type FontConfigRequest,
|
||||
type UnifiedFontStore,
|
||||
unifiedFontStore,
|
||||
} from './store';
|
||||
@@ -0,0 +1,142 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||
|
||||
describe('AppliedFontsManager', () => {
|
||||
let manager: AppliedFontsManager;
|
||||
let mockFontFaceSet: any;
|
||||
let mockFetch: any;
|
||||
let failUrls: Set<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
failUrls = new Set();
|
||||
|
||||
mockFontFaceSet = {
|
||||
add: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
// 1. Properly mock FontFace as a constructor function
|
||||
// The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string
|
||||
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
|
||||
this.name = name;
|
||||
this.bufferOrUrl = bufferOrUrl;
|
||||
this.load = vi.fn().mockImplementation(() => {
|
||||
// For error tests, we track which URLs should fail via failUrls
|
||||
// The fetch mock will have already rejected for those URLs
|
||||
return Promise.resolve(this);
|
||||
});
|
||||
});
|
||||
|
||||
vi.stubGlobal('FontFace', MockFontFace);
|
||||
|
||||
// 2. Mock document.fonts safely
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
value: mockFontFaceSet,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
|
||||
});
|
||||
|
||||
// 3. Mock fetch to return fake ArrayBuffer data
|
||||
mockFetch = vi.fn((url: string) => {
|
||||
if (failUrls.has(url)) {
|
||||
return Promise.reject(new Error('Network error'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
clone: () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
}),
|
||||
} as Response);
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
manager = new AppliedFontsManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should batch multiple font requests into a single process', async () => {
|
||||
const configs = [
|
||||
{ id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
|
||||
{ id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
|
||||
];
|
||||
|
||||
manager.touch(configs);
|
||||
|
||||
// Advance to trigger the 16ms debounced #processQueue
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
|
||||
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle font loading errors gracefully', async () => {
|
||||
// Suppress expected console error for clean test logs
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const failUrl = 'https://example.com/fail.ttf';
|
||||
failUrls.add(failUrl);
|
||||
|
||||
const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
|
||||
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should purge fonts after TTL expires', async () => {
|
||||
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
|
||||
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
|
||||
|
||||
// Move clock forward past TTL (5m) and Purge Interval (1m)
|
||||
// advanceTimersByTimeAsync is key here; it handles the promises inside the interval
|
||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
||||
|
||||
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT purge fonts that are still being "touched"', async () => {
|
||||
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
|
||||
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
// Advance 4 minutes
|
||||
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
|
||||
|
||||
// Refresh touch
|
||||
manager.touch([config]);
|
||||
|
||||
// Advance another 2 minutes (Total 6 since start)
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
|
||||
|
||||
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */
|
||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||
|
||||
/** Configuration for a font load request. */
|
||||
export interface FontConfigRequest {
|
||||
/**
|
||||
* Unique identifier for the font (e.g., "lato", "roboto").
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* URL pointing to the font file (typically .ttf or .woff2).
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Variable fonts load once per ID; static fonts load per weight.
|
||||
*/
|
||||
isVariable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
|
||||
*
|
||||
* **Two-Phase Loading Strategy:**
|
||||
* 1. *Concurrent Fetching*: Font files fetched in parallel (network I/O is non-blocking)
|
||||
* 2. *Sequential Parsing*: Buffers parsed into FontFace objects one at a time with periodic yields
|
||||
*
|
||||
* **Yielding Strategy:**
|
||||
* - Chromium: Yields only when user input is pending (via `scheduler.yield()` + `isInputPending()`)
|
||||
* - Others: Time-based fallback, yields every 8ms
|
||||
*
|
||||
* **Network Adaptation:**
|
||||
* - 2G: 1 concurrent request, 3G: 2, 4G+: 4 (via Network Information API)
|
||||
* - Respects `saveData` mode to defer non-critical weights
|
||||
*
|
||||
* **Cache Integration:** Cache API with best-effort fallback (handles private browsing, quota limits)
|
||||
*
|
||||
* **Cleanup:** LRU-style eviction after 5 minutes of inactivity; cleanup runs every 60 seconds
|
||||
*
|
||||
* **Font Identity:** Variable fonts use `{id}@vf`, static fonts use `{id}@{weight}`
|
||||
*
|
||||
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
||||
*/
|
||||
export class AppliedFontsManager {
|
||||
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
|
||||
#loadedFonts = new Map<string, FontFace>();
|
||||
|
||||
// Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms)
|
||||
#usageTracker = new Map<string, number>();
|
||||
|
||||
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
|
||||
#queue = new Map<string, FontConfigRequest>();
|
||||
|
||||
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Interval handle for periodic cleanup (runs every PURGE_INTERVAL)
|
||||
#intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// AbortController for canceling in-flight fetches on destroy
|
||||
#abortController = new AbortController();
|
||||
|
||||
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||
#pendingType: 'idle' | 'timeout' | null = null;
|
||||
|
||||
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
|
||||
#retryCounts = new Map<string, number>();
|
||||
|
||||
readonly #MAX_RETRIES = 3;
|
||||
readonly #PURGE_INTERVAL = 60000; // 60 seconds
|
||||
readonly #TTL = 5 * 60 * 1000; // 5 minutes
|
||||
readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation
|
||||
|
||||
// Reactive status map for Svelte components to track font states
|
||||
statuses = new SvelteMap<string, FontStatus>();
|
||||
|
||||
// Starts periodic cleanup timer (browser-only).
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
// Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static.
|
||||
#getFontKey(id: string, weight: number, isVariable: boolean): string {
|
||||
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
|
||||
*
|
||||
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
|
||||
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
|
||||
*/
|
||||
touch(configs: FontConfigRequest[]) {
|
||||
if (this.#abortController.signal.aborted) return;
|
||||
|
||||
const now = Date.now();
|
||||
let hasNewItems = false;
|
||||
|
||||
for (const config of configs) {
|
||||
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
|
||||
this.#usageTracker.set(key, now);
|
||||
|
||||
const status = this.statuses.get(key);
|
||||
if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue;
|
||||
if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue;
|
||||
|
||||
this.#queue.set(key, config);
|
||||
hasNewItems = true;
|
||||
}
|
||||
|
||||
if (hasNewItems && !this.#timeoutId) {
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
this.#timeoutId = requestIdleCallback(
|
||||
() => this.#processQueue(),
|
||||
{ timeout: 150 },
|
||||
) as unknown as ReturnType<typeof setTimeout>;
|
||||
this.#pendingType = 'idle';
|
||||
} else {
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||
this.#pendingType = 'timeout';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */
|
||||
async #yieldToMain(): Promise<void> {
|
||||
// @ts-expect-error - scheduler not in TypeScript lib yet
|
||||
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
||||
await scheduler.yield();
|
||||
} else {
|
||||
await new Promise<void>(resolve => {
|
||||
const ch = new MessageChannel();
|
||||
ch.port1.onmessage = () => resolve();
|
||||
ch.port2.postMessage(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */
|
||||
#getEffectiveConcurrency(): number {
|
||||
const nav = navigator as any;
|
||||
const conn = nav.connection;
|
||||
if (!conn) return 4;
|
||||
|
||||
switch (conn.effectiveType) {
|
||||
case 'slow-2g':
|
||||
case '2g':
|
||||
return 1;
|
||||
case '3g':
|
||||
return 2;
|
||||
default:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
|
||||
#shouldDeferNonCritical(): boolean {
|
||||
const nav = navigator as any;
|
||||
return nav.connection?.saveData === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes queued fonts in two phases:
|
||||
* 1. Concurrent fetching (network I/O, non-blocking)
|
||||
* 2. Sequential parsing with periodic yields (CPU-intensive, can block UI)
|
||||
*
|
||||
* Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms.
|
||||
*/
|
||||
async #processQueue() {
|
||||
this.#timeoutId = null;
|
||||
this.#pendingType = null;
|
||||
|
||||
let entries = Array.from(this.#queue.entries());
|
||||
if (!entries.length) return;
|
||||
this.#queue.clear();
|
||||
|
||||
if (this.#shouldDeferNonCritical()) {
|
||||
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
||||
}
|
||||
|
||||
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
|
||||
const concurrency = this.#getEffectiveConcurrency();
|
||||
const buffers = new Map<string, ArrayBuffer>();
|
||||
|
||||
for (let i = 0; i < entries.length; i += concurrency) {
|
||||
const chunk = entries.slice(i, i + concurrency);
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map(async ([key, config]) => {
|
||||
this.statuses.set(key, 'loading');
|
||||
const buffer = await this.#fetchFontBuffer(
|
||||
config.url,
|
||||
this.#abortController.signal,
|
||||
);
|
||||
buffers.set(key, buffer);
|
||||
}),
|
||||
);
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
if (results[j].status === 'rejected') {
|
||||
const [key, config] = chunk[j];
|
||||
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
|
||||
this.statuses.set(key, 'error');
|
||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
|
||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||
let lastYield = performance.now();
|
||||
const YIELD_INTERVAL = 8; // ms
|
||||
|
||||
for (const [key, config] of entries) {
|
||||
const buffer = buffers.get(key);
|
||||
if (!buffer) continue;
|
||||
|
||||
try {
|
||||
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||
const font = new FontFace(config.name, buffer, {
|
||||
weight: weightRange,
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
});
|
||||
await font.load();
|
||||
document.fonts.add(font);
|
||||
this.#loadedFonts.set(key, font);
|
||||
this.statuses.set(key, 'loaded');
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') continue;
|
||||
console.error(`Font parse failed: ${config.name}`, e);
|
||||
this.statuses.set(key, 'error');
|
||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const shouldYield = hasInputPending
|
||||
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||
: (performance.now() - lastYield > YIELD_INTERVAL);
|
||||
|
||||
if (shouldYield) {
|
||||
await this.#yieldToMain();
|
||||
lastYield = performance.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
|
||||
* Cache failures (private browsing, quota limits) are silently ignored.
|
||||
*/
|
||||
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#CACHE_NAME);
|
||||
const cached = await cache.match(url);
|
||||
if (cached) return cached.arrayBuffer();
|
||||
}
|
||||
} catch {
|
||||
// Cache unavailable (private browsing, security restrictions) — fall through to network
|
||||
}
|
||||
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#CACHE_NAME);
|
||||
await cache.put(url, response.clone());
|
||||
}
|
||||
} catch {
|
||||
// Cache write failed (quota, storage pressure) — return font anyway
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
for (const [key, lastUsed] of this.#usageTracker) {
|
||||
if (now - lastUsed < this.#TTL) continue;
|
||||
|
||||
const font = this.#loadedFonts.get(key);
|
||||
if (font) document.fonts.delete(font);
|
||||
|
||||
this.#loadedFonts.delete(key);
|
||||
this.#usageTracker.delete(key);
|
||||
this.statuses.delete(key);
|
||||
this.#retryCounts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns current loading status for a font, or undefined if never requested. */
|
||||
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
|
||||
}
|
||||
|
||||
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
||||
async ready(): Promise<void> {
|
||||
if (typeof document === 'undefined') return;
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
} catch {
|
||||
// document.fonts.ready can reject in some edge cases
|
||||
// (e.g., document unloaded). Silently resolve.
|
||||
}
|
||||
}
|
||||
|
||||
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
|
||||
destroy() {
|
||||
this.#abortController.abort();
|
||||
|
||||
if (this.#timeoutId !== null) {
|
||||
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
|
||||
cancelIdleCallback(this.#timeoutId as unknown as number);
|
||||
} else {
|
||||
clearTimeout(this.#timeoutId);
|
||||
}
|
||||
this.#timeoutId = null;
|
||||
this.#pendingType = null;
|
||||
}
|
||||
|
||||
if (this.#intervalId) {
|
||||
clearInterval(this.#intervalId);
|
||||
this.#intervalId = null;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
for (const font of this.#loadedFonts.values()) {
|
||||
document.fonts.delete(font);
|
||||
}
|
||||
}
|
||||
|
||||
this.#loadedFonts.clear();
|
||||
this.#usageTracker.clear();
|
||||
this.#retryCounts.clear();
|
||||
this.statuses.clear();
|
||||
this.#queue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance — use throughout the application for unified font loading state. */
|
||||
export const appliedFontsManager = new AppliedFontsManager();
|
||||
158
src/entities/Font/model/store/baseFontStore.svelte.ts
Normal file
158
src/entities/Font/model/store/baseFontStore.svelte.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
type QueryKey,
|
||||
QueryObserver,
|
||||
type QueryObserverOptions,
|
||||
type QueryObserverResult,
|
||||
} from '@tanstack/query-core';
|
||||
import type { UnifiedFont } from '../types';
|
||||
|
||||
/** */
|
||||
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
||||
cleanup: () => void;
|
||||
|
||||
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
||||
#internalParams = $state<TParams>({} as TParams);
|
||||
|
||||
params = $derived.by(() => {
|
||||
let merged = { ...this.#internalParams };
|
||||
|
||||
// Loop through every "Cable" plugged into the store
|
||||
// Loop through every "Cable" plugged into the store
|
||||
for (const getter of this.#bindings) {
|
||||
const bindingResult = getter();
|
||||
merged = { ...merged, ...bindingResult };
|
||||
}
|
||||
|
||||
return merged as TParams;
|
||||
});
|
||||
|
||||
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
||||
protected observer: QueryObserver<UnifiedFont[], Error>;
|
||||
protected qc = queryClient;
|
||||
|
||||
constructor(initialParams: TParams) {
|
||||
this.#internalParams = initialParams;
|
||||
|
||||
this.observer = new QueryObserver(this.qc, this.getOptions());
|
||||
|
||||
// Sync TanStack -> Svelte State
|
||||
this.observer.subscribe(r => {
|
||||
this.result = r;
|
||||
});
|
||||
|
||||
// Sync Svelte State -> TanStack Options
|
||||
this.cleanup = $effect.root(() => {
|
||||
$effect(() => {
|
||||
this.observer.setOptions(this.getOptions());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mandatory: Child must define how to fetch data and what the key is.
|
||||
*/
|
||||
protected abstract getQueryKey(params: TParams): QueryKey;
|
||||
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
||||
|
||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||
return {
|
||||
queryKey: this.getQueryKey(params),
|
||||
queryFn: () => this.fetchFn(params),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Common Getters ---
|
||||
get fonts() {
|
||||
return this.result.data ?? [];
|
||||
}
|
||||
get isLoading() {
|
||||
return this.result.isLoading;
|
||||
}
|
||||
get isFetching() {
|
||||
return this.result.isFetching;
|
||||
}
|
||||
get isError() {
|
||||
return this.result.isError;
|
||||
}
|
||||
get isEmpty() {
|
||||
return !this.isLoading && this.fonts.length === 0;
|
||||
}
|
||||
|
||||
// --- Common Actions ---
|
||||
|
||||
addBinding(getter: () => Partial<TParams>) {
|
||||
this.#bindings.push(getter);
|
||||
|
||||
return () => {
|
||||
this.#bindings = this.#bindings.filter(b => b !== getter);
|
||||
};
|
||||
}
|
||||
|
||||
setParams(newParams: Partial<TParams>) {
|
||||
this.#internalParams = { ...this.params, ...newParams };
|
||||
}
|
||||
/**
|
||||
* Invalidate cache and refetch
|
||||
*/
|
||||
invalidate() {
|
||||
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refetch
|
||||
*/
|
||||
async refetch() {
|
||||
await this.observer.refetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch with different params (for hover states, pagination, etc.)
|
||||
*/
|
||||
async prefetch(params: TParams) {
|
||||
await this.qc.prefetchQuery(this.getOptions(params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing queries
|
||||
*/
|
||||
cancel() {
|
||||
this.qc.cancelQueries({
|
||||
queryKey: this.getQueryKey(this.params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for current params
|
||||
*/
|
||||
clearCache() {
|
||||
this.qc.removeQueries({
|
||||
queryKey: this.getQueryKey(this.params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data without triggering fetch
|
||||
*/
|
||||
getCachedData() {
|
||||
return this.qc.getQueryData<UnifiedFont[]>(
|
||||
this.getQueryKey(this.params),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data manually (optimistic updates)
|
||||
*/
|
||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
||||
this.qc.setQueryData(
|
||||
this.getQueryKey(this.params),
|
||||
updater,
|
||||
);
|
||||
}
|
||||
}
|
||||
20
src/entities/Font/model/store/index.ts
Normal file
20
src/entities/Font/model/store/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* UNIFIED FONT STORE EXPORTS
|
||||
* ============================================================================
|
||||
*
|
||||
* Single export point for the unified font store infrastructure.
|
||||
*/
|
||||
|
||||
// Primary store (unified)
|
||||
export {
|
||||
createUnifiedFontStore,
|
||||
type UnifiedFontStore,
|
||||
unifiedFontStore,
|
||||
} from './unifiedFontStore.svelte';
|
||||
|
||||
// Applied fonts manager (CSS loading - unchanged)
|
||||
export {
|
||||
appliedFontsManager,
|
||||
type FontConfigRequest,
|
||||
} from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
43
src/entities/Font/model/store/types.ts
Normal file
43
src/entities/Font/model/store/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* UNIFIED FONT STORE TYPES
|
||||
* ============================================================================
|
||||
*
|
||||
* Type definitions for the unified font store infrastructure.
|
||||
* Provides types for filters, sorting, and fetch parameters.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontshareParams,
|
||||
GoogleFontsParams,
|
||||
} from '$entities/Font/api';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '$entities/Font/model/types/common';
|
||||
|
||||
/**
|
||||
* Sort configuration
|
||||
*/
|
||||
export interface FontSort {
|
||||
field: 'name' | 'popularity' | 'category' | 'date';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch params for unified API
|
||||
*/
|
||||
export interface FetchFontsParams {
|
||||
providers?: FontProvider[];
|
||||
categories?: FontCategory[];
|
||||
subsets?: FontSubset[];
|
||||
search?: string;
|
||||
sort?: FontSort;
|
||||
forceRefetch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-specific params union
|
||||
*/
|
||||
export type ProviderParams = GoogleFontsParams | FontshareParams;
|
||||
377
src/entities/Font/model/store/unifiedFontStore.svelte.ts
Normal file
377
src/entities/Font/model/store/unifiedFontStore.svelte.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Unified font store
|
||||
*
|
||||
* Single source of truth for font data, powered by the proxy API.
|
||||
* Extends BaseFontStore for TanStack Query integration and reactivity.
|
||||
*
|
||||
* Key features:
|
||||
* - Provider-agnostic (proxy API handles provider logic)
|
||||
* - Reactive to filter changes
|
||||
* - Optimistic updates via TanStack Query
|
||||
* - Pagination support
|
||||
* - Provider-specific shortcuts for common operations
|
||||
*/
|
||||
|
||||
import type { QueryObserverOptions } from '@tanstack/query-core';
|
||||
import type { ProxyFontsParams } from '../../api';
|
||||
import { fetchProxyFonts } from '../../api';
|
||||
import type { UnifiedFont } from '../types';
|
||||
import { BaseFontStore } from './baseFontStore.svelte';
|
||||
|
||||
/**
|
||||
* Unified font store wrapping TanStack Query with Svelte 5 runes
|
||||
*
|
||||
* Extends BaseFontStore to provide:
|
||||
* - Reactive state management
|
||||
* - TanStack Query integration for caching
|
||||
* - Dynamic parameter binding for filters
|
||||
* - Pagination support
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const store = new UnifiedFontStore({
|
||||
* provider: 'google',
|
||||
* category: 'sans-serif',
|
||||
* limit: 50
|
||||
* });
|
||||
*
|
||||
* // Access reactive state
|
||||
* $effect(() => {
|
||||
* console.log(store.fonts);
|
||||
* console.log(store.isLoading);
|
||||
* console.log(store.pagination);
|
||||
* });
|
||||
*
|
||||
* // Update parameters
|
||||
* store.setCategory('serif');
|
||||
* store.nextPage();
|
||||
* ```
|
||||
*/
|
||||
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
/**
|
||||
* Store pagination metadata separately from fonts
|
||||
* This is a workaround for TanStack Query's type system
|
||||
*/
|
||||
#paginationMetadata = $state<
|
||||
{
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
} | null
|
||||
>(null);
|
||||
|
||||
/**
|
||||
* Accumulated fonts from all pages (for infinite scroll)
|
||||
*/
|
||||
#accumulatedFonts = $state<UnifiedFont[]>([]);
|
||||
|
||||
/**
|
||||
* Pagination metadata (derived from proxy API response)
|
||||
*/
|
||||
readonly pagination = $derived.by(() => {
|
||||
if (this.#paginationMetadata) {
|
||||
const { total, limit, offset } = this.#paginationMetadata;
|
||||
return {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total,
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
return {
|
||||
total: 0,
|
||||
limit: this.params.limit || 50,
|
||||
offset: this.params.offset || 0,
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
totalPages: 0,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Track previous filter params to detect changes and reset pagination
|
||||
*/
|
||||
#previousFilterParams = $state<string>('');
|
||||
|
||||
/**
|
||||
* Cleanup function for the filter tracking effect
|
||||
*/
|
||||
#filterCleanup: (() => void) | null = null;
|
||||
|
||||
constructor(initialParams: ProxyFontsParams = {}) {
|
||||
super(initialParams);
|
||||
|
||||
// Track filter params (excluding pagination params)
|
||||
// Wrapped in $effect.root() to prevent effect_orphan error
|
||||
this.#filterCleanup = $effect.root(() => {
|
||||
$effect(() => {
|
||||
const filterParams = JSON.stringify({
|
||||
provider: this.params.provider,
|
||||
category: this.params.category,
|
||||
subset: this.params.subset,
|
||||
q: this.params.q,
|
||||
});
|
||||
|
||||
// If filters changed, reset offset to 0
|
||||
if (filterParams !== this.#previousFilterParams) {
|
||||
if (this.#previousFilterParams && this.params.offset !== 0) {
|
||||
this.setParams({ offset: 0 });
|
||||
}
|
||||
this.#previousFilterParams = filterParams;
|
||||
}
|
||||
});
|
||||
|
||||
// Effect: Sync state from Query result (Handles Cache Hits)
|
||||
$effect(() => {
|
||||
const data = this.result.data;
|
||||
const offset = this.params.offset || 0;
|
||||
|
||||
// When we have data and we are at the start (offset 0),
|
||||
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
||||
// This fixes the issue where cache hits skip fetchFn side-effects.
|
||||
if (offset === 0 && data && data.length > 0) {
|
||||
this.#accumulatedFonts = data;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up both parent and child effects
|
||||
*/
|
||||
destroy() {
|
||||
// Call parent cleanup (TanStack observer effect)
|
||||
super.destroy();
|
||||
|
||||
// Call filter tracking effect cleanup
|
||||
if (this.#filterCleanup) {
|
||||
this.#filterCleanup();
|
||||
this.#filterCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key for TanStack Query caching
|
||||
* Normalizes params to treat empty arrays/strings as undefined
|
||||
*/
|
||||
protected getQueryKey(params: ProxyFontsParams) {
|
||||
// Normalize params to treat empty arrays/strings as undefined
|
||||
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
||||
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
|
||||
return acc;
|
||||
}
|
||||
return { ...acc, [key]: value };
|
||||
}, {});
|
||||
|
||||
// Return a consistent key
|
||||
return ['unifiedFonts', normalized] as const;
|
||||
}
|
||||
|
||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||
const hasFilters = !!(params.q || params.provider || params.category || params.subset);
|
||||
return {
|
||||
queryKey: this.getQueryKey(params),
|
||||
queryFn: () => this.fetchFn(params),
|
||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch function that calls the proxy API
|
||||
* Returns the full response including pagination metadata
|
||||
*/
|
||||
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
||||
const response = await fetchProxyFonts(params);
|
||||
|
||||
// Validate response structure
|
||||
if (!response) {
|
||||
console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params });
|
||||
throw new Error('Proxy API returned undefined response');
|
||||
}
|
||||
|
||||
if (!response.fonts) {
|
||||
console.error('[UnifiedFontStore] response.fonts is undefined', { response });
|
||||
throw new Error('Proxy API response missing fonts array');
|
||||
}
|
||||
|
||||
if (!Array.isArray(response.fonts)) {
|
||||
console.error('[UnifiedFontStore] response.fonts is not an array', {
|
||||
fonts: response.fonts,
|
||||
});
|
||||
throw new Error('Proxy API fonts is not an array');
|
||||
}
|
||||
|
||||
// Store pagination metadata separately for derived values
|
||||
this.#paginationMetadata = {
|
||||
total: response.total ?? 0,
|
||||
limit: response.limit ?? this.params.limit ?? 50,
|
||||
offset: response.offset ?? this.params.offset ?? 0,
|
||||
};
|
||||
|
||||
// Accumulate fonts for infinite scroll
|
||||
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
||||
// This prevents race conditions and double-setting.
|
||||
if (params.offset !== 0) {
|
||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||
}
|
||||
|
||||
return response.fonts;
|
||||
}
|
||||
|
||||
// --- Getters (proxied from BaseFontStore) ---
|
||||
|
||||
/**
|
||||
* Get all accumulated fonts (for infinite scroll)
|
||||
*/
|
||||
get fonts(): UnifiedFont[] {
|
||||
return this.#accumulatedFonts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if loading initial data
|
||||
*/
|
||||
get isLoading(): boolean {
|
||||
return this.result.isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fetching (including background refetches)
|
||||
*/
|
||||
get isFetching(): boolean {
|
||||
return this.result.isFetching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error occurred
|
||||
*/
|
||||
get isError(): boolean {
|
||||
return this.result.isError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result is empty (not loading and no fonts)
|
||||
*/
|
||||
get isEmpty(): boolean {
|
||||
return !this.isLoading && this.fonts.length === 0;
|
||||
}
|
||||
|
||||
// --- Provider-specific shortcuts ---
|
||||
|
||||
/**
|
||||
* Set provider filter
|
||||
*/
|
||||
setProvider(provider: 'google' | 'fontshare' | undefined) {
|
||||
this.setParams({ provider });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set category filter
|
||||
*/
|
||||
setCategory(category: ProxyFontsParams['category']) {
|
||||
this.setParams({ category });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set subset filter
|
||||
*/
|
||||
setSubset(subset: ProxyFontsParams['subset']) {
|
||||
this.setParams({ subset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set search query
|
||||
*/
|
||||
setSearch(search: string) {
|
||||
this.setParams({ q: search || undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sort order
|
||||
*/
|
||||
setSort(sort: ProxyFontsParams['sort']) {
|
||||
this.setParams({ sort });
|
||||
}
|
||||
|
||||
// --- Pagination methods ---
|
||||
|
||||
/**
|
||||
* Go to next page
|
||||
*/
|
||||
nextPage() {
|
||||
if (this.pagination.hasMore) {
|
||||
this.setParams({
|
||||
offset: this.pagination.offset + this.pagination.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to previous page
|
||||
*/
|
||||
prevPage() {
|
||||
if (this.pagination.page > 1) {
|
||||
this.setParams({
|
||||
offset: this.pagination.offset - this.pagination.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
goToPage(page: number) {
|
||||
if (page >= 1 && page <= this.pagination.totalPages) {
|
||||
this.setParams({
|
||||
offset: (page - 1) * this.pagination.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set limit (items per page)
|
||||
*/
|
||||
setLimit(limit: number) {
|
||||
this.setParams({ limit });
|
||||
}
|
||||
|
||||
// --- Category shortcuts (for convenience) ---
|
||||
|
||||
get sansSerifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||
}
|
||||
|
||||
get serifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'serif');
|
||||
}
|
||||
|
||||
get displayFonts() {
|
||||
return this.fonts.filter(f => f.category === 'display');
|
||||
}
|
||||
|
||||
get handwritingFonts() {
|
||||
return this.fonts.filter(f => f.category === 'handwriting');
|
||||
}
|
||||
|
||||
get monospaceFonts() {
|
||||
return this.fonts.filter(f => f.category === 'monospace');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create unified font store
|
||||
*/
|
||||
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
|
||||
return new UnifiedFontStore(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for global use
|
||||
* Initialized with a default limit to prevent fetching all fonts at once
|
||||
*/
|
||||
export const unifiedFontStore = new UnifiedFontStore({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
58
src/entities/Font/model/types/common.ts
Normal file
58
src/entities/Font/model/types/common.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* DOMAIN TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
import type { FontCategory as FontshareFontCategory } from './fontshare';
|
||||
import type { FontCategory as GoogleFontCategory } from './google';
|
||||
|
||||
/**
|
||||
* Font category
|
||||
*/
|
||||
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
|
||||
|
||||
/**
|
||||
* Font provider
|
||||
*/
|
||||
export type FontProvider = 'google' | 'fontshare';
|
||||
|
||||
/**
|
||||
* Font subset
|
||||
*/
|
||||
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
||||
|
||||
/**
|
||||
* Filter state
|
||||
*/
|
||||
export interface FontFilters {
|
||||
providers: FontProvider[];
|
||||
categories: FontCategory[];
|
||||
subsets: FontSubset[];
|
||||
}
|
||||
|
||||
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
|
||||
export type FilterType = CheckboxFilter | 'searchQuery';
|
||||
|
||||
/**
|
||||
* Standard font weights
|
||||
*/
|
||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||
|
||||
/**
|
||||
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
||||
*/
|
||||
export type FontWeightItalic = `${FontWeight}italic`;
|
||||
|
||||
/**
|
||||
* All possible font variants
|
||||
* - Numeric weights: "400", "700", etc.
|
||||
* - Italic variants: "400italic", "700italic", etc.
|
||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||
*/
|
||||
export type FontVariant =
|
||||
| FontWeight
|
||||
| FontWeightItalic
|
||||
| 'regular'
|
||||
| 'italic'
|
||||
| 'bold'
|
||||
| 'bolditalic';
|
||||
@@ -1,12 +1,39 @@
|
||||
import type { CollectionApiModel } from '../../../shared/types/collection';
|
||||
/**
|
||||
* ============================================================================
|
||||
* FONTHARE API TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' as const;
|
||||
|
||||
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const;
|
||||
export type FontCategory = 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script';
|
||||
|
||||
/**
|
||||
* Model of Fontshare API response
|
||||
* @see https://fontshare.com
|
||||
*
|
||||
* Fontshare API uses 'fonts' key instead of 'items' for the array
|
||||
*/
|
||||
export type FontshareApiModel = CollectionApiModel<FontshareFont>;
|
||||
export interface FontshareApiModel {
|
||||
/**
|
||||
* Number of items returned in current page/response
|
||||
*/
|
||||
count: number;
|
||||
|
||||
/**
|
||||
* Total number of items available across all pages
|
||||
*/
|
||||
count_total: number;
|
||||
|
||||
/**
|
||||
* Indicates if there are more items available beyond this page
|
||||
*/
|
||||
has_more: boolean;
|
||||
|
||||
/**
|
||||
* Array of fonts (Fontshare uses 'fonts' key, not 'items')
|
||||
*/
|
||||
fonts: FontshareFont[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual font metadata from Fontshare API
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* GOOGLE FONTS API TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type { FontVariant } from './common';
|
||||
|
||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||
|
||||
/**
|
||||
* Model of google fonts api response
|
||||
*/
|
||||
@@ -9,6 +19,9 @@ export interface GoogleFontsApiModel {
|
||||
items: FontItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual font from Google Fonts API
|
||||
*/
|
||||
export interface FontItem {
|
||||
/**
|
||||
* Font family name (e.g., "Roboto", "Open Sans", "Lato")
|
||||
@@ -20,7 +33,7 @@ export interface FontItem {
|
||||
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
|
||||
* Useful for grouping and filtering fonts by style
|
||||
*/
|
||||
category: string;
|
||||
category: FontCategory;
|
||||
|
||||
/**
|
||||
* Available font variants for this font family
|
||||
@@ -70,28 +83,10 @@ export interface FontItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard font weights that can appear in Google Fonts API
|
||||
* Type alias for backward compatibility
|
||||
* Google Fonts API font item
|
||||
*/
|
||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||
|
||||
/**
|
||||
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
||||
*/
|
||||
export type FontWeightItalic = `${FontWeight}italic`;
|
||||
|
||||
/**
|
||||
* All possible font variants in Google Fonts API
|
||||
* - Numeric weights: "400", "700", etc.
|
||||
* - Italic variants: "400italic", "700italic", etc.
|
||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||
*/
|
||||
export type FontVariant =
|
||||
| FontWeight
|
||||
| FontWeightItalic
|
||||
| 'regular'
|
||||
| 'italic'
|
||||
| 'bold'
|
||||
| 'bolditalic';
|
||||
export type GoogleFontItem = FontItem;
|
||||
|
||||
/**
|
||||
* Google Fonts API file mapping
|
||||
58
src/entities/Font/model/types/index.ts
Normal file
58
src/entities/Font/model/types/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* SINGLE EXPORT POINT
|
||||
* ============================================================================
|
||||
*
|
||||
* This is the single export point for all Font types.
|
||||
* All imports should use: `import { X } from '$entities/Font/model/types'`
|
||||
*/
|
||||
|
||||
// Domain types
|
||||
export type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
} from './common';
|
||||
|
||||
// Google Fonts API types
|
||||
export type {
|
||||
FontFiles,
|
||||
FontItem,
|
||||
GoogleFontItem,
|
||||
GoogleFontsApiModel,
|
||||
} from './google';
|
||||
|
||||
// Fontshare API types
|
||||
export type {
|
||||
FontshareApiModel,
|
||||
FontshareAxis,
|
||||
FontshareDesigner,
|
||||
FontshareFeature,
|
||||
FontshareFont,
|
||||
FontshareLink,
|
||||
FontsharePublisher,
|
||||
FontshareStyle,
|
||||
FontshareStyleProperties,
|
||||
FontshareTag,
|
||||
FontshareWeight,
|
||||
} from './fontshare';
|
||||
export { FONTSHARE_API_URL } from './fontshare';
|
||||
|
||||
// Normalization types
|
||||
export type {
|
||||
FontFeatures,
|
||||
FontMetadata,
|
||||
FontStyleUrls,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './normalize';
|
||||
|
||||
// Store types
|
||||
export type {
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
} from './store';
|
||||
94
src/entities/Font/model/types/normalize.ts
Normal file
94
src/entities/Font/model/types/normalize.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* NORMALIZATION TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
} from './common';
|
||||
|
||||
/**
|
||||
* Font variant types (standardized)
|
||||
*/
|
||||
export type UnifiedFontVariant = FontVariant;
|
||||
|
||||
/**
|
||||
* Font style URLs
|
||||
*/
|
||||
export interface LegacyFontStyleUrls {
|
||||
/** Regular weight URL */
|
||||
regular?: string;
|
||||
/** Italic URL */
|
||||
italic?: string;
|
||||
/** Bold weight URL */
|
||||
bold?: string;
|
||||
/** Bold italic URL */
|
||||
boldItalic?: string;
|
||||
}
|
||||
|
||||
export interface FontStyleUrls extends LegacyFontStyleUrls {
|
||||
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font metadata
|
||||
*/
|
||||
export interface FontMetadata {
|
||||
/** Timestamp when font was cached */
|
||||
cachedAt: number;
|
||||
/** Font version from provider */
|
||||
version?: string;
|
||||
/** Last modified date from provider */
|
||||
lastModified?: string;
|
||||
/** Popularity rank (if available from provider) */
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font features (variable fonts, axes, tags)
|
||||
*/
|
||||
export interface FontFeatures {
|
||||
/** Whether this is a variable font */
|
||||
isVariable?: boolean;
|
||||
/** Variable font axes (for Fontshare) */
|
||||
axes?: Array<{
|
||||
name: string;
|
||||
property: string;
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}>;
|
||||
/** Usage tags (for Fontshare) */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified font model
|
||||
*
|
||||
* Combines Google Fonts and Fontshare data into a common interface
|
||||
* for consistent font handling across the application.
|
||||
*/
|
||||
export interface UnifiedFont {
|
||||
/** Unique identifier (Google: family name, Fontshare: slug) */
|
||||
id: string;
|
||||
/** Font display name */
|
||||
name: string;
|
||||
/** Font provider (google | fontshare) */
|
||||
provider: FontProvider;
|
||||
/** Font category classification */
|
||||
category: FontCategory;
|
||||
/** Supported character subsets */
|
||||
subsets: FontSubset[];
|
||||
/** Available font variants (weights, styles) */
|
||||
variants: UnifiedFontVariant[];
|
||||
/** URL mapping for font file downloads */
|
||||
styles: FontStyleUrls;
|
||||
/** Additional metadata */
|
||||
metadata: FontMetadata;
|
||||
/** Advanced font features */
|
||||
features: FontFeatures;
|
||||
}
|
||||
48
src/entities/Font/model/types/store.ts
Normal file
48
src/entities/Font/model/types/store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* STORE TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from './common';
|
||||
import type { UnifiedFont } from './normalize';
|
||||
|
||||
/**
|
||||
* Font collection state
|
||||
*/
|
||||
export interface FontCollectionState {
|
||||
/** All cached fonts */
|
||||
fonts: Record<string, UnifiedFont>;
|
||||
/** Active filters */
|
||||
filters: FontCollectionFilters;
|
||||
/** Sort configuration */
|
||||
sort: FontCollectionSort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection filters
|
||||
*/
|
||||
export interface FontCollectionFilters {
|
||||
/** Search query */
|
||||
searchQuery: string;
|
||||
/** Filter by providers */
|
||||
providers?: FontProvider[];
|
||||
/** Filter by categories */
|
||||
categories?: FontCategory[];
|
||||
/** Filter by subsets */
|
||||
subsets?: FontSubset[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection sort configuration
|
||||
*/
|
||||
export interface FontCollectionSort {
|
||||
/** Sort field */
|
||||
field: 'name' | 'popularity' | 'category';
|
||||
/** Sort direction */
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
77
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal file
77
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<!--
|
||||
Component: FontApplicator
|
||||
Loads fonts from fontshare with link tag
|
||||
- Loads font only if it's not already applied
|
||||
- Reacts to font load status to show/hide content
|
||||
- Adds smooth transition when font appears
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
import {
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
} from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Applied font
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Font weight
|
||||
*/
|
||||
weight?: number;
|
||||
/**
|
||||
* Additional classes
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Children
|
||||
*/
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
font,
|
||||
weight = 400,
|
||||
className,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const status = $derived(
|
||||
appliedFontsManager.getFontStatus(
|
||||
font.id,
|
||||
weight,
|
||||
font.features.isVariable,
|
||||
),
|
||||
);
|
||||
|
||||
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
|
||||
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||
|
||||
const transitionClasses = $derived(
|
||||
prefersReducedMotion.current
|
||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
||||
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
style:font-family={shouldReveal
|
||||
? `'${font.name}'`
|
||||
: 'system-ui, -apple-system, sans-serif'}
|
||||
class={cn(
|
||||
transitionClasses,
|
||||
// If reduced motion is on, we skip the transform/blur entirely
|
||||
!shouldReveal
|
||||
&& !prefersReducedMotion.current
|
||||
&& 'opacity-50 scale-[0.95] blur-sm',
|
||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
||||
shouldReveal && 'opacity-100 scale-100 blur-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
39
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal file
39
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { type UnifiedFont } from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Object with information about font
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Is element fully visible
|
||||
*/
|
||||
isFullyVisible: boolean;
|
||||
/**
|
||||
* Is element partially visible
|
||||
*/
|
||||
isPartiallyVisible: boolean;
|
||||
/**
|
||||
* From 0 to 1
|
||||
*/
|
||||
proximity: number;
|
||||
/**
|
||||
* Children snippet
|
||||
*/
|
||||
children: Snippet<[font: UnifiedFont]>;
|
||||
}
|
||||
|
||||
const { font, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'pb-1 will-change-transform transition-transform duration-200 ease-out',
|
||||
'hover:scale-[0.98]', // Simple CSS hover effect
|
||||
)}
|
||||
>
|
||||
{@render children?.(font)}
|
||||
</div>
|
||||
129
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal file
129
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal file
@@ -0,0 +1,129 @@
|
||||
<!--
|
||||
Component: FontVirtualList
|
||||
- Renders a virtualized list of fonts
|
||||
- Handles font registration with the manager
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
Skeleton,
|
||||
VirtualList,
|
||||
} from '$shared/ui';
|
||||
import type {
|
||||
ComponentProps,
|
||||
Snippet,
|
||||
} from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getFontUrl } from '../../lib';
|
||||
import {
|
||||
type FontConfigRequest,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
unifiedFontStore,
|
||||
} from '../../model';
|
||||
|
||||
interface Props extends
|
||||
Omit<
|
||||
ComponentProps<typeof VirtualList<UnifiedFont>>,
|
||||
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
|
||||
>
|
||||
{
|
||||
/**
|
||||
* Callback for when visible items change
|
||||
*/
|
||||
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
||||
/**
|
||||
* Weight of the font
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Skeleton snippet
|
||||
*/
|
||||
skeleton?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
onVisibleItemsChange,
|
||||
weight,
|
||||
skeleton,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const isLoading = $derived(
|
||||
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
||||
);
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||
const configs: FontConfigRequest[] = [];
|
||||
|
||||
visibleItems.forEach(item => {
|
||||
const url = getFontUrl(item, weight);
|
||||
|
||||
if (url) {
|
||||
configs.push({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
weight,
|
||||
url,
|
||||
isVariable: item.features?.isVariable,
|
||||
});
|
||||
}
|
||||
});
|
||||
// Auto-register fonts with the manager
|
||||
appliedFontsManager.touch(configs);
|
||||
|
||||
// Forward the call to any external listener
|
||||
// onVisibleItemsChange?.(visibleItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!unifiedFontStore.pagination.hasMore
|
||||
|| unifiedFontStore.isFetching
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unifiedFontStore.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll near bottom - auto-load next page
|
||||
*
|
||||
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = unifiedFontStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items
|
||||
if (hasMore && !unifiedFontStore.isFetching) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full h-full">
|
||||
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
||||
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||
<div transition:fade={{ duration: 300 }}>
|
||||
{@render skeleton()}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||
<VirtualList
|
||||
items={unifiedFontStore.fonts}
|
||||
total={unifiedFontStore.pagination.total}
|
||||
isLoading={isLoading}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
{...rest}
|
||||
>
|
||||
{#snippet children(scope)}
|
||||
{@render children(scope)}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
{/if}
|
||||
</div>
|
||||
9
src/entities/Font/ui/index.ts
Normal file
9
src/entities/Font/ui/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||
import FontListItem from './FontListItem/FontListItem.svelte';
|
||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
};
|
||||
1
src/features/DisplayFont/index.ts
Normal file
1
src/features/DisplayFont/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FontSampler } from './ui';
|
||||
110
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal file
110
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
Component: FontSampler
|
||||
Displays a sample text with a given font in a contenteditable element.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import {
|
||||
ContentEditable,
|
||||
Footnote,
|
||||
// IconButton,
|
||||
} from '$shared/ui';
|
||||
// import XIcon from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Font info
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Text to display
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Index of the font sampler
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Font settings
|
||||
*/
|
||||
fontSize?: number;
|
||||
lineHeight?: number;
|
||||
letterSpacing?: number;
|
||||
}
|
||||
|
||||
let { font, text = $bindable(), index = 0, ...restProps }: Props = $props();
|
||||
|
||||
const fontWeight = $derived(controlManager.weight);
|
||||
const fontSize = $derived(controlManager.renderedSize);
|
||||
const lineHeight = $derived(controlManager.height);
|
||||
const letterSpacing = $derived(controlManager.spacing);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="
|
||||
w-full h-full rounded-xl sm:rounded-2xl
|
||||
flex flex-col
|
||||
bg-background-80
|
||||
border border-border-muted
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
relative overflow-hidden
|
||||
"
|
||||
style:font-weight={fontWeight}
|
||||
>
|
||||
<div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-border-subtle flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 sm:gap-2.5">
|
||||
<Footnote>
|
||||
typeface_{String(index).padStart(3, '0')}
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
|
||||
<div class="font-bold text-foreground">
|
||||
{font.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<IconButton
|
||||
onclick={removeSample}
|
||||
class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<XIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-5 md:p-8 relative z-10">
|
||||
<FontApplicator {font} weight={fontWeight}>
|
||||
<ContentEditable
|
||||
bind:text
|
||||
{...restProps}
|
||||
{fontSize}
|
||||
{lineHeight}
|
||||
{letterSpacing}
|
||||
/>
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-border-subtle w-full flex flex-row gap-2 sm:gap-4 bg-background mt-auto">
|
||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
|
||||
SZ:{fontSize}PX
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||
WGT:{fontWeight}
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||
LH:{lineHeight?.toFixed(2)}
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
|
||||
LTR:{letterSpacing}
|
||||
</Footnote>
|
||||
</div>
|
||||
</div>
|
||||
3
src/features/DisplayFont/ui/index.ts
Normal file
3
src/features/DisplayFont/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
|
||||
export { FontSampler };
|
||||
@@ -1,5 +0,0 @@
|
||||
export { categoryFilterStore } from './model/stores/categoryFilterStore';
|
||||
export { providersFilterStore } from './model/stores/providersFilterStore';
|
||||
export { subsetsFilterStore } from './model/stores/subsetsFilterStore';
|
||||
|
||||
export { clearAllFilters } from './model/services/clearAllFilters/clearAllFilters';
|
||||
@@ -1,9 +0,0 @@
|
||||
import { categoryFilterStore } from '../../stores/categoryFilterStore';
|
||||
import { providersFilterStore } from '../../stores/providersFilterStore';
|
||||
import { subsetsFilterStore } from '../../stores/subsetsFilterStore';
|
||||
|
||||
export function clearAllFilters() {
|
||||
categoryFilterStore.deselectAllProperties();
|
||||
providersFilterStore.deselectAllProperties();
|
||||
subsetsFilterStore.deselectAllProperties();
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
type FilterModel,
|
||||
createFilterStore,
|
||||
} from '$shared/store/createFilterStore';
|
||||
import { FONT_CATEGORIES } from '../const/const';
|
||||
|
||||
/**
|
||||
* Initial state for CategoryFilter
|
||||
*/
|
||||
export const initialState: FilterModel = {
|
||||
searchQuery: '',
|
||||
properties: FONT_CATEGORIES,
|
||||
};
|
||||
|
||||
/**
|
||||
* CategoryFilter store
|
||||
*/
|
||||
export const categoryFilterStore = createFilterStore(initialState);
|
||||
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
type FilterModel,
|
||||
createFilterStore,
|
||||
} from '$shared/store/createFilterStore';
|
||||
import { FONT_PROVIDERS } from '../const/const';
|
||||
|
||||
/**
|
||||
* Initial state for ProvidersFilter
|
||||
*/
|
||||
export const initialState: FilterModel = {
|
||||
searchQuery: '',
|
||||
properties: FONT_PROVIDERS,
|
||||
};
|
||||
|
||||
/**
|
||||
* ProvidersFilter store
|
||||
*/
|
||||
export const providersFilterStore = createFilterStore(initialState);
|
||||
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
type FilterModel,
|
||||
createFilterStore,
|
||||
} from '$shared/store/createFilterStore';
|
||||
import { FONT_SUBSETS } from '../const/const';
|
||||
|
||||
/**
|
||||
* Initial state for SubsetsFilter
|
||||
*/
|
||||
const initialState: FilterModel = {
|
||||
searchQuery: '',
|
||||
properties: FONT_SUBSETS,
|
||||
};
|
||||
|
||||
/**
|
||||
* SubsetsFilter store
|
||||
*/
|
||||
export const subsetsFilterStore = createFilterStore(initialState);
|
||||
18
src/features/GetFonts/index.ts
Normal file
18
src/features/GetFonts/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
createFilterManager,
|
||||
type FilterManager,
|
||||
mapManagerToParams,
|
||||
} from './lib';
|
||||
|
||||
export {
|
||||
FONT_CATEGORIES,
|
||||
FONT_PROVIDERS,
|
||||
FONT_SUBSETS,
|
||||
} from './model/const/const';
|
||||
|
||||
export { filterManager } from './model/state/manager.svelte';
|
||||
|
||||
export {
|
||||
FilterControls,
|
||||
Filters,
|
||||
} from './ui';
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createFilter } from '$shared/lib';
|
||||
import { createDebouncedState } from '$shared/lib/helpers';
|
||||
import type { FilterConfig } from '../../model';
|
||||
|
||||
/**
|
||||
* Create a filter manager instance.
|
||||
* - Uses debounce to update search query for better performance.
|
||||
* - Manages filter instances for each group.
|
||||
*
|
||||
* @param config - Configuration for the filter manager.
|
||||
* @returns - An instance of the filter manager.
|
||||
*/
|
||||
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
|
||||
const search = createDebouncedState(config.queryValue ?? '');
|
||||
|
||||
// Create filter instances upfront
|
||||
const groups = $state(
|
||||
config.groups.map(config => ({
|
||||
id: config.id,
|
||||
label: config.label,
|
||||
instance: createFilter({ properties: config.properties }),
|
||||
})),
|
||||
);
|
||||
|
||||
// Derived: any selection across all groups
|
||||
const hasAnySelection = $derived(
|
||||
groups.some(group => group.instance.selectedProperties.length > 0),
|
||||
);
|
||||
|
||||
return {
|
||||
// Getter for queryValue (immediate value for UI)
|
||||
get queryValue() {
|
||||
return search.immediate;
|
||||
},
|
||||
|
||||
// Setter for queryValue
|
||||
set queryValue(value) {
|
||||
search.immediate = value;
|
||||
},
|
||||
|
||||
// Getter for queryValue (debounced value for logic)
|
||||
get debouncedQueryValue() {
|
||||
return search.debounced;
|
||||
},
|
||||
|
||||
// Direct array reference (reactive)
|
||||
get groups() {
|
||||
return groups;
|
||||
},
|
||||
|
||||
// Derived values
|
||||
get hasAnySelection() {
|
||||
return hasAnySelection;
|
||||
},
|
||||
|
||||
// Global action
|
||||
deselectAllGlobal: () => {
|
||||
groups.forEach(group => group.instance.deselectAll());
|
||||
},
|
||||
|
||||
// Helper to get group by id
|
||||
getGroup: (id: string) => {
|
||||
return groups.find(g => g.id === id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type FilterManager = ReturnType<typeof createFilterManager>;
|
||||
6
src/features/GetFonts/lib/index.ts
Normal file
6
src/features/GetFonts/lib/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createFilterManager,
|
||||
type FilterManager,
|
||||
} from './filterManager/filterManager.svelte';
|
||||
|
||||
export { mapManagerToParams } from './mapper/mapManagerToParams';
|
||||
54
src/features/GetFonts/lib/mapper/mapManagerToParams.ts
Normal file
54
src/features/GetFonts/lib/mapper/mapManagerToParams.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { ProxyFontsParams } from '$entities/Font/api';
|
||||
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
||||
|
||||
/**
|
||||
* Maps filter manager to proxy API parameters.
|
||||
*
|
||||
* Transforms UI filter state into proxy API query parameters.
|
||||
* Handles conversion from filter groups to API-specific parameters.
|
||||
*
|
||||
* @param manager - Filter manager instance with reactive state
|
||||
* @returns - Partial proxy API parameters ready for API call
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Example filter manager state:
|
||||
* // {
|
||||
* // queryValue: 'roboto',
|
||||
* // providers: ['google'],
|
||||
* // categories: ['sans-serif'],
|
||||
* // subsets: ['latin']
|
||||
* // }
|
||||
*
|
||||
* const params = mapManagerToParams(manager);
|
||||
* // Returns: { provider: 'google', category: 'sans-serif', subset: 'latin', q: 'roboto' }
|
||||
* ```
|
||||
*/
|
||||
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
|
||||
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
|
||||
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
|
||||
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
|
||||
|
||||
return {
|
||||
// Search query (debounced)
|
||||
q: manager.debouncedQueryValue || undefined,
|
||||
|
||||
// Provider filter (single value - proxy API doesn't support array)
|
||||
// Use first provider if multiple selected, or undefined if none/all selected
|
||||
provider: providers && providers.length === 1
|
||||
? (providers[0] as 'google' | 'fontshare')
|
||||
: undefined,
|
||||
|
||||
// Category filter (single value - proxy API doesn't support array)
|
||||
// Use first category if multiple selected, or undefined if none/all selected
|
||||
category: categories && categories.length === 1
|
||||
? (categories[0] as ProxyFontsParams['category'])
|
||||
: undefined,
|
||||
|
||||
// Subset filter (single value - proxy API doesn't support array)
|
||||
// Use first subset if multiple selected, or undefined if none/all selected
|
||||
subset: subsets && subsets.length === 1
|
||||
? (subsets[0] as ProxyFontsParams['subset'])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,70 +1,90 @@
|
||||
import type { Property } from '$shared/store/createFilterStore';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '$entities/Font';
|
||||
import type { Property } from '$shared/lib';
|
||||
|
||||
export const FONT_CATEGORIES: Property[] = [
|
||||
export const FONT_CATEGORIES: Property<FontCategory>[] = [
|
||||
{
|
||||
id: 'serif',
|
||||
name: 'Serif',
|
||||
value: 'serif',
|
||||
},
|
||||
{
|
||||
id: 'sans-serif',
|
||||
name: 'Sans-serif',
|
||||
value: 'sans-serif',
|
||||
},
|
||||
{
|
||||
id: 'display',
|
||||
name: 'Display',
|
||||
value: 'display',
|
||||
},
|
||||
{
|
||||
id: 'handwriting',
|
||||
name: 'Handwriting',
|
||||
value: 'handwriting',
|
||||
},
|
||||
{
|
||||
id: 'monospace',
|
||||
name: 'Monospace',
|
||||
value: 'monospace',
|
||||
},
|
||||
{
|
||||
id: 'script',
|
||||
name: 'Script',
|
||||
value: 'script',
|
||||
},
|
||||
{
|
||||
id: 'slab',
|
||||
name: 'Slab',
|
||||
value: 'slab',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const FONT_PROVIDERS: Property[] = [
|
||||
export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google Fonts',
|
||||
value: 'google',
|
||||
},
|
||||
{
|
||||
id: 'fontshare',
|
||||
name: 'Fontshare',
|
||||
value: 'fontshare',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const FONT_SUBSETS: Property[] = [
|
||||
export const FONT_SUBSETS: Property<FontSubset>[] = [
|
||||
{
|
||||
id: 'latin',
|
||||
name: 'Latin',
|
||||
value: 'latin',
|
||||
},
|
||||
{
|
||||
id: 'latin-ext',
|
||||
name: 'Latin Extended',
|
||||
value: 'latin-ext',
|
||||
},
|
||||
{
|
||||
id: 'cyrillic',
|
||||
name: 'Cyrillic',
|
||||
value: 'cyrillic',
|
||||
},
|
||||
{
|
||||
id: 'greek',
|
||||
name: 'Greek',
|
||||
value: 'greek',
|
||||
},
|
||||
{
|
||||
id: 'arabic',
|
||||
name: 'Arabic',
|
||||
value: 'arabic',
|
||||
},
|
||||
{
|
||||
id: 'devanagari',
|
||||
name: 'Devanagari',
|
||||
value: 'devanagari',
|
||||
},
|
||||
] as const;
|
||||
6
src/features/GetFonts/model/index.ts
Normal file
6
src/features/GetFonts/model/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
FilterConfig,
|
||||
FilterGroupConfig,
|
||||
} from './types/filter';
|
||||
|
||||
export { filterManager } from './state/manager.svelte';
|
||||
29
src/features/GetFonts/model/state/manager.svelte.ts
Normal file
29
src/features/GetFonts/model/state/manager.svelte.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
|
||||
import {
|
||||
FONT_CATEGORIES,
|
||||
FONT_PROVIDERS,
|
||||
FONT_SUBSETS,
|
||||
} from '../const/const';
|
||||
|
||||
const initialConfig = {
|
||||
queryValue: '',
|
||||
groups: [
|
||||
{
|
||||
id: 'providers',
|
||||
label: 'Font provider',
|
||||
properties: FONT_PROVIDERS,
|
||||
},
|
||||
{
|
||||
id: 'subsets',
|
||||
label: 'Font subset',
|
||||
properties: FONT_SUBSETS,
|
||||
},
|
||||
{
|
||||
id: 'categories',
|
||||
label: 'Font category',
|
||||
properties: FONT_CATEGORIES,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const filterManager = createFilterManager(initialConfig);
|
||||
12
src/features/GetFonts/model/types/filter.ts
Normal file
12
src/features/GetFonts/model/types/filter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Property } from '$shared/lib';
|
||||
|
||||
export interface FilterGroupConfig<TValue extends string> {
|
||||
id: string;
|
||||
label: string;
|
||||
properties: Property<TValue>[];
|
||||
}
|
||||
|
||||
export interface FilterConfig<TValue extends string> {
|
||||
queryValue?: string;
|
||||
groups: FilterGroupConfig<TValue>[];
|
||||
}
|
||||
15
src/features/GetFonts/ui/Filters/Filters.svelte
Normal file
15
src/features/GetFonts/ui/Filters/Filters.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
Component: Filters
|
||||
Renders a list of CheckboxFilter components for each filter group.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { CheckboxFilter } from '$shared/ui';
|
||||
import { filterManager } from '../../model';
|
||||
</script>
|
||||
|
||||
{#each filterManager.groups as group (group.id)}
|
||||
<CheckboxFilter
|
||||
displayedLabel={group.label}
|
||||
filter={group.instance}
|
||||
/>
|
||||
{/each}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!--
|
||||
Component: FiltersControl
|
||||
Renders a group of action buttons for filter operations.
|
||||
- Reset: Clears all active filters (outline variant for secondary action)
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import Rotate from '@lucide/svelte/icons/rotate-ccw';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { Tween } from 'svelte/motion';
|
||||
import { filterManager } from '../../model';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className }: Props = $props();
|
||||
|
||||
const transform = new Tween(
|
||||
{ scale: 1, rotate: 0 },
|
||||
{ duration: 150, easing: cubicOut },
|
||||
);
|
||||
|
||||
function handleClick() {
|
||||
filterManager.deselectAllGlobal();
|
||||
|
||||
transform.set({ scale: 0.98, rotate: 1 }).then(() => {
|
||||
transform.set({ scale: 1, rotate: 0 });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('flex flex-row gap-2', className)}
|
||||
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group flex flex-1 cursor-pointer gap-1"
|
||||
onclick={handleClick}
|
||||
>
|
||||
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
7
src/features/GetFonts/ui/index.ts
Normal file
7
src/features/GetFonts/ui/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Filters from './Filters/Filters.svelte';
|
||||
import FilterControls from './FiltersControl/FilterControls.svelte';
|
||||
|
||||
export {
|
||||
FilterControls,
|
||||
Filters,
|
||||
};
|
||||
28
src/features/SetupFont/index.ts
Normal file
28
src/features/SetupFont/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export { TypographyMenu } from './ui';
|
||||
|
||||
export {
|
||||
type ControlId,
|
||||
controlManager,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LINE_HEIGHT,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
createTypographyControlManager,
|
||||
type TypographyControlManager,
|
||||
} from './lib';
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
type PersistentStore,
|
||||
type TypographyControl,
|
||||
createPersistentStore,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
type ControlId,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '../../model';
|
||||
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||
|
||||
export interface Control extends ControlOnlyFields<ControlId> {
|
||||
instance: TypographyControl;
|
||||
}
|
||||
|
||||
export class TypographyControlManager {
|
||||
#controls = new SvelteMap<string, Control>();
|
||||
#multiplier = $state(1);
|
||||
#storage: PersistentStore<TypographySettings>;
|
||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||
|
||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||
this.#storage = storage;
|
||||
|
||||
// Initial Load
|
||||
const saved = storage.value;
|
||||
this.#baseSize = saved.fontSize;
|
||||
|
||||
// Setup Controls
|
||||
configs.forEach(config => {
|
||||
const initialValue = this.#getInitialValue(config.id, saved);
|
||||
|
||||
this.#controls.set(config.id, {
|
||||
...config,
|
||||
instance: createTypographyControl({
|
||||
...config,
|
||||
value: initialValue,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// The Sync Effect (UI -> Storage)
|
||||
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
||||
const fontSize = this.#baseSize;
|
||||
const fontWeight = this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||
const lineHeight = this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||
const letterSpacing = this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||
|
||||
// Syncing back to storage
|
||||
this.#storage.value = {
|
||||
fontSize,
|
||||
fontWeight,
|
||||
lineHeight,
|
||||
letterSpacing,
|
||||
};
|
||||
});
|
||||
|
||||
// The Font Size Proxy Effect
|
||||
// This handles the "Multiplier" logic specifically for the Font Size Control
|
||||
$effect(() => {
|
||||
const ctrl = this.#controls.get('font_size')?.instance;
|
||||
if (!ctrl) return;
|
||||
|
||||
// If the user moves the slider/clicks buttons in the UI:
|
||||
// We update the baseSize (User Intent)
|
||||
const currentDisplayValue = ctrl.value;
|
||||
const calculatedBase = currentDisplayValue / this.#multiplier;
|
||||
|
||||
// Only update if the difference is significant (prevents rounding jitter)
|
||||
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
|
||||
this.#baseSize = calculatedBase;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#getInitialValue(id: string, saved: TypographySettings): number {
|
||||
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
||||
if (id === 'font_weight') return saved.fontWeight;
|
||||
if (id === 'line_height') return saved.lineHeight;
|
||||
if (id === 'letter_spacing') return saved.letterSpacing;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Getters / Setters ---
|
||||
|
||||
get multiplier() {
|
||||
return this.#multiplier;
|
||||
}
|
||||
set multiplier(value: number) {
|
||||
if (this.#multiplier === value) return;
|
||||
this.#multiplier = value;
|
||||
|
||||
// When multiplier changes, we must update the Font Size Control's display value
|
||||
const ctrl = this.#controls.get('font_size')?.instance;
|
||||
if (ctrl) {
|
||||
ctrl.value = this.#baseSize * this.#multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
/** The scaled size for CSS usage */
|
||||
get renderedSize() {
|
||||
return this.#baseSize * this.#multiplier;
|
||||
}
|
||||
|
||||
/** The base size (User Preference) */
|
||||
get baseSize() {
|
||||
return this.#baseSize;
|
||||
}
|
||||
set baseSize(val: number) {
|
||||
this.#baseSize = val;
|
||||
const ctrl = this.#controls.get('font_size')?.instance;
|
||||
if (ctrl) ctrl.value = val * this.#multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters for controls
|
||||
*/
|
||||
get controls() {
|
||||
return Array.from(this.#controls.values());
|
||||
}
|
||||
|
||||
get weightControl() {
|
||||
return this.#controls.get('font_weight')?.instance;
|
||||
}
|
||||
|
||||
get sizeControl() {
|
||||
return this.#controls.get('font_size')?.instance;
|
||||
}
|
||||
|
||||
get heightControl() {
|
||||
return this.#controls.get('line_height')?.instance;
|
||||
}
|
||||
|
||||
get spacingControl() {
|
||||
return this.#controls.get('letter_spacing')?.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters for values (besides font-size)
|
||||
*/
|
||||
get weight() {
|
||||
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||
}
|
||||
|
||||
get spacing() {
|
||||
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#storage.clear();
|
||||
const defaults = this.#storage.value;
|
||||
|
||||
this.#baseSize = defaults.fontSize;
|
||||
|
||||
// Reset all control instances
|
||||
this.#controls.forEach(c => {
|
||||
if (c.id === 'font_size') {
|
||||
c.instance.value = defaults.fontSize * this.#multiplier;
|
||||
} else {
|
||||
// Map storage key to control id
|
||||
const key = c.id.replace('_', '') as keyof TypographySettings;
|
||||
// Simplified for brevity, you'd map these properly:
|
||||
if (c.id === 'font_weight') c.instance.value = defaults.fontWeight;
|
||||
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
|
||||
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage schema for typography settings
|
||||
*/
|
||||
export interface TypographySettings {
|
||||
fontSize: number;
|
||||
fontWeight: number;
|
||||
lineHeight: number;
|
||||
letterSpacing: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typography control manager that handles a collection of typography controls.
|
||||
*
|
||||
* @param configs - Array of control configurations.
|
||||
* @param storageId - Persistent storage identifier.
|
||||
* @returns - Typography control manager instance.
|
||||
*/
|
||||
export function createTypographyControlManager(
|
||||
configs: ControlModel<ControlId>[],
|
||||
storageId: string = 'glyphdiff:typography',
|
||||
) {
|
||||
const storage = createPersistentStore<TypographySettings>(storageId, {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontWeight: DEFAULT_FONT_WEIGHT,
|
||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
});
|
||||
return new TypographyControlManager(configs, storage);
|
||||
}
|
||||
4
src/features/SetupFont/lib/index.ts
Normal file
4
src/features/SetupFont/lib/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createTypographyControlManager,
|
||||
type TypographyControlManager,
|
||||
} from './controlManager/controlManager.svelte';
|
||||
@@ -1,14 +1,88 @@
|
||||
export const DEFAULT_FONT_SIZE = 16;
|
||||
import type { ControlModel } from '$shared/lib';
|
||||
import type { ControlId } from '..';
|
||||
|
||||
/**
|
||||
* Font size constants
|
||||
*/
|
||||
export const DEFAULT_FONT_SIZE = 48;
|
||||
export const MIN_FONT_SIZE = 8;
|
||||
export const MAX_FONT_SIZE = 100;
|
||||
export const FONT_SIZE_STEP = 1;
|
||||
|
||||
/**
|
||||
* Font weight constants
|
||||
*/
|
||||
export const DEFAULT_FONT_WEIGHT = 400;
|
||||
export const MIN_FONT_WEIGHT = 100;
|
||||
export const MAX_FONT_WEIGHT = 900;
|
||||
export const FONT_WEIGHT_STEP = 100;
|
||||
|
||||
/**
|
||||
* Line height constants
|
||||
*/
|
||||
export const DEFAULT_LINE_HEIGHT = 1.5;
|
||||
export const MIN_LINE_HEIGHT = 1;
|
||||
export const MAX_LINE_HEIGHT = 2;
|
||||
export const LINE_HEIGHT_STEP = 0.05;
|
||||
|
||||
/**
|
||||
* Letter spacing constants
|
||||
*/
|
||||
export const DEFAULT_LETTER_SPACING = 0;
|
||||
export const MIN_LETTER_SPACING = -0.1;
|
||||
export const MAX_LETTER_SPACING = 0.5;
|
||||
export const LETTER_SPACING_STEP = 0.01;
|
||||
|
||||
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
{
|
||||
id: 'font_size',
|
||||
value: DEFAULT_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
min: MIN_FONT_SIZE,
|
||||
step: FONT_SIZE_STEP,
|
||||
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Font Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
value: DEFAULT_FONT_WEIGHT,
|
||||
max: MAX_FONT_WEIGHT,
|
||||
min: MIN_FONT_WEIGHT,
|
||||
step: FONT_WEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Font Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
value: DEFAULT_LINE_HEIGHT,
|
||||
max: MAX_LINE_HEIGHT,
|
||||
min: MIN_LINE_HEIGHT,
|
||||
step: LINE_HEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Line Height',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Letter Spacing',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Font size multipliers
|
||||
*/
|
||||
export const MULTIPLIER_S = 0.5;
|
||||
export const MULTIPLIER_M = 0.75;
|
||||
export const MULTIPLIER_L = 1;
|
||||
|
||||
24
src/features/SetupFont/model/index.ts
Normal file
24
src/features/SetupFont/model/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LINE_HEIGHT,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './const/const';
|
||||
|
||||
export {
|
||||
type ControlId,
|
||||
controlManager,
|
||||
} from './state/manager.svelte';
|
||||
6
src/features/SetupFont/model/state/manager.svelte.ts
Normal file
6
src/features/SetupFont/model/state/manager.svelte.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createTypographyControlManager } from '../../lib';
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
|
||||
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
|
||||
export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
|
||||
@@ -1,17 +0,0 @@
|
||||
import {
|
||||
type ControlModel,
|
||||
createControlStore,
|
||||
} from '$shared/store/createControlStore';
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
MIN_FONT_SIZE,
|
||||
} from '../const/const';
|
||||
|
||||
const initialValue: ControlModel = {
|
||||
value: DEFAULT_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
min: MIN_FONT_SIZE,
|
||||
};
|
||||
|
||||
export const fontSizeStore = createControlStore(initialValue);
|
||||
@@ -1,19 +0,0 @@
|
||||
import {
|
||||
type ControlModel,
|
||||
createControlStore,
|
||||
} from '$shared/store/createControlStore';
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
FONT_WEIGHT_STEP,
|
||||
MAX_FONT_WEIGHT,
|
||||
MIN_FONT_WEIGHT,
|
||||
} from '../const/const';
|
||||
|
||||
const initialValue: ControlModel = {
|
||||
value: DEFAULT_FONT_WEIGHT,
|
||||
max: MAX_FONT_WEIGHT,
|
||||
min: MIN_FONT_WEIGHT,
|
||||
step: FONT_WEIGHT_STEP,
|
||||
};
|
||||
|
||||
export const fontWeightStore = createControlStore(initialValue);
|
||||
@@ -1,19 +0,0 @@
|
||||
import {
|
||||
type ControlModel,
|
||||
createControlStore,
|
||||
} from '$shared/store/createControlStore';
|
||||
import {
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_LINE_HEIGHT,
|
||||
} from '../const/const';
|
||||
|
||||
const initialValue: ControlModel = {
|
||||
value: DEFAULT_LINE_HEIGHT,
|
||||
max: MAX_LINE_HEIGHT,
|
||||
min: MIN_LINE_HEIGHT,
|
||||
step: LINE_HEIGHT_STEP,
|
||||
};
|
||||
|
||||
export const lineHeightStore = createControlStore(initialValue);
|
||||
@@ -1,59 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Item from '$shared/shadcn/ui/item';
|
||||
import { Separator } from '$shared/shadcn/ui/separator/index';
|
||||
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
|
||||
import ComboControl from '$shared/ui/ComboControl/ComboControl.svelte';
|
||||
import { fontSizeStore } from '../model/stores/fontSizeStore';
|
||||
import { fontWeightStore } from '../model/stores/fontWeightStore';
|
||||
import { lineHeightStore } from '../model/stores/lineHeightStore';
|
||||
|
||||
const fontSize = $derived($fontSizeStore);
|
||||
const fontWeight = $derived($fontWeightStore);
|
||||
const lineHeight = $derived($lineHeightStore);
|
||||
</script>
|
||||
|
||||
<div class="w-full p-2">
|
||||
<Item.Root variant="outline" class="w-full p-2.5">
|
||||
<Item.Content class="flex flex-row items-center">
|
||||
<Sidebar.Trigger />
|
||||
<Separator orientation="vertical" class="h-full" />
|
||||
<ComboControl
|
||||
value={fontSize.value}
|
||||
minValue={fontSize.min}
|
||||
maxValue={fontSize.max}
|
||||
onChange={fontSizeStore.setValue}
|
||||
onIncrease={fontSizeStore.increase}
|
||||
onDecrease={fontSizeStore.decrease}
|
||||
increaseDisabled={fontSizeStore.isAtMax()}
|
||||
decreaseDisabled={fontSizeStore.isAtMin()}
|
||||
increaseLabel="Increase Font Size"
|
||||
decreaseLabel="Decrease Font Size"
|
||||
/>
|
||||
<ComboControl
|
||||
value={fontWeight.value}
|
||||
minValue={fontWeight.min}
|
||||
maxValue={fontWeight.max}
|
||||
onChange={fontWeightStore.setValue}
|
||||
onIncrease={fontWeightStore.increase}
|
||||
onDecrease={fontWeightStore.decrease}
|
||||
increaseDisabled={fontWeightStore.isAtMax()}
|
||||
decreaseDisabled={fontWeightStore.isAtMin()}
|
||||
increaseLabel="Increase Font Weight"
|
||||
decreaseLabel="Decrease Font Weight"
|
||||
/>
|
||||
<ComboControl
|
||||
value={lineHeight.value}
|
||||
minValue={lineHeight.min}
|
||||
maxValue={lineHeight.max}
|
||||
step={lineHeight.step}
|
||||
onChange={lineHeightStore.setValue}
|
||||
onIncrease={lineHeightStore.increase}
|
||||
onDecrease={lineHeightStore.decrease}
|
||||
increaseDisabled={lineHeightStore.isAtMax()}
|
||||
decreaseDisabled={lineHeightStore.isAtMin()}
|
||||
increaseLabel="Increase Line Height"
|
||||
decreaseLabel="Decrease Line Height"
|
||||
/>
|
||||
</Item.Content>
|
||||
</Item.Root>
|
||||
</div>
|
||||
134
src/features/SetupFont/ui/TypographyMenu.svelte
Normal file
134
src/features/SetupFont/ui/TypographyMenu.svelte
Normal file
@@ -0,0 +1,134 @@
|
||||
<!--
|
||||
Component: TypographyMenu
|
||||
Provides a menu for selecting and configuring typography settings
|
||||
- On mobile the menu is displayed as a drawer
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import {
|
||||
Content as ItemContent,
|
||||
Root as ItemRoot,
|
||||
} from '$shared/shadcn/ui/item';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
ComboControlV2,
|
||||
Drawer,
|
||||
IconButton,
|
||||
} from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { crossfade } from 'svelte/transition';
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
controlManager,
|
||||
} from '../model';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const { class: className, hidden = false }: Props = $props();
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
const [send, receive] = crossfade({
|
||||
duration: 300,
|
||||
easing: cubicOut,
|
||||
fallback(node, params) {
|
||||
// If it can't find a pair, it falls back to a simple fade/slide
|
||||
return {
|
||||
duration: 300,
|
||||
css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 10}px);`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets the common font size multiplier based on the current responsive state.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!responsive) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
controlManager.multiplier = MULTIPLIER_S;
|
||||
break;
|
||||
case responsive.isTablet:
|
||||
controlManager.multiplier = MULTIPLIER_M;
|
||||
break;
|
||||
case responsive.isDesktop:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
default:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'w-auto max-screen z-10 flex justify-center',
|
||||
hidden && 'hidden',
|
||||
className,
|
||||
)}
|
||||
in:receive={{ key: 'panel' }}
|
||||
out:send={{ key: 'panel' }}
|
||||
>
|
||||
{#if responsive.isMobile}
|
||||
<Drawer>
|
||||
{#snippet trigger({ onClick })}
|
||||
<IconButton onclick={onClick}>
|
||||
{#snippet icon({ className })}
|
||||
<SlidersIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<Label
|
||||
class="mt-6 mb-12 px-2"
|
||||
text="Typography Controls"
|
||||
align="center"
|
||||
/>
|
||||
<div class={cn(className, 'flex flex-col gap-8')}>
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ComboControlV2
|
||||
control={control.instance}
|
||||
orientation="horizontal"
|
||||
label={control.controlLabel}
|
||||
reduced
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Drawer>
|
||||
{:else}
|
||||
<ItemRoot
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto max-w-full sm:max-w-max p-2 sm:p-2.5 rounded-xl sm:rounded-2xl backdrop-blur-lg"
|
||||
>
|
||||
<ItemContent class="flex flex-row justify-center items-center max-w-full sm:max-w-max">
|
||||
<div class="sm:py-2 sm:px-10 flex flex-row items-center gap-2">
|
||||
<div class="flex flex-row gap-3">
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ComboControlV2
|
||||
control={control.instance}
|
||||
increaseLabel={control.increaseLabel}
|
||||
decreaseLabel={control.decreaseLabel}
|
||||
controlLabel={control.controlLabel}
|
||||
orientation="vertical"
|
||||
showScale={false}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</ItemRoot>
|
||||
{/if}
|
||||
</div>
|
||||
1
src/features/SetupFont/ui/index.ts
Normal file
1
src/features/SetupFont/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TypographyMenu } from './TypographyMenu.svelte';
|
||||
@@ -1,16 +1,152 @@
|
||||
<script>
|
||||
/**
|
||||
* Page Component
|
||||
*
|
||||
* Main page route component. This is the default route that users see when
|
||||
* accessing the application. Currently displays a welcome message.
|
||||
*
|
||||
* Note: This is a placeholder component. Replace with actual application content
|
||||
* as the font comparison and filtering features are implemented.
|
||||
*/
|
||||
<!--
|
||||
Component: Page
|
||||
Description: The main page component of the application.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Logo,
|
||||
Section,
|
||||
} from '$shared/ui';
|
||||
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||
import { FontSearch } from '$widgets/FontSearch';
|
||||
import { SampleList } from '$widgets/SampleList';
|
||||
import CodeIcon from '@lucide/svelte/icons/code';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
|
||||
import ScanSearchIcon from '@lucide/svelte/icons/search';
|
||||
import {
|
||||
type Snippet,
|
||||
getContext,
|
||||
} from 'svelte';
|
||||
import { cubicIn } from 'svelte/easing';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let searchContainer: HTMLElement;
|
||||
|
||||
let isExpanded = $state(true);
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
function handleTitleStatusChanged(
|
||||
index: number,
|
||||
isPast: boolean,
|
||||
title?: Snippet<[{ className?: string }]>,
|
||||
id?: string,
|
||||
) {
|
||||
if (isPast && title) {
|
||||
scrollBreadcrumbsStore.add({ index, title, id });
|
||||
} else {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
}
|
||||
|
||||
return () => {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Welcome to Svelte + Vite</h1>
|
||||
<p>
|
||||
Visit <a href="https://svelte.dev/docs">svelte.dev/docs</a> to read the documentation
|
||||
</p>
|
||||
<!-- Font List -->
|
||||
<div
|
||||
class="p-2 sm:p-3 md:p-4 h-full grid gap-3 sm:gap-4 grid-cols-[max-content_1fr]"
|
||||
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
||||
>
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-8"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<CodeIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet description({ className })}
|
||||
<span class={className}> Project_Codename </span>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, 'col-start-0 col-span-2')}>
|
||||
<Logo />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||
index={1}
|
||||
id="optical_comparator"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
stickyTitle={responsive.isDesktopLarge}
|
||||
stickyOffset="4rem"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<EyeIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h1 class={className}>
|
||||
Optical<br />Comparator
|
||||
</h1>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<ComparisonSlider />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||
index={2}
|
||||
id="query_module"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
stickyTitle={responsive.isDesktopLarge}
|
||||
stickyOffset="4rem"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<ScanSearchIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h2 class={className}>
|
||||
Query<br />Module
|
||||
</h2>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||
index={3}
|
||||
id="sample_set"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
stickyTitle={responsive.isDesktopLarge}
|
||||
stickyOffset="4rem"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<LineSquiggleIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h2 class={className}>
|
||||
Sample<br />Set
|
||||
</h2>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<SampleList />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
/* Tells the browser to skip rendering off-screen content */
|
||||
content-visibility: auto;
|
||||
/* Helps the browser reserve space without calculating everything */
|
||||
contain-intrinsic-size: 1px 1000px;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,6 +56,5 @@ export const api = {
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
delete: <T>(url: string, options?: RequestInit) =>
|
||||
request<T>(url, { ...options, method: 'DELETE' }),
|
||||
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
|
||||
26
src/shared/api/queryClient.ts
Normal file
26
src/shared/api/queryClient.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { QueryClient } from '@tanstack/query-core';
|
||||
|
||||
/**
|
||||
* Query client instance
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
/**
|
||||
* Default staleTime: 5 minutes
|
||||
*/
|
||||
staleTime: 5 * 60 * 1000,
|
||||
/**
|
||||
* Default gcTime: 10 minutes
|
||||
*/
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
retry: 3,
|
||||
/**
|
||||
* Exponential backoff
|
||||
*/
|
||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
},
|
||||
},
|
||||
});
|
||||
4
src/shared/assets/GD.svg
Normal file
4
src/shared/assets/GD.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
|
||||
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user