Compare commits
555 Commits
d4e2885ae0
...
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 | ||
| 9cf91e0992 | |||
|
|
3d35f1901d | ||
|
|
d8e5f5a0b5 | ||
| 90497fac16 | |||
|
|
b0afa0145d | ||
|
|
e01a746460 | ||
|
|
53baacf05a | ||
|
|
ac41f324b1 | ||
|
|
00aaecaa22 | ||
|
|
bb4db09f87 | ||
|
|
4f017c88d5 | ||
|
|
23f3a5b803 | ||
|
|
d439e97729 | ||
|
|
1bb699ea2d | ||
|
|
bf36f8e642 | ||
|
|
0742eb8c3d | ||
|
|
109c69c1b9 | ||
|
|
ff665e1d26 | ||
|
|
949c7c1b48 | ||
|
|
90899c0b3b | ||
|
|
4ba02b5933 | ||
|
|
3a2cc1c76b | ||
|
|
be267d43d8 | ||
|
|
14d7f0976c | ||
|
|
98febdc24c | ||
|
|
f8e62340e4 | ||
|
|
d78eb3037c | ||
|
|
9f8d7ad844 | ||
|
|
904b48844d | ||
|
|
82d36ad156 | ||
|
|
c65243ed02 | ||
|
|
11014f36af | ||
|
|
a76b83ee0e | ||
|
|
792b142c07 | ||
|
|
e35c1cb6dd | ||
|
|
a903554695 | ||
|
|
6041ffd954 | ||
|
|
7bc0a690cb | ||
|
|
e885560c45 | ||
|
|
1ecbc9b9d7 | ||
|
|
4a283213d4 | ||
|
|
fcc266f3a5 | ||
|
|
879e8cd710 | ||
|
|
d443f9ab85 | ||
|
|
873a020959 | ||
|
|
9924fcba3a | ||
|
|
1321347ac3 | ||
|
|
8713207afb | ||
|
|
7d6ce78584 | ||
|
|
b4d24cac4e | ||
|
|
2bcded583d | ||
|
|
fdb8c38b7f | ||
|
|
3971d364bd | ||
|
|
aa951260a0 | ||
| 8b6a1c468e | |||
|
|
d5527929f9 | ||
|
|
53e3d6985b | ||
|
|
7c94622b95 | ||
|
|
f3315be32d | ||
| def01a2cad | |||
|
|
31e64f4eac | ||
| 1a49a7bc34 | |||
|
|
b94b0e8b72 | ||
| 4c965fae90 |
@@ -1,200 +0,0 @@
|
||||
# Gitea Actions Quick Start Guide
|
||||
|
||||
This is a quick reference guide for getting started with the GlyphDiff CI/CD pipeline.
|
||||
|
||||
## 🚀 Quick Start (5 Minutes)
|
||||
|
||||
### 1. Verify Actions Enabled
|
||||
|
||||
1. Go to your Gitea instance → Site Admin → Actions
|
||||
2. Ensure Actions is enabled
|
||||
|
||||
### 2. Enable Actions for Repository
|
||||
|
||||
1. Go to repository → Settings → Actions
|
||||
2. Enable Actions (if not already enabled)
|
||||
|
||||
### 3. Commit and Push Workflows
|
||||
|
||||
```bash
|
||||
git add .gitea/
|
||||
git commit -m "Add Gitea Actions CI/CD workflows"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 4. Verify Workflows Run
|
||||
|
||||
- Go to repository → Actions tab
|
||||
- You should see workflows running on push
|
||||
|
||||
## 📋 Workflow Summary
|
||||
|
||||
| Workflow | What It Does | When It Runs |
|
||||
| ---------- | --------------------------------------- | --------------------------------- |
|
||||
| **lint** | Runs oxlint & dprint check | Push/PR to main/develop/feature/* |
|
||||
| **test** | Type check & Playwright E2E tests | Push/PR to main/develop/feature/* |
|
||||
| **build** | Builds SvelteKit production bundle | Push to main/develop, PRs |
|
||||
| **deploy** | Deploys to production (configure first) | Push to main, manual trigger |
|
||||
|
||||
## 🔧 Self-Hosted Runner Setup (Linux)
|
||||
|
||||
### Install act_runner
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Register Runner
|
||||
|
||||
1. Go to repo → Settings → Actions → Runners
|
||||
2. Click "New Runner"
|
||||
3. Copy registration token
|
||||
4. Run:
|
||||
|
||||
```bash
|
||||
act_runner register \
|
||||
--instance https://your-gitea-instance.com \
|
||||
--token YOUR_TOKEN \
|
||||
--name "linux-runner-1" \
|
||||
--labels ubuntu-latest,linux,docker \
|
||||
--no-interactive
|
||||
```
|
||||
|
||||
### Run as Service
|
||||
|
||||
```bash
|
||||
# Create systemd service
|
||||
sudo tee /etc/systemd/system/gitea-runner.service > /dev/null <<EOF
|
||||
[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
|
||||
EOF
|
||||
|
||||
# Enable and start
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable gitea-runner
|
||||
sudo systemctl start gitea-runner
|
||||
|
||||
# Check status
|
||||
sudo systemctl status gitea-runner
|
||||
```
|
||||
|
||||
## 🔑 Secrets Setup (For Deployment)
|
||||
|
||||
Go to repo → Settings → Secrets → Actions
|
||||
|
||||
### For Docker Deployment
|
||||
|
||||
```
|
||||
REGISTRY_URL=registry.example.com
|
||||
REGISTRY_USERNAME=username
|
||||
REGISTRY_PASSWORD=password
|
||||
```
|
||||
|
||||
### For SSH Deployment
|
||||
|
||||
```
|
||||
DEPLOY_HOST=server.example.com
|
||||
DEPLOY_USER=deploy
|
||||
DEPLOY_SSH_KEY=-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
...
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
```
|
||||
|
||||
### For Vercel Deployment
|
||||
|
||||
```
|
||||
VERCEL_TOKEN=your-vercel-token
|
||||
VERCEL_ORG_ID=your-org-id
|
||||
VERCEL_PROJECT_ID=your-project-id
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Workflows not running?
|
||||
|
||||
- Check Actions is enabled in Gitea
|
||||
- Verify `.gitea/workflows/` directory exists
|
||||
- Check workflow YAML syntax
|
||||
|
||||
### Runner offline?
|
||||
|
||||
```bash
|
||||
sudo systemctl status gitea-runner
|
||||
sudo journalctl -u gitea-runner -f
|
||||
```
|
||||
|
||||
### Tests failing?
|
||||
|
||||
```bash
|
||||
# Run tests locally
|
||||
yarn lint
|
||||
yarn test
|
||||
yarn build
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
For detailed information, see [README.md](./README.md)
|
||||
|
||||
## 🎯 Common Tasks
|
||||
|
||||
### Trigger manual workflow run
|
||||
|
||||
1. Go to Actions tab
|
||||
2. Select workflow (e.g., Build)
|
||||
3. Click "Run workflow"
|
||||
4. Select branch and click "Run workflow"
|
||||
|
||||
### View workflow logs
|
||||
|
||||
1. Go to Actions tab
|
||||
2. Click on workflow run
|
||||
3. Click on job to view logs
|
||||
|
||||
### Download artifacts
|
||||
|
||||
1. Go to workflow run
|
||||
2. Scroll to "Artifacts" section
|
||||
3. Download desired artifact (e.g., playwright-report, build-artifacts)
|
||||
|
||||
### Re-run failed workflow
|
||||
|
||||
1. Go to failed workflow run
|
||||
2. Click "Re-run failed jobs"
|
||||
|
||||
## 🔄 Workflow States
|
||||
|
||||
| Status | Meaning |
|
||||
| -------------- | ---------------------------- |
|
||||
| ⏳ Queued | Waiting for available runner |
|
||||
| 🔄 In Progress | Currently running |
|
||||
| ✅ Success | All checks passed |
|
||||
| ❌ Failed | One or more checks failed |
|
||||
| ⚠️ Cancelled | Workflow was cancelled |
|
||||
|
||||
## 📊 Workflow Duration Estimates
|
||||
|
||||
| Workflow | Typical Duration |
|
||||
| ----------------- | --------------------- |
|
||||
| lint | 30-60 seconds |
|
||||
| test (type-check) | 45-90 seconds |
|
||||
| test (e2e) | 2-5 minutes |
|
||||
| build | 1-3 minutes |
|
||||
| deploy | Varies (2-10 minutes) |
|
||||
|
||||
---
|
||||
|
||||
**Need Help?** See the full [README.md](./README.md) for detailed documentation.
|
||||
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,59 +0,0 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Project
|
||||
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: Run SvelteKit sync
|
||||
run: yarn svelte-kit sync
|
||||
|
||||
- name: Build project
|
||||
run: yarn build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
.svelte-kit/output
|
||||
.svelte-kit/build
|
||||
retention-days: 7
|
||||
|
||||
- name: Verify build (Preview)
|
||||
run: |
|
||||
yarn preview &
|
||||
PREVIEW_PID=$!
|
||||
sleep 5
|
||||
curl -f http://localhost:4173 || exit 1
|
||||
kill $PREVIEW_PID
|
||||
@@ -1,127 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Deployment environment'
|
||||
required: true
|
||||
default: 'production'
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to ${{ github.event.inputs.environment || 'production' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: ${{ github.event.inputs.environment || 'production' }}
|
||||
|
||||
# Only deploy after successful linting, testing, and building
|
||||
needs: [lint, test, build]
|
||||
|
||||
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: Build project
|
||||
run: yarn build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
# Example deployment step - replace with your actual deployment strategy
|
||||
# Options:
|
||||
# - Docker container registry
|
||||
# - Cloud provider (AWS, GCP, Azure)
|
||||
# - Traditional hosting (Vercel, Netlify, Cloudflare Pages)
|
||||
# - SSH deployment to VPS
|
||||
|
||||
# Example: Docker image build and push
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v3
|
||||
|
||||
# - name: Log in to Container Registry
|
||||
# uses: docker/login-action@v3
|
||||
# with:
|
||||
# registry: ${{ secrets.REGISTRY_URL }}
|
||||
# username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
# password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
# - name: Build and push Docker image
|
||||
# uses: docker/build-push-action@v5
|
||||
# with:
|
||||
# context: .
|
||||
# push: true
|
||||
# tags: ${{ secrets.REGISTRY_URL }}/glyphdiff:latest
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
|
||||
# Example: SSH deployment to server
|
||||
# - name: Deploy to server via SSH
|
||||
# uses: appleboy/ssh-action@v1.0.3
|
||||
# with:
|
||||
# host: ${{ secrets.DEPLOY_HOST }}
|
||||
# username: ${{ secrets.DEPLOY_USER }}
|
||||
# key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# script: |
|
||||
# cd /path/to/app
|
||||
# git pull origin main
|
||||
# yarn install --frozen-lockfile
|
||||
# yarn build
|
||||
# pm2 restart glyphdiff
|
||||
|
||||
# Example: Deploy to Vercel
|
||||
# - name: Deploy to Vercel
|
||||
# uses: amondnet/vercel-action@v25
|
||||
# with:
|
||||
# vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
# vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
|
||||
# vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
# vercel-args: '--prod'
|
||||
|
||||
- name: Deployment placeholder
|
||||
run: |
|
||||
echo "Deployment step not configured yet."
|
||||
echo "Uncomment and modify one of the deployment examples above."
|
||||
echo "Configure the necessary secrets in your Gitea instance:"
|
||||
echo " - REGISTRY_URL, REGISTRY_USERNAME, REGISTRY_PASSWORD"
|
||||
echo " - DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY"
|
||||
echo " - VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID"
|
||||
|
||||
- name: Post-deployment health check
|
||||
run: |
|
||||
echo "Add health check here after deployment"
|
||||
# curl -f https://your-app.com || exit 1
|
||||
|
||||
lint:
|
||||
name: Lint Check
|
||||
uses: ./.gitea/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
test:
|
||||
name: Test Suite
|
||||
uses: ./.gitea/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Build Verification
|
||||
uses: ./.gitea/workflows/build.yml
|
||||
secrets: inherit
|
||||
@@ -1,41 +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: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Run oxlint
|
||||
run: yarn oxlint .
|
||||
|
||||
- name: Check code formatting
|
||||
run: yarn dprint check
|
||||
@@ -1,83 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- feature/*
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
name: Type Check
|
||||
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: Run TypeScript type check
|
||||
run: yarn tsc --noEmit
|
||||
|
||||
- name: Run Svelte check
|
||||
run: yarn svelte-check --threshold warning
|
||||
|
||||
# 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
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -8,6 +8,7 @@ node_modules
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
/dist
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -21,6 +22,8 @@ Thumbs.db
|
||||
|
||||
# Yarn
|
||||
.yarn
|
||||
.yarn/**
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Zed
|
||||
@@ -31,3 +34,12 @@ vite.config.js.timestamp-*
|
||||
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.
491
CICD.md
491
CICD.md
@@ -1,491 +0,0 @@
|
||||
# DevOps CI/CD Pipeline Summary
|
||||
|
||||
This document provides an overview of the continuous integration and deployment (CI/CD) pipeline for the GlyphDiff SvelteKit project using Gitea Actions.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Pipeline Overview](#pipeline-overview)
|
||||
- [Quick Reference](#quick-reference)
|
||||
- [Architecture](#architecture)
|
||||
- [Workflow Details](#workflow-details)
|
||||
- [Deployment Strategy](#deployment-strategy)
|
||||
- [Monitoring & Observability](#monitoring--observability)
|
||||
- [Self-Hosted Runner Setup](#self-hosted-runner-setup)
|
||||
- [Getting Started](#getting-started)
|
||||
|
||||
## Pipeline Overview
|
||||
|
||||
### DevOps Solution: Gitea Actions CI/CD Pipeline
|
||||
|
||||
#### Architecture Overview
|
||||
|
||||
The GlyphDiff project uses Gitea Actions (GitHub Actions compatible) for CI/CD on a self-hosted Gitea instance. The pipeline is designed to be:
|
||||
|
||||
- **Automated**: Every push and PR triggers validation workflows
|
||||
- **Fast**: Caching strategies optimize execution time
|
||||
- **Reliable**: Multiple quality gates ensure code quality
|
||||
- **Secure**: Secrets management and least-privilege access
|
||||
- **Observable**: Artifacts and logs for debugging
|
||||
|
||||
#### CI/CD Pipeline Stages
|
||||
|
||||
1. **Source**: Trigger on commits, PRs, or manual dispatch
|
||||
2. **Lint**: Code quality checks (oxlint, dprint)
|
||||
3. **Test**: Type checking and E2E tests (Playwright)
|
||||
4. **Build**: Production build verification
|
||||
5. **Deploy**: Automated deployment (optional/template)
|
||||
|
||||
#### Quality Gates
|
||||
|
||||
- ✅ All linting checks must pass
|
||||
- ✅ Type checking must have no errors
|
||||
- ✅ All E2E tests must pass
|
||||
- ✅ Production build must complete successfully
|
||||
- ✅ Health check on preview server must succeed
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Workflow | Purpose | Trigger | Duration |
|
||||
| ------------ | ---------------------- | ------------ | -------- |
|
||||
| `lint.yml` | Code quality checks | Push/PR | 30-60s |
|
||||
| `test.yml` | Type check & E2E tests | Push/PR | 3-6min |
|
||||
| `build.yml` | Production build | Push/PR | 1-3min |
|
||||
| `deploy.yml` | Deployment to prod | Push to main | 2-10min |
|
||||
|
||||
### Branch Strategy
|
||||
|
||||
| Branch | Workflows | Deployment |
|
||||
| ----------- | ----------------- | ------------- |
|
||||
| `main` | All workflows | Yes (on push) |
|
||||
| `develop` | Lint, Test, Build | No |
|
||||
| `feature/*` | Lint, Test | No |
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
| Environment | Branch | Purpose |
|
||||
| ----------- | --------- | ------------------- |
|
||||
| Production | `main` | Live deployment |
|
||||
| Development | `develop` | Integration testing |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Pipeline Flowchart
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Push/PR │
|
||||
└──────┬──────┘
|
||||
│
|
||||
├──────────────┬──────────────┬──────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ Lint │ │ Test │ │ Build │ │ Deploy │
|
||||
│ (30s) │ │ (3-6min) │ │ (1-3min) │ │ (2-10min) │
|
||||
└───────────┘ └───────────┘ └───────────┘ └───────────┘
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
└──────────────┴──────────────┴──────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ All Pass ✅ │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
|
||||
| Component | Technology | Version |
|
||||
| ------------------- | ------------- | ------- |
|
||||
| **CI/CD Platform** | Gitea Actions | Latest |
|
||||
| **Runner** | act_runner | 0.2.11+ |
|
||||
| **Package Manager** | Yarn | Latest |
|
||||
| **Runtime** | Node.js | 20.x |
|
||||
| **Framework** | SvelteKit | 2.49.1+ |
|
||||
| **Build Tool** | Vite | 7.2.6+ |
|
||||
| **Linter** | oxlint | 1.35.0+ |
|
||||
| **Formatter** | dprint | 0.50.2+ |
|
||||
| **E2E Testing** | Playwright | 1.57.0+ |
|
||||
| **TypeScript** | TypeScript | 5.9.3+ |
|
||||
| **Git Hooks** | lefthook | 2.0.13+ |
|
||||
|
||||
## Workflow Details
|
||||
|
||||
### 1. Lint Workflow (`lint.yml`)
|
||||
|
||||
**Purpose**: Ensure code quality and formatting standards
|
||||
|
||||
**Checks**:
|
||||
|
||||
- `oxlint` - Fast JavaScript/TypeScript linting
|
||||
- `dprint check` - Code formatting verification
|
||||
|
||||
**Triggers**:
|
||||
|
||||
- Push to `main`, `develop`, `feature/*`
|
||||
- Pull requests to `main`, `develop`
|
||||
- Manual dispatch
|
||||
|
||||
**Features**:
|
||||
|
||||
- Yarn caching for fast dependency installation
|
||||
- Concurrency control (cancels in-progress runs)
|
||||
- ~30-60 second execution time
|
||||
|
||||
**Quality Gate**:
|
||||
|
||||
```yaml
|
||||
Exit on: Failure
|
||||
Prevents: Build and deployment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Test Workflow (`test.yml`)
|
||||
|
||||
**Purpose**: Verify type safety and application behavior
|
||||
|
||||
**Jobs**:
|
||||
|
||||
#### Type Check Job
|
||||
|
||||
- `tsc --noEmit` - TypeScript type checking
|
||||
- `svelte-check --threshold warning` - Svelte component type checking
|
||||
|
||||
#### E2E Tests Job
|
||||
|
||||
- Install Playwright browsers with system dependencies
|
||||
- Run E2E tests using Playwright
|
||||
- Upload test report artifacts (7-day retention)
|
||||
- Upload screenshots on test failure
|
||||
|
||||
**Triggers**: Same as lint workflow
|
||||
|
||||
**Artifacts**:
|
||||
|
||||
- `playwright-report` - HTML test report
|
||||
- `playwright-screenshots` - Screenshots from failed tests
|
||||
|
||||
**Quality Gates**:
|
||||
|
||||
```yaml
|
||||
Exit on: Failure
|
||||
Prevents: Build and deployment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Build Workflow (`build.yml`)
|
||||
|
||||
**Purpose**: Verify production build success
|
||||
|
||||
**Steps**:
|
||||
|
||||
1. Setup Node.js v20 with Yarn caching
|
||||
2. Install dependencies with `--frozen-lockfile`
|
||||
3. Run `svelte-kit sync` to prepare SvelteKit
|
||||
4. Build with `NODE_ENV=production`
|
||||
5. Upload build artifacts (`.svelte-kit/output`, `.svelte-kit/build`)
|
||||
6. Run preview server and health check
|
||||
|
||||
**Triggers**:
|
||||
|
||||
- Push to `main` or `develop`
|
||||
- Pull requests to `main` or `develop`
|
||||
- Manual dispatch
|
||||
|
||||
**Artifacts**:
|
||||
|
||||
- `build-artifacts` - Compiled SvelteKit output (7-day retention)
|
||||
|
||||
**Quality Gate**:
|
||||
|
||||
```yaml
|
||||
Exit on: Failure
|
||||
Prevents: Deployment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Deploy Workflow (`deploy.yml`)
|
||||
|
||||
**Purpose**: Automated deployment to production
|
||||
|
||||
**Current State**: Template configuration (requires customization)
|
||||
|
||||
**Pre-deployment Checks**:
|
||||
|
||||
- Must pass linting workflow
|
||||
- Must pass testing workflow
|
||||
- Must pass build workflow
|
||||
|
||||
**Triggers**:
|
||||
|
||||
- Push to `main` branch
|
||||
- Manual dispatch with environment selection (staging/production)
|
||||
|
||||
**Deployment Options** (uncomment one):
|
||||
|
||||
1. **Docker Container Registry**
|
||||
- Build and push Docker image
|
||||
- Supports GitHub Actions cache
|
||||
- Requires: `REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`
|
||||
|
||||
2. **SSH Deployment**
|
||||
- Deploy to server via SSH
|
||||
- Supports custom deployment scripts
|
||||
- Requires: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`
|
||||
|
||||
3. **Vercel**
|
||||
- Deploy to Vercel platform
|
||||
- Zero-configuration deployment
|
||||
- Requires: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`
|
||||
|
||||
**Features**:
|
||||
|
||||
- Environment-based deployment (staging/production)
|
||||
- Post-deployment health check (template)
|
||||
- Prevents concurrent deployments (concurrency group)
|
||||
|
||||
## Deployment Strategy
|
||||
|
||||
**Strategy**: Manual Trigger with Environment Gates (Template)
|
||||
|
||||
### Description
|
||||
|
||||
The deployment workflow is designed as a template that can be customized based on your deployment needs. It requires manual approval and successful completion of all CI checks before deployment.
|
||||
|
||||
### Pros
|
||||
|
||||
- ✅ Flexibility to choose deployment method
|
||||
- ✅ Multiple deployment options (Docker, SSH, Vercel)
|
||||
- ✅ Environment separation (staging/production)
|
||||
- ✅ Pre-deployment gates ensure quality
|
||||
- ✅ Prevents concurrent deployments
|
||||
|
||||
### Cons
|
||||
|
||||
- ⚠️ Requires manual configuration
|
||||
- ⚠️ No automated rollback mechanism (can be added)
|
||||
- ⚠️ No blue-green deployment (can be implemented)
|
||||
|
||||
### Recommended Future Enhancements
|
||||
|
||||
1. **Automated Rollback**: Detect failures and auto-rollback
|
||||
2. **Blue-Green Deployment**: Zero downtime deployment
|
||||
3. **Canary Releases**: Gradual traffic rollout
|
||||
4. **Slack Notifications**: Notify team on deployment status
|
||||
5. **Deployment Dashboard**: Visual deployment tracking
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Metrics
|
||||
|
||||
**Workflow Execution**:
|
||||
|
||||
- Success/failure rates
|
||||
- Execution duration
|
||||
- Resource usage (CPU, memory)
|
||||
- Queue times
|
||||
|
||||
**Application Metrics** (to be added):
|
||||
|
||||
- Response times
|
||||
- Error rates
|
||||
- Throughput
|
||||
- Custom business metrics
|
||||
|
||||
### Logs
|
||||
|
||||
**Workflow Logs**:
|
||||
|
||||
- Available in Gitea Actions UI
|
||||
- Retention: 90 days (configurable)
|
||||
- Searchable and filterable
|
||||
|
||||
**Application Logs** (to be added):
|
||||
|
||||
- Centralized log aggregation (ELK, Loki)
|
||||
- Structured logging (JSON)
|
||||
- Log levels (debug, info, warn, error)
|
||||
|
||||
### Alerts
|
||||
|
||||
**Current**:
|
||||
|
||||
- Email notifications on workflow failure (Gitea feature)
|
||||
|
||||
**Recommended**:
|
||||
|
||||
- Slack/Mattermost integration
|
||||
- PagerDuty for critical failures
|
||||
- Health check alerts (uptime monitoring)
|
||||
|
||||
## Self-Hosted Runner Setup
|
||||
|
||||
### Runner Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Gitea Instance │
|
||||
│ (Actions Enabled, Self-Hosted) │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
│ HTTPS/Webhook
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ act_runner (Linux/Ubuntu) │
|
||||
│ - Systemd service │
|
||||
│ - Labels: ubuntu-latest │
|
||||
│ - Docker enabled │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
│ Executes workflows
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Docker Containers │
|
||||
│ - Node.js 20 container │
|
||||
│ - Playwright browsers │
|
||||
│ - Build environment │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Installation Steps
|
||||
|
||||
#### 1. Install act_runner
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
#### 2. Register Runner
|
||||
|
||||
```bash
|
||||
act_runner register \
|
||||
--instance https://your-gitea-instance.com \
|
||||
--token YOUR_TOKEN \
|
||||
--name "linux-runner-1" \
|
||||
--labels ubuntu-latest,linux,docker \
|
||||
--no-interactive
|
||||
```
|
||||
|
||||
#### 3. Configure as Service
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/gitea-runner.service > /dev/null <<EOF
|
||||
[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
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 4. Start Service
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable gitea-runner
|
||||
sudo systemctl start gitea-runner
|
||||
sudo systemctl status gitea-runner
|
||||
```
|
||||
|
||||
### Runner Requirements
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
| ----------- | ------------- | ------------- |
|
||||
| **CPU** | 2 cores | 4 cores |
|
||||
| **RAM** | 2 GB | 4 GB |
|
||||
| **Disk** | 20 GB | 50 GB |
|
||||
| **OS** | Ubuntu 20.04+ | Ubuntu 22.04+ |
|
||||
| **Docker** | 20.10+ | 24.0+ |
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. ✅ Gitea instance with Actions enabled
|
||||
2. ✅ Self-hosted runner configured (optional for testing)
|
||||
3. ✅ Repository with workflows in `.gitea/workflows/`
|
||||
4. ✅ Secrets configured (for deployment)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Verify Gitea Actions is enabled**
|
||||
- Go to Site Admin → Actions
|
||||
- Ensure Actions is enabled
|
||||
|
||||
2. **Enable Actions for repository**
|
||||
- Go to repository → Settings → Actions
|
||||
- Enable Actions
|
||||
|
||||
3. **Commit and push workflows**
|
||||
```bash
|
||||
git add .gitea/
|
||||
git commit -m "Add Gitea Actions CI/CD workflows"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
4. **Verify workflows run**
|
||||
- Go to repository → Actions tab
|
||||
- View workflow execution logs
|
||||
|
||||
5. **Configure deployment** (optional)
|
||||
- Go to Settings → Secrets → Actions
|
||||
- Add deployment secrets
|
||||
- Uncomment deployment method in `deploy.yml`
|
||||
|
||||
### Validation Checklist
|
||||
|
||||
- [ ] Workflows trigger on push/PR
|
||||
- [ ] Lint workflow passes
|
||||
- [ ] Test workflow passes
|
||||
- [ ] Build workflow passes
|
||||
- [ ] Artifacts are uploaded
|
||||
- [ ] Runner is online (if self-hosted)
|
||||
- [ ] Caching is working (check logs)
|
||||
- [ ] Secrets are configured (for deployment)
|
||||
|
||||
## Documentation Links
|
||||
|
||||
- **Full Documentation**: [`.gitea/README.md`](.gitea/README.md)
|
||||
- **Quick Start**: [`.gitea/QUICKSTART.md`](.gitea/QUICKSTART.md)
|
||||
- **Workflow Files**: [`.gitea/workflows/`](.gitea/workflows/)
|
||||
- **Gitea Actions Docs**: https://docs.gitea.com/usage/actions/overview
|
||||
- **act_runner Docs**: https://docs.gitea.com/usage/actions/act-runner
|
||||
|
||||
## Status Badges
|
||||
|
||||
Add to your `README.md`:
|
||||
|
||||
```markdown
|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Customize Deployment**: Choose and configure deployment method
|
||||
2. **Add Notifications**: Set up Slack/Mattermost alerts
|
||||
3. **Optimize Caching**: Add Playwright browser cache
|
||||
4. **Add Monitoring**: Integrate application monitoring
|
||||
5. **Set Up Environments**: Configure staging/production environments
|
||||
|
||||
---
|
||||
|
||||
**Maintained By**: DevOps Team
|
||||
**Last Updated**: December 30, 2025
|
||||
**Version**: 1.0.0
|
||||
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
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
"components": "$shared/shadcn/ui",
|
||||
"utils": "$shared/shadcn/utils/shadcn-utils",
|
||||
"ui": "$shared/shadcn/ui",
|
||||
"hooks": "$shared/shadcn/hooks",
|
||||
"lib": "$shared"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
|
||||
23
dprint.json
23
dprint.json
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://dprint.dev/schemas/v0.json",
|
||||
"incremental": true,
|
||||
"includes": ["**/*.{ts,tsx,js,jsx,svelte,json,md}"],
|
||||
"excludes": [
|
||||
@@ -15,14 +16,22 @@
|
||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
||||
],
|
||||
"typescript": {
|
||||
"lineWidth": 100,
|
||||
"lineWidth": 120,
|
||||
"indentWidth": 4,
|
||||
"useTabs": false,
|
||||
"semiColons": "prefer",
|
||||
"quoteStyle": "preferSingle",
|
||||
"trailingCommas": "onlyMultiLine",
|
||||
"arrowFunction.useParentheses": "preferNone",
|
||||
"importDeclaration.sortNamedImports": "caseInsensitive"
|
||||
|
||||
"module.sortImportDeclarations": "caseSensitive",
|
||||
"module.sortExportDeclarations": "caseSensitive",
|
||||
"importDeclaration.sortNamedImports": "caseSensitive",
|
||||
|
||||
"importDeclaration.forceMultiLine": "whenMultiple",
|
||||
"importDeclaration.forceSingleLine": false,
|
||||
"exportDeclaration.forceMultiLine": "whenMultiple",
|
||||
"exportDeclaration.forceSingleLine": false
|
||||
},
|
||||
"json": {
|
||||
"indentWidth": 2,
|
||||
@@ -32,11 +41,15 @@
|
||||
"lineWidth": 100
|
||||
},
|
||||
"markup": {
|
||||
"printWidth": 100,
|
||||
"printWidth": 120,
|
||||
"indentWidth": 4,
|
||||
"useTabs": false,
|
||||
"quotes": "double",
|
||||
"scriptIndent": true,
|
||||
"styleIndent": true
|
||||
"scriptIndent": false,
|
||||
"styleIndent": false,
|
||||
|
||||
"vBindStyle": "short",
|
||||
"vOnStyle": "short",
|
||||
"formatComments": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +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();
|
||||
});
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>glyphdiff</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,10 +17,12 @@ pre-push:
|
||||
run: yarn tsc --noEmit
|
||||
|
||||
svelte-check:
|
||||
run: yarn svelte-check --threshold warning
|
||||
run: yarn check:shadcn-excluded --threshold warning
|
||||
|
||||
format-check:
|
||||
run: yarn dprint check
|
||||
glob: "*.{ts,js,svelte,json,md}"
|
||||
run: yarn dprint check {push_files}
|
||||
|
||||
lint-full:
|
||||
run: yarn oxlint .
|
||||
glob: "*.{ts,js,svelte}"
|
||||
run: yarn oxlint {push_files}
|
||||
|
||||
51
package.json
51
package.json
@@ -2,38 +2,71 @@
|
||||
"name": "glyphdiff",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"packageManager": "yarn@4.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
|
||||
"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": {
|
||||
"@lucide/svelte": "^0.562.0",
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@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",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"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: 'npm run build && npm run preview', port: 4173 },
|
||||
webServer: {
|
||||
command: 'yarn build && yarn preview',
|
||||
port: 4173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
testDir: 'e2e',
|
||||
});
|
||||
|
||||
121
src/app.css
121
src/app.css
@@ -1,121 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--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);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
13
src/app.d.ts
vendored
13
src/app.d.ts
vendored
@@ -1,13 +0,0 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
src/app.html
11
src/app.html
@@ -1,11 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
22
src/app/App.svelte
Normal file
22
src/app/App.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* App Component
|
||||
*
|
||||
* Application entry point component. Wraps the main page route within the shared
|
||||
* 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>
|
||||
|
||||
<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';
|
||||
307
src/app/styles/app.css
Normal file
307
src/app/styles/app.css
Normal file
@@ -0,0 +1,307 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--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 {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--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 {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--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 {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global utility - useful across your app */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance optimization for collapsible elements */
|
||||
[data-state="open"] {
|
||||
will-change: height;
|
||||
}
|
||||
|
||||
/* Smooth focus transitions - good globally */
|
||||
.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;
|
||||
}
|
||||
50
src/app/types/ambient.d.ts
vendored
Normal file
50
src/app/types/ambient.d.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
declare module '*.svelte' {
|
||||
import type {
|
||||
ComponentProps as SvelteComponentProps,
|
||||
ComponentType,
|
||||
Snippet,
|
||||
} from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
interface Component {
|
||||
new(options: {
|
||||
target: HTMLElement;
|
||||
props?: Record<string, unknown>;
|
||||
intro?: boolean;
|
||||
}): {
|
||||
$on: (event: string, handler: (...args: unknown[]) => unknown) => void;
|
||||
$destroy: () => void;
|
||||
$set: (props: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export default Component;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
107
src/app/ui/Layout.svelte
Normal file
107
src/app/ui/Layout.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Layout Component
|
||||
*
|
||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
||||
* toolbar provider initialization, and renders child routes with consistent structure.
|
||||
*
|
||||
* Layout structure:
|
||||
* - Header area (currently empty, reserved for future use)
|
||||
*
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*/
|
||||
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';
|
||||
|
||||
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={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>
|
||||
|
||||
<ResponsiveProvider>
|
||||
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||
<header>
|
||||
<BreadcrumbHeader />
|
||||
</header>
|
||||
|
||||
<!-- <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>
|
||||
<!-- </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);
|
||||
}
|
||||
136
src/entities/Font/index.ts
Normal file
136
src/entities/Font/index.ts
Normal file
@@ -0,0 +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,
|
||||
// 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 './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';
|
||||
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';
|
||||
468
src/entities/Font/model/types/fontshare.ts
Normal file
468
src/entities/Font/model/types/fontshare.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* FONTHARE API TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' 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 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
|
||||
*/
|
||||
export interface FontshareFont {
|
||||
/**
|
||||
* Unique identifier for the font
|
||||
* UUID v4 format (e.g., "20e9fcdc-1e41-4559-a43d-1ede0adc8896")
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Display name of the font family
|
||||
* Examples: "Satoshi", "General Sans", "Clash Display"
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Native/localized name of the font (if available)
|
||||
* Often null for Latin-script fonts
|
||||
*/
|
||||
native_name: string | null;
|
||||
|
||||
/**
|
||||
* URL-friendly identifier for the font
|
||||
* Used in URLs: e.g., "satoshi", "general-sans", "clash-display"
|
||||
*/
|
||||
slug: string;
|
||||
|
||||
/**
|
||||
* Font category classification
|
||||
* Examples: "Sans", "Serif", "Display", "Script"
|
||||
*/
|
||||
category: string;
|
||||
|
||||
/**
|
||||
* Script/writing system supported by the font
|
||||
* Examples: "latin", "arabic", "devanagari"
|
||||
*/
|
||||
script: string;
|
||||
|
||||
/**
|
||||
* Font publisher/foundry information
|
||||
*/
|
||||
publisher: FontsharePublisher;
|
||||
|
||||
/**
|
||||
* Array of designers who created this font
|
||||
* Multiple designers may have collaborated on a single font
|
||||
*/
|
||||
designers: FontshareDesigner[];
|
||||
|
||||
/**
|
||||
* Related font families (if any)
|
||||
* Often null, as fonts are typically independent
|
||||
*/
|
||||
related_families: string | null;
|
||||
|
||||
/**
|
||||
* Whether to display publisher as the designer instead of individual designers
|
||||
*/
|
||||
display_publisher_as_designer: boolean;
|
||||
|
||||
/**
|
||||
* Whether trial downloads are enabled for this font
|
||||
*/
|
||||
trials_enabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show Latin-specific metrics
|
||||
*/
|
||||
show_latin_metrics: boolean;
|
||||
|
||||
/**
|
||||
* Type of license for this font
|
||||
* Examples: "itf_ffl" (ITF Free Font License)
|
||||
*/
|
||||
license_type: string;
|
||||
|
||||
/**
|
||||
* Comma-separated list of languages supported by this font
|
||||
* Example: "Afar, Afrikaans, Albanian, Aranese, Aromanian, Aymara, ..."
|
||||
*/
|
||||
languages: string;
|
||||
|
||||
/**
|
||||
* ISO 8601 timestamp when the font was added to Fontshare
|
||||
* Format: "2021-03-12T20:49:05Z"
|
||||
*/
|
||||
inserted_at: string;
|
||||
|
||||
/**
|
||||
* HTML-formatted story/description about the font
|
||||
* Contains marketing text, design philosophy, and usage recommendations
|
||||
*/
|
||||
story: string;
|
||||
|
||||
/**
|
||||
* Version of the font family
|
||||
* Format: "1.0", "1.2", etc.
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* Total number of times this font has been viewed
|
||||
*/
|
||||
views: number;
|
||||
|
||||
/**
|
||||
* Number of views in the recent time period
|
||||
*/
|
||||
views_recent: number;
|
||||
|
||||
/**
|
||||
* Whether this font is marked as "hot"/trending
|
||||
*/
|
||||
is_hot: boolean;
|
||||
|
||||
/**
|
||||
* Whether this font is marked as new
|
||||
*/
|
||||
is_new: boolean;
|
||||
|
||||
/**
|
||||
* Whether this font is in the shortlisted collection
|
||||
*/
|
||||
is_shortlisted: boolean | null;
|
||||
|
||||
/**
|
||||
* Whether this font is marked as top/popular
|
||||
*/
|
||||
is_top: boolean;
|
||||
|
||||
/**
|
||||
* Variable font axes (for variable fonts)
|
||||
* Empty array [] for static fonts
|
||||
*/
|
||||
axes: FontshareAxis[];
|
||||
|
||||
/**
|
||||
* Tags/categories for this font
|
||||
* Examples: ["Magazines", "Branding", "Logos", "Posters"]
|
||||
*/
|
||||
font_tags: FontshareTag[];
|
||||
|
||||
/**
|
||||
* OpenType features available in this font
|
||||
*/
|
||||
features: FontshareFeature[];
|
||||
|
||||
/**
|
||||
* Array of available font styles/variants
|
||||
* Each style represents a different font file (weight, italic, variable)
|
||||
*/
|
||||
styles: FontshareStyle[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Publisher/foundry information
|
||||
*/
|
||||
export interface FontsharePublisher {
|
||||
/**
|
||||
* Description/bio of the publisher
|
||||
* Example: "Indian Type Foundry (ITF) creates retail and custom multilingual fonts..."
|
||||
*/
|
||||
bio: string;
|
||||
|
||||
/**
|
||||
* Publisher email (if available)
|
||||
*/
|
||||
email: string | null;
|
||||
|
||||
/**
|
||||
* Unique publisher identifier
|
||||
* UUID format
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Publisher links (social media, website, etc.)
|
||||
*/
|
||||
links: FontshareLink[];
|
||||
|
||||
/**
|
||||
* Publisher name
|
||||
* Example: "Indian Type Foundry"
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Designer information
|
||||
*/
|
||||
export interface FontshareDesigner {
|
||||
/**
|
||||
* Designer bio/description
|
||||
*/
|
||||
bio: string;
|
||||
|
||||
/**
|
||||
* Designer links (Twitter, website, etc.)
|
||||
*/
|
||||
links: FontshareLink[];
|
||||
|
||||
/**
|
||||
* Designer name
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link information
|
||||
*/
|
||||
export interface FontshareLink {
|
||||
/**
|
||||
* Name of the link platform/site
|
||||
* Examples: "Twitter", "GitHub", "Website"
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* URL of the link (may be null)
|
||||
*/
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font tag/category
|
||||
*/
|
||||
export interface FontshareTag {
|
||||
/**
|
||||
* Tag name
|
||||
* Examples: "Magazines", "Branding", "Logos", "Posters"
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenType feature
|
||||
*/
|
||||
export interface FontshareFeature {
|
||||
/**
|
||||
* Feature name (descriptive name or null)
|
||||
* Examples: "Alternate t", "All Alternates", or null
|
||||
*/
|
||||
name: string | null;
|
||||
|
||||
/**
|
||||
* Whether this feature is on by default
|
||||
*/
|
||||
on_by_default: boolean;
|
||||
|
||||
/**
|
||||
* OpenType feature tag (4-character code)
|
||||
* Examples: "ss01", "frac", "liga", "aalt", "case"
|
||||
*/
|
||||
tag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable font axis (for variable fonts)
|
||||
* Defines the range and properties of a variable font axis (e.g., weight)
|
||||
*/
|
||||
export interface FontshareAxis {
|
||||
/**
|
||||
* Name of the axis
|
||||
* Example: "wght" (weight axis)
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* CSS property name for the axis
|
||||
* Example: "wght"
|
||||
*/
|
||||
property: string;
|
||||
|
||||
/**
|
||||
* Default value for the axis
|
||||
* Example: 420.0, 650.0, 700.0
|
||||
*/
|
||||
range_default: number;
|
||||
|
||||
/**
|
||||
* Minimum value for the axis
|
||||
* Example: 300.0, 100.0, 200.0
|
||||
*/
|
||||
range_left: number;
|
||||
|
||||
/**
|
||||
* Maximum value for the axis
|
||||
* Example: 900.0, 700.0, 800.0
|
||||
*/
|
||||
range_right: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual font style/variant
|
||||
* Each style represents a single downloadable font file
|
||||
*/
|
||||
export interface FontshareStyle {
|
||||
/**
|
||||
* Unique identifier for this style
|
||||
* UUID format
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Whether this is the default style for the font family
|
||||
* Typically, one style per font is marked as default
|
||||
*/
|
||||
default: boolean;
|
||||
|
||||
/**
|
||||
* CDN URL to the font file
|
||||
* Protocol-relative URL: "//cdn.fontshare.com/wf/..."
|
||||
* Note: URL starts with "//" (protocol-relative), may need protocol prepended
|
||||
*/
|
||||
file: string;
|
||||
|
||||
/**
|
||||
* Whether this style is italic
|
||||
* false for upright, true for italic styles
|
||||
*/
|
||||
is_italic: boolean;
|
||||
|
||||
/**
|
||||
* Whether this is a variable font
|
||||
* Variable fonts have adjustable axes (weight, slant, etc.)
|
||||
*/
|
||||
is_variable: boolean;
|
||||
|
||||
/**
|
||||
* Typography properties for this style
|
||||
* Contains measurements like cap height, x-height, ascenders/descenders
|
||||
* May be empty object {} for some styles
|
||||
*/
|
||||
properties: FontshareStyleProperties | Record<string, never>;
|
||||
|
||||
/**
|
||||
* Weight information for this style
|
||||
*/
|
||||
weight: FontshareWeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typography/measurement properties for a font style
|
||||
*/
|
||||
export interface FontshareStyleProperties {
|
||||
/**
|
||||
* Distance from baseline to the top of ascenders
|
||||
* Example: 1010, 990, 1000
|
||||
*/
|
||||
ascending_leading: number | null;
|
||||
|
||||
/**
|
||||
* Height of uppercase letters (cap height)
|
||||
* Example: 710, 680, 750
|
||||
*/
|
||||
cap_height: number | null;
|
||||
|
||||
/**
|
||||
* Distance from baseline to the bottom of descenders (negative value)
|
||||
* Example: -203, -186, -220
|
||||
*/
|
||||
descending_leading: number | null;
|
||||
|
||||
/**
|
||||
* Body height of the font
|
||||
* Often null in Fontshare data
|
||||
*/
|
||||
body_height: number | null;
|
||||
|
||||
/**
|
||||
* Maximum character width in the font
|
||||
* Example: 1739, 1739, 1739
|
||||
*/
|
||||
max_char_width: number | null;
|
||||
|
||||
/**
|
||||
* Height of lowercase x-height
|
||||
* Example: 480, 494, 523
|
||||
*/
|
||||
x_height: number | null;
|
||||
|
||||
/**
|
||||
* Maximum Y coordinate (top of ascenders)
|
||||
* Example: 1010, 990, 1026
|
||||
*/
|
||||
y_max: number | null;
|
||||
|
||||
/**
|
||||
* Minimum Y coordinate (bottom of descenders)
|
||||
* Example: -240, -250, -280
|
||||
*/
|
||||
y_min: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weight information for a font style
|
||||
*/
|
||||
export interface FontshareWeight {
|
||||
/**
|
||||
* Display label for the weight
|
||||
* Examples: "Light", "Regular", "Bold", "Variable", "Variable Italic"
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Internal name for the weight
|
||||
* Examples: "Light", "Regular", "Bold", "Variable", "VariableItalic"
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Native/localized name for the weight (if available)
|
||||
* Often null for Latin-script fonts
|
||||
*/
|
||||
native_name: string | null;
|
||||
|
||||
/**
|
||||
* Numeric weight value
|
||||
* Examples: 300, 400, 700, 0 (for variable fonts), 1, 2
|
||||
* Note: This matches the `weight` property
|
||||
*/
|
||||
number: number;
|
||||
|
||||
/**
|
||||
* Numeric weight value (duplicate of `number`)
|
||||
* Appears to be redundant with `number` field
|
||||
*/
|
||||
weight: number;
|
||||
}
|
||||
99
src/entities/Font/model/types/google.ts
Normal file
99
src/entities/Font/model/types/google.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* GOOGLE FONTS API TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type { FontVariant } from './common';
|
||||
|
||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||
|
||||
/**
|
||||
* Model of google fonts api response
|
||||
*/
|
||||
export interface GoogleFontsApiModel {
|
||||
/**
|
||||
* Array of font items returned by the Google Fonts API
|
||||
* Contains all font families matching the requested query parameters
|
||||
*/
|
||||
items: FontItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual font from Google Fonts API
|
||||
*/
|
||||
export interface FontItem {
|
||||
/**
|
||||
* Font family name (e.g., "Roboto", "Open Sans", "Lato")
|
||||
* This is the name used in CSS font-family declarations
|
||||
*/
|
||||
family: string;
|
||||
|
||||
/**
|
||||
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
|
||||
* Useful for grouping and filtering fonts by style
|
||||
*/
|
||||
category: FontCategory;
|
||||
|
||||
/**
|
||||
* Available font variants for this font family
|
||||
* Array of strings representing available weights and styles
|
||||
* Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"]
|
||||
* The keys in the `files` object correspond to these variant values
|
||||
*/
|
||||
variants: FontVariant[];
|
||||
|
||||
/**
|
||||
* Supported character subsets for this font
|
||||
* Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.]
|
||||
* Determines which character sets are included in the font files
|
||||
*/
|
||||
subsets: string[];
|
||||
|
||||
/**
|
||||
* Font version identifier
|
||||
* Format: "v" followed by version number (e.g., "v31", "v20", "v1")
|
||||
* Used to track font updates and cache busting
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* Last modification date of the font
|
||||
* Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01")
|
||||
* Indicates when the font was last updated by the font foundry
|
||||
*/
|
||||
lastModified: string;
|
||||
|
||||
/**
|
||||
* Mapping of font variants to their downloadable URLs
|
||||
* Keys correspond to values in the `variants` array
|
||||
* Examples:
|
||||
* - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..."
|
||||
* - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..."
|
||||
* - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..."
|
||||
*/
|
||||
files: FontFiles;
|
||||
|
||||
/**
|
||||
* URL to the font menu preview image
|
||||
* Typically a PNG showing the font family name in the font
|
||||
* Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2"
|
||||
*/
|
||||
menu: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for backward compatibility
|
||||
* Google Fonts API font item
|
||||
*/
|
||||
export type GoogleFontItem = FontItem;
|
||||
|
||||
/**
|
||||
* Google Fonts API file mapping
|
||||
* Dynamic keys that match the variants array
|
||||
*
|
||||
* Examples:
|
||||
* - { "regular": "...", "italic": "...", "700": "...", "700italic": "..." }
|
||||
* - { "400": "...", "400italic": "...", "900": "..." }
|
||||
*/
|
||||
export type FontFiles = Partial<Record<FontVariant, string>>;
|
||||
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 };
|
||||
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,
|
||||
};
|
||||
}
|
||||
90
src/features/GetFonts/model/const/const.ts
Normal file
90
src/features/GetFonts/model/const/const.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '$entities/Font';
|
||||
import type { Property } from '$shared/lib';
|
||||
|
||||
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<FontProvider>[] = [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google Fonts',
|
||||
value: 'google',
|
||||
},
|
||||
{
|
||||
id: 'fontshare',
|
||||
name: 'Fontshare',
|
||||
value: 'fontshare',
|
||||
},
|
||||
] as const;
|
||||
|
||||
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';
|
||||
88
src/features/SetupFont/model/const/const.ts
Normal file
88
src/features/SetupFont/model/const/const.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
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 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -1,13 +0,0 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
7
src/main.ts
Normal file
7
src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import App from '$app/App.svelte';
|
||||
import { mount } from 'svelte';
|
||||
import '$app/styles/app.css';
|
||||
|
||||
mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
@@ -1,9 +0,0 @@
|
||||
<script>
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
</script>
|
||||
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>
|
||||
Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation
|
||||
</p>
|
||||
<Button>Click me!</Button>
|
||||
152
src/routes/Page.svelte
Normal file
152
src/routes/Page.svelte
Normal file
@@ -0,0 +1,152 @@
|
||||
<!--
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
60
src/shared/api/api.ts
Normal file
60
src/shared/api/api.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { ApiResponse } from '$shared/types/common';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
public response?: Response,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
`Request failed: ${response.statusText}`,
|
||||
response,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json() as T;
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
|
||||
|
||||
post: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
put: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user