jiplak 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.id.md +312 -0
- package/README.md +312 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +107 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +108 -0
- package/dist/core.d.ts +27 -0
- package/dist/core.js +14 -0
- package/dist/react.d.ts +29 -0
- package/dist/react.js +77 -0
- package/dist/snapshot.d.ts +13 -0
- package/dist/snapshot.js +50 -0
- package/package.json +72 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wahyu Agus Arifin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.id.md
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# jiplak
|
|
4
|
+
|
|
5
|
+
**Skeleton loading otomatis, dijiplak dari tampilan asli aplikasi React kamu.**
|
|
6
|
+
|
|
7
|
+
Bungkus komponen. Jalankan satu perintah. Skeleton langsung sama persis.
|
|
8
|
+
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
[](https://react.dev)
|
|
11
|
+
[](https://www.typescriptlang.org)
|
|
12
|
+
[](https://lokio.link)
|
|
13
|
+
|
|
14
|
+
[](https://buymeacoffee.com/wahyuagusarifin)
|
|
15
|
+
|
|
16
|
+
[English](./README.md) · Bahasa Indonesia
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Kenapa jiplak?
|
|
23
|
+
|
|
24
|
+
Skeleton yang digambar manual cepat tidak sesuai lagi begitu layout berubah.
|
|
25
|
+
jiplak memotret komponen asli di browser dan menyimpan bentuknya. Kamu tidak
|
|
26
|
+
perlu menggambar skeleton lagi.
|
|
27
|
+
|
|
28
|
+
- **Tanpa menggambar.** Bentuk diambil dari DOM asli: teks, gambar, tombol.
|
|
29
|
+
- **Tanpa daftar route.** CLI menjelajah aplikasi lewat link-nya sendiri.
|
|
30
|
+
- **Responsif.** Dipotret di beberapa lebar layar, dipilih otomatis saat tampil.
|
|
31
|
+
- **Satu file YAML.** `jiplak.yaml`, cukup satu baris untuk mulai.
|
|
32
|
+
- **Mode gelap.** Mengikuti class `.dark` (konvensi Tailwind).
|
|
33
|
+
- **Runtime kecil.** Satu komponen React; alat pemotret tidak ikut ke browser.
|
|
34
|
+
|
|
35
|
+
## Daftar isi
|
|
36
|
+
|
|
37
|
+
- [Mulai cepat](#mulai-cepat)
|
|
38
|
+
- [Pertanyaan umum](#pertanyaan-umum)
|
|
39
|
+
- [Konfigurasi](#konfigurasi)
|
|
40
|
+
- [API](#api)
|
|
41
|
+
- [Cara kerja](#cara-kerja)
|
|
42
|
+
- [Sponsor](#sponsor)
|
|
43
|
+
- [Pembuat](#pembuat)
|
|
44
|
+
- [Lisensi](#lisensi)
|
|
45
|
+
|
|
46
|
+
## Mulai cepat
|
|
47
|
+
|
|
48
|
+
### 1. Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install jiplak
|
|
52
|
+
# atau
|
|
53
|
+
bun add jiplak
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 2. Bungkus komponen yang menunggu data
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { Skeleton } from 'jiplak/react'
|
|
60
|
+
|
|
61
|
+
function Profil() {
|
|
62
|
+
const { data, isLoading } = useProfil()
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Skeleton name="profil-card" loading={isLoading}>
|
|
66
|
+
<ProfilCard user={data} />
|
|
67
|
+
</Skeleton>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 3. Buat `jiplak.yaml` di root project
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
url: http://localhost:3000
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 4. Jalankan aplikasi, lalu potret
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm run dev
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Di terminal lain:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx jiplak build
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 5. Import registry hasil build sekali saja
|
|
91
|
+
|
|
92
|
+
Import dari komponen client yang selalu dimuat, misalnya providers:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
'use client'
|
|
96
|
+
import './bones/registry'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Selesai. Saat `loading` bernilai `true`, skeleton tampil otomatis.
|
|
100
|
+
|
|
101
|
+
## Pertanyaan umum
|
|
102
|
+
|
|
103
|
+
<details>
|
|
104
|
+
<summary><b>Apakah setiap screen harus diberi <code><Skeleton></code>?</b></summary>
|
|
105
|
+
|
|
106
|
+
Tidak. Bungkus hanya bagian yang menunggu data, misalnya card, list, atau
|
|
107
|
+
tabel. Bagian statis seperti header dan menu tidak perlu. Satu screen bisa
|
|
108
|
+
punya beberapa `<Skeleton>` dengan `name` yang berbeda.
|
|
109
|
+
|
|
110
|
+
</details>
|
|
111
|
+
|
|
112
|
+
<details>
|
|
113
|
+
<summary><b>Apakah skeleton benar-benar dibuat otomatis?</b></summary>
|
|
114
|
+
|
|
115
|
+
Ya. `jiplak build` menjelajah aplikasi lewat link, menemukan setiap
|
|
116
|
+
`<Skeleton>`, lalu mencatat bentuknya. Tugas kamu hanya membungkus komponen.
|
|
117
|
+
|
|
118
|
+
</details>
|
|
119
|
+
|
|
120
|
+
<details>
|
|
121
|
+
<summary><b>Kapan harus menjalankan <code>jiplak build</code> lagi?</b></summary>
|
|
122
|
+
|
|
123
|
+
Setelah layout komponen yang dibungkus berubah, atau setelah menambah
|
|
124
|
+
`<Skeleton>` baru. Commit folder `src/bones/` supaya tim tidak perlu build
|
|
125
|
+
ulang.
|
|
126
|
+
|
|
127
|
+
</details>
|
|
128
|
+
|
|
129
|
+
<details>
|
|
130
|
+
<summary><b>Bagaimana sebelum build pertama?</b></summary>
|
|
131
|
+
|
|
132
|
+
Aplikasi berjalan normal. Komponen tampil seperti biasa, hanya tanpa
|
|
133
|
+
skeleton.
|
|
134
|
+
|
|
135
|
+
</details>
|
|
136
|
+
|
|
137
|
+
<details>
|
|
138
|
+
<summary><b>Komponen saya kosong tanpa data.</b></summary>
|
|
139
|
+
|
|
140
|
+
Beri contoh isi lewat `fixture`. jiplak memotret `fixture`, bukan komponen
|
|
141
|
+
aslinya:
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
<Skeleton name="profil-card" loading={isLoading} fixture={<ProfilCard user={contohUser} />}>
|
|
145
|
+
<ProfilCard user={data} />
|
|
146
|
+
</Skeleton>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
</details>
|
|
150
|
+
|
|
151
|
+
<details>
|
|
152
|
+
<summary><b>Ada halaman yang tidak ketemu.</b></summary>
|
|
153
|
+
|
|
154
|
+
Penjelajah hanya menemukan halaman yang punya link dari halaman lain.
|
|
155
|
+
Tambahkan halaman tanpa link sebagai titik awal:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
routes:
|
|
159
|
+
- /
|
|
160
|
+
- /pengaturan/profil
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
</details>
|
|
164
|
+
|
|
165
|
+
<details>
|
|
166
|
+
<summary><b>Ada halaman yang tidak boleh dikunjungi.</b></summary>
|
|
167
|
+
|
|
168
|
+
Link logout dan file (pdf, gambar, csv) selalu dilewati. Untuk path lain:
|
|
169
|
+
|
|
170
|
+
```yaml
|
|
171
|
+
ignore:
|
|
172
|
+
- /admin
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
</details>
|
|
176
|
+
|
|
177
|
+
<details>
|
|
178
|
+
<summary><b>Build berhenti di <code>maxPages</code>.</b></summary>
|
|
179
|
+
|
|
180
|
+
Penjelajah berhenti di 50 halaman supaya route dinamis (`/post/1`,
|
|
181
|
+
`/post/2`, …) tidak dijelajah tanpa akhir. Naikkan bila perlu:
|
|
182
|
+
|
|
183
|
+
```yaml
|
|
184
|
+
maxPages: 200
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
</details>
|
|
188
|
+
|
|
189
|
+
<details>
|
|
190
|
+
<summary><b>Halaman saya butuh login.</b></summary>
|
|
191
|
+
|
|
192
|
+
Kirim cookie sesi:
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
cookies:
|
|
196
|
+
- session=isi-cookie-kamu
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
</details>
|
|
200
|
+
|
|
201
|
+
<details>
|
|
202
|
+
<summary><b>Browser tidak ditemukan.</b></summary>
|
|
203
|
+
|
|
204
|
+
Pakai Chrome yang sudah terpasang:
|
|
205
|
+
|
|
206
|
+
```yaml
|
|
207
|
+
channel: chrome
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Atau unduh Chromium sekali:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
npx playwright install chromium
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
</details>
|
|
217
|
+
|
|
218
|
+
## Konfigurasi
|
|
219
|
+
|
|
220
|
+
Semua key opsional. Nilai di bawah adalah default. Key yang salah ketik
|
|
221
|
+
langsung ditolak dengan pesan error.
|
|
222
|
+
|
|
223
|
+
```yaml
|
|
224
|
+
# pemotretan
|
|
225
|
+
url: http://localhost:3000 # alamat aplikasi yang berjalan
|
|
226
|
+
routes: [/] # titik awal jelajah
|
|
227
|
+
ignore: [] # path yang tidak dikunjungi
|
|
228
|
+
maxPages: 50 # batas jumlah halaman
|
|
229
|
+
breakpoints: [375, 768, 1280] # lebar layar yang dipotret
|
|
230
|
+
out: ./src/bones # folder hasil
|
|
231
|
+
wait: 800 # jeda (ms) setelah halaman dimuat
|
|
232
|
+
channel: chrome # opsional: pakai Chrome yang terpasang
|
|
233
|
+
cookies: [] # opsional: name=value
|
|
234
|
+
|
|
235
|
+
# tampilan (props di <Skeleton> menimpa nilai ini)
|
|
236
|
+
animate: pulse # pulse | shimmer | solid
|
|
237
|
+
color: "#ececec" # mode terang
|
|
238
|
+
darkColor: "#262626" # mode gelap (class .dark)
|
|
239
|
+
shimmerColor: "#f7f7f7"
|
|
240
|
+
darkShimmerColor: "#323232"
|
|
241
|
+
speed: 1.6s
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## API
|
|
245
|
+
|
|
246
|
+
### `<Skeleton>`
|
|
247
|
+
|
|
248
|
+
| Prop | Wajib | Fungsi |
|
|
249
|
+
| --- | --- | --- |
|
|
250
|
+
| `name` | ya | Nama unik: huruf, angka, `-`, `_` |
|
|
251
|
+
| `loading` | ya | `true` saat data belum datang |
|
|
252
|
+
| `fixture` | tidak | Contoh isi yang dipotret saat build |
|
|
253
|
+
| `animate`, `color`, `darkColor`, `shimmerColor`, `darkShimmerColor`, `speed` | tidak | Menimpa `jiplak.yaml` untuk skeleton ini saja |
|
|
254
|
+
| `options` | tidak | `{ exclude: ['.badge'] }` melewati elemen; `{ leafTags: ['section'] }` menjadikan satu tag satu kotak |
|
|
255
|
+
| `bones` | tidak | Data skeleton langsung, tanpa registry |
|
|
256
|
+
| `className` | tidak | Class untuk elemen pembungkus |
|
|
257
|
+
|
|
258
|
+
### Fungsi
|
|
259
|
+
|
|
260
|
+
| Fungsi | Kegunaan |
|
|
261
|
+
| --- | --- |
|
|
262
|
+
| `registerBones(map)` | Mendaftarkan bones per nama. Dipanggil oleh registry hasil build. |
|
|
263
|
+
| `configureJiplak(theme)` | Mengatur tampilan global. Dipanggil oleh registry dengan nilai dari YAML. |
|
|
264
|
+
|
|
265
|
+
### CLI
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
jiplak build [url] [--config jiplak.yaml]
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Cara kerja
|
|
272
|
+
|
|
273
|
+
1. `jiplak build` membuka aplikasi di browser tanpa tampilan, mulai dari
|
|
274
|
+
`routes`, lalu mengikuti link di domain yang sama.
|
|
275
|
+
2. Di setiap lebar layar, setiap `<Skeleton>` dipotret. Teks, gambar, dan
|
|
276
|
+
tombol dicatat sebagai kotak ("bones").
|
|
277
|
+
3. Kotak disimpan ke `src/bones/<name>.bones.json`, ditambah `registry.ts`
|
|
278
|
+
yang memuatnya.
|
|
279
|
+
4. Saat `loading`, kotak digambar di atas komponen yang disembunyikan,
|
|
280
|
+
memakai lebar layar yang paling cocok dengan lebar container.
|
|
281
|
+
|
|
282
|
+
## Pengembangan
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
bun install
|
|
286
|
+
bun test
|
|
287
|
+
bun run typecheck
|
|
288
|
+
bun run build
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Issue dan pull request terbuka di
|
|
292
|
+
[GitHub](https://github.com/ItPohgero/jiplak/issues).
|
|
293
|
+
|
|
294
|
+
## Sponsor
|
|
295
|
+
|
|
296
|
+
<a href="https://lokio.link">
|
|
297
|
+
<img src="https://raw.githubusercontent.com/ItPohgero/jiplak/main/.github/assets/lokio.png" alt="Lokio" width="72" />
|
|
298
|
+
</a>
|
|
299
|
+
|
|
300
|
+
jiplak disponsori oleh **[Lokio](https://lokio.link)**.
|
|
301
|
+
|
|
302
|
+
Suka jiplak? Dukung pengembangannya:
|
|
303
|
+
|
|
304
|
+
[](https://buymeacoffee.com/wahyuagusarifin)
|
|
305
|
+
|
|
306
|
+
## Pembuat
|
|
307
|
+
|
|
308
|
+
**Wahyu Agus Arifin** · [@ItPohgero](https://github.com/ItPohgero)
|
|
309
|
+
|
|
310
|
+
## Lisensi
|
|
311
|
+
|
|
312
|
+
[MIT](./LICENSE) © 2026 Wahyu Agus Arifin
|
package/README.md
CHANGED
|
@@ -1,3 +1,313 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# jiplak
|
|
4
|
+
|
|
5
|
+
**Pixel-perfect skeleton loading screens, auto-captured from your real React UI.**
|
|
6
|
+
|
|
7
|
+
Wrap a component. Run one command. Get a skeleton that matches it exactly.
|
|
8
|
+
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
[](https://react.dev)
|
|
11
|
+
[](https://www.typescriptlang.org)
|
|
12
|
+
[](https://lokio.link)
|
|
13
|
+
|
|
14
|
+
[](https://buymeacoffee.com/wahyuagusarifin)
|
|
15
|
+
|
|
16
|
+
English · [Bahasa Indonesia](./README.id.md)
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Why jiplak?
|
|
23
|
+
|
|
24
|
+
Hand-drawn skeletons drift out of sync with the real UI the moment a layout
|
|
25
|
+
changes. jiplak ("to trace" in Indonesian) traces your rendered components in
|
|
26
|
+
a real browser and stores their exact shape. You never draw a skeleton again.
|
|
27
|
+
|
|
28
|
+
- **Zero drawing.** Bones come from the real DOM: text, images, buttons.
|
|
29
|
+
- **Zero route list.** The CLI crawls your app through its own links.
|
|
30
|
+
- **Responsive.** Captured at several screen widths; the right one is picked at runtime.
|
|
31
|
+
- **One YAML file.** `jiplak.yaml`, one line to start.
|
|
32
|
+
- **Dark mode.** Follows the `.dark` class (Tailwind convention).
|
|
33
|
+
- **Tiny runtime.** One React component; the capture tooling never ships to the browser.
|
|
34
|
+
|
|
35
|
+
## Contents
|
|
36
|
+
|
|
37
|
+
- [Quick start](#quick-start)
|
|
38
|
+
- [FAQ](#faq)
|
|
39
|
+
- [Configuration](#configuration)
|
|
40
|
+
- [API](#api)
|
|
41
|
+
- [How it works](#how-it-works)
|
|
42
|
+
- [Sponsor](#sponsor)
|
|
43
|
+
- [Author](#author)
|
|
44
|
+
- [License](#license)
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
### 1. Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install jiplak
|
|
52
|
+
# or
|
|
53
|
+
bun add jiplak
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 2. Wrap components that wait for data
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { Skeleton } from 'jiplak/react'
|
|
60
|
+
|
|
61
|
+
function Profile() {
|
|
62
|
+
const { data, isLoading } = useProfile()
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Skeleton name="profile-card" loading={isLoading}>
|
|
66
|
+
<ProfileCard user={data} />
|
|
67
|
+
</Skeleton>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 3. Add `jiplak.yaml` to your project root
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
url: http://localhost:3000
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 4. Start your app, then capture
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm run dev
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
In a second terminal:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx jiplak build
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 5. Import the generated registry once
|
|
91
|
+
|
|
92
|
+
Import it from a client component that always loads, such as your providers:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
'use client'
|
|
96
|
+
import './bones/registry'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Done. While `loading` is `true`, the skeleton shows automatically.
|
|
100
|
+
|
|
101
|
+
## FAQ
|
|
102
|
+
|
|
103
|
+
<details>
|
|
104
|
+
<summary><b>Do I wrap every screen?</b></summary>
|
|
105
|
+
|
|
106
|
+
No. Wrap only the parts that wait for data, such as cards, lists, or tables.
|
|
107
|
+
Static parts like headers and menus need nothing. One screen can hold several
|
|
108
|
+
`<Skeleton>` components with different names.
|
|
109
|
+
|
|
110
|
+
</details>
|
|
111
|
+
|
|
112
|
+
<details>
|
|
113
|
+
<summary><b>Is the skeleton really generated automatically?</b></summary>
|
|
114
|
+
|
|
115
|
+
Yes. `jiplak build` crawls your app through its links, finds every
|
|
116
|
+
`<Skeleton>`, and records its shape. Your only job is to wrap components.
|
|
117
|
+
|
|
118
|
+
</details>
|
|
119
|
+
|
|
120
|
+
<details>
|
|
121
|
+
<summary><b>When do I run <code>jiplak build</code> again?</b></summary>
|
|
122
|
+
|
|
123
|
+
After a wrapped component changes its layout, or after you add a new
|
|
124
|
+
`<Skeleton>`. Commit the `src/bones/` folder so your team does not need to
|
|
125
|
+
rebuild.
|
|
126
|
+
|
|
127
|
+
</details>
|
|
128
|
+
|
|
129
|
+
<details>
|
|
130
|
+
<summary><b>What happens before the first build?</b></summary>
|
|
131
|
+
|
|
132
|
+
Your app works as usual. Components render normally, just without a
|
|
133
|
+
skeleton.
|
|
134
|
+
|
|
135
|
+
</details>
|
|
136
|
+
|
|
137
|
+
<details>
|
|
138
|
+
<summary><b>My component is empty without data.</b></summary>
|
|
139
|
+
|
|
140
|
+
Pass sample content through `fixture`. jiplak captures the fixture instead of
|
|
141
|
+
the real children:
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
<Skeleton name="profile-card" loading={isLoading} fixture={<ProfileCard user={sampleUser} />}>
|
|
145
|
+
<ProfileCard user={data} />
|
|
146
|
+
</Skeleton>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
</details>
|
|
150
|
+
|
|
151
|
+
<details>
|
|
152
|
+
<summary><b>A page was not found.</b></summary>
|
|
153
|
+
|
|
154
|
+
The crawler finds only pages that another page links to. Add unlinked pages
|
|
155
|
+
as extra start points:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
routes:
|
|
159
|
+
- /
|
|
160
|
+
- /settings/profile
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
</details>
|
|
164
|
+
|
|
165
|
+
<details>
|
|
166
|
+
<summary><b>Some pages must never be visited.</b></summary>
|
|
167
|
+
|
|
168
|
+
Logout links and files (pdf, images, csv) are always skipped. Skip other
|
|
169
|
+
paths with `ignore`:
|
|
170
|
+
|
|
171
|
+
```yaml
|
|
172
|
+
ignore:
|
|
173
|
+
- /admin
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
</details>
|
|
177
|
+
|
|
178
|
+
<details>
|
|
179
|
+
<summary><b>The build stopped at <code>maxPages</code>.</b></summary>
|
|
180
|
+
|
|
181
|
+
The crawler stops at 50 pages so dynamic routes (`/post/1`, `/post/2`, …)
|
|
182
|
+
cannot run forever. Raise the limit if needed:
|
|
183
|
+
|
|
184
|
+
```yaml
|
|
185
|
+
maxPages: 200
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
</details>
|
|
189
|
+
|
|
190
|
+
<details>
|
|
191
|
+
<summary><b>My pages need a login.</b></summary>
|
|
192
|
+
|
|
193
|
+
Send a session cookie:
|
|
194
|
+
|
|
195
|
+
```yaml
|
|
196
|
+
cookies:
|
|
197
|
+
- session=your-session-cookie
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
</details>
|
|
201
|
+
|
|
202
|
+
<details>
|
|
203
|
+
<summary><b>Browser not found.</b></summary>
|
|
204
|
+
|
|
205
|
+
Use the Chrome you already have:
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
channel: chrome
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Or download Chromium once:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
npx playwright install chromium
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
</details>
|
|
218
|
+
|
|
219
|
+
## Configuration
|
|
220
|
+
|
|
221
|
+
Every key is optional. Values below are the defaults. Unknown keys fail with
|
|
222
|
+
a clear error, so typos never pass silently.
|
|
223
|
+
|
|
224
|
+
```yaml
|
|
225
|
+
# capture
|
|
226
|
+
url: http://localhost:3000 # your running app
|
|
227
|
+
routes: [/] # crawl start points
|
|
228
|
+
ignore: [] # path prefixes never visited
|
|
229
|
+
maxPages: 50 # crawl limit
|
|
230
|
+
breakpoints: [375, 768, 1280] # screen widths to capture
|
|
231
|
+
out: ./src/bones # output folder
|
|
232
|
+
wait: 800 # ms to wait after page load
|
|
233
|
+
channel: chrome # optional: use installed Chrome
|
|
234
|
+
cookies: [] # optional: name=value
|
|
235
|
+
|
|
236
|
+
# look (props on <Skeleton> override these)
|
|
237
|
+
animate: pulse # pulse | shimmer | solid
|
|
238
|
+
color: "#ececec" # light mode
|
|
239
|
+
darkColor: "#262626" # dark mode (.dark class)
|
|
240
|
+
shimmerColor: "#f7f7f7"
|
|
241
|
+
darkShimmerColor: "#323232"
|
|
242
|
+
speed: 1.6s
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## API
|
|
246
|
+
|
|
247
|
+
### `<Skeleton>`
|
|
248
|
+
|
|
249
|
+
| Prop | Required | Description |
|
|
250
|
+
| --- | --- | --- |
|
|
251
|
+
| `name` | yes | Unique name: letters, digits, `-`, `_` |
|
|
252
|
+
| `loading` | yes | `true` while data is loading |
|
|
253
|
+
| `fixture` | no | Sample content captured at build time |
|
|
254
|
+
| `animate`, `color`, `darkColor`, `shimmerColor`, `darkShimmerColor`, `speed` | no | Override `jiplak.yaml` for this skeleton |
|
|
255
|
+
| `options` | no | `{ exclude: ['.badge'] }` skips elements; `{ leafTags: ['section'] }` turns a tag into one bone |
|
|
256
|
+
| `bones` | no | Bones passed directly instead of the registry |
|
|
257
|
+
| `className` | no | Class for the wrapper element |
|
|
258
|
+
|
|
259
|
+
### Functions
|
|
260
|
+
|
|
261
|
+
| Function | Description |
|
|
262
|
+
| --- | --- |
|
|
263
|
+
| `registerBones(map)` | Register bones by name. The generated registry calls it. |
|
|
264
|
+
| `configureJiplak(theme)` | Set global look defaults. The generated registry calls it with your YAML values. |
|
|
265
|
+
|
|
266
|
+
### CLI
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
jiplak build [url] [--config jiplak.yaml]
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## How it works
|
|
273
|
+
|
|
274
|
+
1. `jiplak build` opens your app in a headless browser, starting at
|
|
275
|
+
`routes`, and follows links on the same origin.
|
|
276
|
+
2. At each breakpoint, every `<Skeleton>` on the page is captured. Text
|
|
277
|
+
blocks, images, and controls become rectangles ("bones").
|
|
278
|
+
3. Bones are saved to `src/bones/<name>.bones.json`, plus a `registry.ts`
|
|
279
|
+
that loads them.
|
|
280
|
+
4. At runtime, while `loading` is `true`, the bones are drawn over the hidden
|
|
281
|
+
component, using the breakpoint that fits the container width.
|
|
282
|
+
|
|
283
|
+
## Development
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
bun install
|
|
287
|
+
bun test
|
|
288
|
+
bun run typecheck
|
|
289
|
+
bun run build
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Issues and pull requests are welcome on
|
|
293
|
+
[GitHub](https://github.com/ItPohgero/jiplak/issues).
|
|
294
|
+
|
|
295
|
+
## Sponsor
|
|
296
|
+
|
|
297
|
+
<a href="https://lokio.link">
|
|
298
|
+
<img src="https://raw.githubusercontent.com/ItPohgero/jiplak/main/.github/assets/lokio.png" alt="Lokio" width="72" />
|
|
299
|
+
</a>
|
|
300
|
+
|
|
301
|
+
jiplak is proudly sponsored by **[Lokio](https://lokio.link)**.
|
|
302
|
+
|
|
303
|
+
Like jiplak? Support its development:
|
|
304
|
+
|
|
305
|
+
[](https://buymeacoffee.com/wahyuagusarifin)
|
|
306
|
+
|
|
307
|
+
## Author
|
|
308
|
+
|
|
309
|
+
**Wahyu Agus Arifin** · [@ItPohgero](https://github.com/ItPohgero)
|
|
310
|
+
|
|
311
|
+
## License
|
|
312
|
+
|
|
313
|
+
[MIT](./LICENSE) © 2026 Wahyu Agus Arifin
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { chromium } from 'playwright';
|
|
6
|
+
import { crawlPaths, loadConfig, registrySource, themeOf, validName } from './config.js';
|
|
7
|
+
const USAGE = `Usage: jiplak build [url] [--config jiplak.yaml]
|
|
8
|
+
|
|
9
|
+
Starts at each route in jiplak.yaml, follows same-origin links, captures
|
|
10
|
+
every <Skeleton name="..."> at every breakpoint, and writes
|
|
11
|
+
<out>/<name>.bones.json plus <out>/registry.ts.`;
|
|
12
|
+
const { positionals, values } = parseArgs({
|
|
13
|
+
allowPositionals: true,
|
|
14
|
+
options: { config: { type: 'string', default: 'jiplak.yaml' }, help: { type: 'boolean', short: 'h' } },
|
|
15
|
+
});
|
|
16
|
+
if (values.help || positionals[0] !== 'build') {
|
|
17
|
+
console.log(USAGE);
|
|
18
|
+
process.exit(values.help ? 0 : 1);
|
|
19
|
+
}
|
|
20
|
+
let configText = '';
|
|
21
|
+
try {
|
|
22
|
+
configText = readFileSync(resolve(values.config), 'utf8');
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
console.warn(`jiplak: ${values.config} not found, using defaults`);
|
|
26
|
+
}
|
|
27
|
+
const config = loadConfig(configText);
|
|
28
|
+
const origin = (positionals[1] ?? config.url).replace(/\/$/, '');
|
|
29
|
+
const out = resolve(config.out);
|
|
30
|
+
const browser = await chromium.launch({ channel: config.channel });
|
|
31
|
+
const context = await browser.newContext();
|
|
32
|
+
await context.addInitScript(() => {
|
|
33
|
+
window.__JIPLAK_BUILD = true;
|
|
34
|
+
});
|
|
35
|
+
if (config.cookies.length > 0) {
|
|
36
|
+
await context.addCookies(config.cookies.map(c => {
|
|
37
|
+
const i = c.indexOf('=');
|
|
38
|
+
return { name: c.slice(0, i), value: c.slice(i + 1), url: origin };
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
const captured = {};
|
|
42
|
+
const page = await context.newPage();
|
|
43
|
+
const queue = [...new Set(config.routes)];
|
|
44
|
+
const seen = new Set(queue);
|
|
45
|
+
try {
|
|
46
|
+
for (let route = queue.shift(); route !== undefined; route = queue.shift()) {
|
|
47
|
+
for (const [i, width] of config.breakpoints.entries()) {
|
|
48
|
+
await page.setViewportSize({ width, height: 900 });
|
|
49
|
+
await page.goto(origin + route, { waitUntil: 'load' });
|
|
50
|
+
await page.waitForTimeout(config.wait);
|
|
51
|
+
if (i === 0) {
|
|
52
|
+
if (!page.url().startsWith(origin)) {
|
|
53
|
+
console.warn(` ${route}: redirected off-origin to ${page.url()}, skipped`);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const hrefs = await page.$$eval('a[href]', as => as.map(a => a.href));
|
|
57
|
+
for (const path of crawlPaths(hrefs, origin, config.ignore)) {
|
|
58
|
+
if (seen.size >= config.maxPages)
|
|
59
|
+
break;
|
|
60
|
+
if (!seen.has(path)) {
|
|
61
|
+
seen.add(path);
|
|
62
|
+
queue.push(path);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const found = await page.evaluate(() => {
|
|
67
|
+
const snap = window.__JIPLAK_SNAPSHOT;
|
|
68
|
+
if (!snap)
|
|
69
|
+
return null;
|
|
70
|
+
const result = {};
|
|
71
|
+
for (const el of document.querySelectorAll('[data-jiplak]')) {
|
|
72
|
+
const opts = el.dataset.jiplakOptions ? JSON.parse(el.dataset.jiplakOptions) : {};
|
|
73
|
+
result[el.dataset.jiplak] = snap(el, opts);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
});
|
|
77
|
+
if (!found) {
|
|
78
|
+
continue; // page does not load jiplak/react
|
|
79
|
+
}
|
|
80
|
+
for (const [name, capture] of Object.entries(found)) {
|
|
81
|
+
if (!validName(name)) {
|
|
82
|
+
console.warn(` skip "${name}": use letters, digits, - or _`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
;
|
|
86
|
+
(captured[name] ??= {})[width] = capture;
|
|
87
|
+
}
|
|
88
|
+
if (Object.keys(found).length > 0)
|
|
89
|
+
console.log(` ${route} @${width}: ${Object.keys(found).join(', ')}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await browser.close();
|
|
95
|
+
}
|
|
96
|
+
mkdirSync(out, { recursive: true });
|
|
97
|
+
for (const [name, bones] of Object.entries(captured)) {
|
|
98
|
+
writeFileSync(join(out, `${name}.bones.json`), JSON.stringify(bones) + '\n');
|
|
99
|
+
}
|
|
100
|
+
// Skeletons not visited this run keep their previous bones.
|
|
101
|
+
const names = readdirSync(out)
|
|
102
|
+
.filter(f => f.endsWith('.bones.json'))
|
|
103
|
+
.map(f => f.slice(0, -'.bones.json'.length));
|
|
104
|
+
writeFileSync(join(out, 'registry.ts'), registrySource(names, themeOf(config)));
|
|
105
|
+
if (seen.size >= config.maxPages)
|
|
106
|
+
console.warn(`jiplak: stopped at maxPages (${config.maxPages}); raise it in jiplak.yaml if pages were missed`);
|
|
107
|
+
console.log(`jiplak: ${seen.size} page(s) crawled, ${Object.keys(captured).length} skeleton(s) captured, ${names.length} in ${config.out}/registry.ts`);
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Theme } from './core.js';
|
|
2
|
+
export interface Config extends Theme {
|
|
3
|
+
/** Origin of the running app, e.g. http://localhost:3000. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** Start paths. Same-origin links found on each page are followed too. */
|
|
6
|
+
routes: string[];
|
|
7
|
+
/** Upper bound on pages visited, so dynamic routes (/item/1, /item/2, ...) cannot run forever. */
|
|
8
|
+
maxPages: number;
|
|
9
|
+
/** Path prefixes never visited, e.g. /admin. Logout links are always skipped. */
|
|
10
|
+
ignore: string[];
|
|
11
|
+
/** Viewport widths captured per route. */
|
|
12
|
+
breakpoints: number[];
|
|
13
|
+
/** Output directory for `<name>.bones.json` and `registry.ts`. */
|
|
14
|
+
out: string;
|
|
15
|
+
/** ms to wait after load before capture. */
|
|
16
|
+
wait: number;
|
|
17
|
+
/** Playwright browser channel, e.g. `chrome` to reuse installed Chrome instead of downloading Chromium. */
|
|
18
|
+
channel?: string;
|
|
19
|
+
/** Cookies sent with every page, `name=value`. */
|
|
20
|
+
cookies: string[];
|
|
21
|
+
}
|
|
22
|
+
/** Parse and validate jiplak.yaml text; unknown keys are rejected so typos surface. */
|
|
23
|
+
export declare function loadConfig(text: string): Config;
|
|
24
|
+
export declare function themeOf(config: Config): Theme;
|
|
25
|
+
/** Source of the generated registry: imports every bones file and applies the yaml theme. */
|
|
26
|
+
export declare function registrySource(names: string[], theme: Theme): string;
|
|
27
|
+
/** Same-origin paths from link hrefs worth crawling: no query/hash, no logout, no files, no ignored prefixes. */
|
|
28
|
+
export declare function crawlPaths(hrefs: string[], origin: string, ignore: string[]): string[];
|
|
29
|
+
/** Skeleton names become file names; keep them safe for the filesystem and imports. */
|
|
30
|
+
export declare const validName: (name: string) => boolean;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
const DEFAULTS = {
|
|
3
|
+
url: 'http://localhost:3000',
|
|
4
|
+
routes: ['/'],
|
|
5
|
+
maxPages: 50,
|
|
6
|
+
ignore: [],
|
|
7
|
+
breakpoints: [375, 768, 1280],
|
|
8
|
+
out: './src/bones',
|
|
9
|
+
wait: 800,
|
|
10
|
+
cookies: [],
|
|
11
|
+
};
|
|
12
|
+
const THEME_KEYS = ['color', 'darkColor', 'shimmerColor', 'darkShimmerColor', 'speed'];
|
|
13
|
+
const ANIMATIONS = ['pulse', 'shimmer', 'solid'];
|
|
14
|
+
function fail(msg) {
|
|
15
|
+
throw new Error(`jiplak.yaml: ${msg}`);
|
|
16
|
+
}
|
|
17
|
+
const isStrings = (v) => Array.isArray(v) && v.every(s => typeof s === 'string');
|
|
18
|
+
/** Parse and validate jiplak.yaml text; unknown keys are rejected so typos surface. */
|
|
19
|
+
export function loadConfig(text) {
|
|
20
|
+
const raw = parse(text) ?? {};
|
|
21
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
22
|
+
fail('root must be a mapping');
|
|
23
|
+
const c = { ...DEFAULTS, ...raw };
|
|
24
|
+
const known = new Set([...Object.keys(DEFAULTS), ...THEME_KEYS, 'animate', 'channel']);
|
|
25
|
+
for (const key of Object.keys(c))
|
|
26
|
+
if (!known.has(key))
|
|
27
|
+
fail(`unknown key "${key}"`);
|
|
28
|
+
if (typeof c.url !== 'string' || !/^https?:\/\//.test(c.url))
|
|
29
|
+
fail('url must start with http:// or https://');
|
|
30
|
+
if (!isStrings(c.routes) || c.routes.length === 0)
|
|
31
|
+
fail('routes must be a non-empty list of paths');
|
|
32
|
+
if (c.routes.some(r => !r.startsWith('/')))
|
|
33
|
+
fail('every route must start with "/"');
|
|
34
|
+
if (!Number.isInteger(c.maxPages) || c.maxPages < 1)
|
|
35
|
+
fail('maxPages must be a positive integer');
|
|
36
|
+
if (!isStrings(c.ignore) || c.ignore.some(r => !r.startsWith('/')))
|
|
37
|
+
fail('ignore must be a list of paths starting with "/"');
|
|
38
|
+
const bps = c.breakpoints;
|
|
39
|
+
if (!Array.isArray(bps) || bps.length === 0 || !bps.every(b => Number.isInteger(b) && b > 0))
|
|
40
|
+
fail('breakpoints must be a non-empty list of positive integers');
|
|
41
|
+
if (typeof c.out !== 'string' || c.out === '')
|
|
42
|
+
fail('out must be a path');
|
|
43
|
+
if (typeof c.wait !== 'number' || c.wait < 0)
|
|
44
|
+
fail('wait must be a number >= 0');
|
|
45
|
+
if (c.channel !== undefined && typeof c.channel !== 'string')
|
|
46
|
+
fail('channel must be a string');
|
|
47
|
+
if (!isStrings(c.cookies) || c.cookies.some(s => !s.includes('=')))
|
|
48
|
+
fail('cookies must be a list of name=value');
|
|
49
|
+
for (const key of THEME_KEYS)
|
|
50
|
+
if (c[key] !== undefined && typeof c[key] !== 'string')
|
|
51
|
+
fail(`${key} must be a string`);
|
|
52
|
+
if (c.animate !== undefined && !ANIMATIONS.includes(c.animate))
|
|
53
|
+
fail(`animate must be one of ${ANIMATIONS.join(', ')}`);
|
|
54
|
+
return c;
|
|
55
|
+
}
|
|
56
|
+
export function themeOf(config) {
|
|
57
|
+
const theme = {};
|
|
58
|
+
for (const key of THEME_KEYS)
|
|
59
|
+
if (config[key] !== undefined)
|
|
60
|
+
theme[key] = config[key];
|
|
61
|
+
if (config.animate)
|
|
62
|
+
theme.animate = config.animate;
|
|
63
|
+
return theme;
|
|
64
|
+
}
|
|
65
|
+
/** Source of the generated registry: imports every bones file and applies the yaml theme. */
|
|
66
|
+
export function registrySource(names, theme) {
|
|
67
|
+
const sorted = [...names].sort();
|
|
68
|
+
return [
|
|
69
|
+
'// Generated by `jiplak build`. Do not edit.',
|
|
70
|
+
"'use client'",
|
|
71
|
+
'',
|
|
72
|
+
"import { configureJiplak, registerBones, type Bones } from 'jiplak/react'",
|
|
73
|
+
...sorted.map((n, i) => `import b${i} from './${n}.bones.json'`),
|
|
74
|
+
'',
|
|
75
|
+
`configureJiplak(${JSON.stringify(theme)})`,
|
|
76
|
+
'',
|
|
77
|
+
'registerBones({',
|
|
78
|
+
...sorted.map((n, i) => ` ${JSON.stringify(n)}: b${i} as unknown as Bones,`),
|
|
79
|
+
'})',
|
|
80
|
+
'',
|
|
81
|
+
].join('\n');
|
|
82
|
+
}
|
|
83
|
+
// Visiting these with a GET can end the session or download files.
|
|
84
|
+
const NEVER_VISIT = /(log-?out|sign-?out|keluar)(\/|$)|\.(pdf|zip|png|jpe?g|gif|svg|webp|mp4|csv|xlsx?)$/i;
|
|
85
|
+
/** Same-origin paths from link hrefs worth crawling: no query/hash, no logout, no files, no ignored prefixes. */
|
|
86
|
+
export function crawlPaths(hrefs, origin, ignore) {
|
|
87
|
+
const paths = new Set();
|
|
88
|
+
for (const href of hrefs) {
|
|
89
|
+
let u;
|
|
90
|
+
try {
|
|
91
|
+
u = new URL(href, origin);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (u.origin !== new URL(origin).origin)
|
|
97
|
+
continue;
|
|
98
|
+
const path = u.pathname.replace(/(.)\/$/, '$1');
|
|
99
|
+
if (NEVER_VISIT.test(path))
|
|
100
|
+
continue;
|
|
101
|
+
if (ignore.some(p => path === p || path.startsWith(p.replace(/\/$/, '') + '/')))
|
|
102
|
+
continue;
|
|
103
|
+
paths.add(path);
|
|
104
|
+
}
|
|
105
|
+
return [...paths];
|
|
106
|
+
}
|
|
107
|
+
/** Skeleton names become file names; keep them safe for the filesystem and imports. */
|
|
108
|
+
export const validName = (name) => /^[a-z0-9][a-z0-9-_]*$/i.test(name);
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** One placeholder rect: x and w are % of capture width, y and h are px, r is border-radius. */
|
|
2
|
+
export type Bone = [x: number, y: number, w: number, h: number, r: number | string];
|
|
3
|
+
/** Layout of one `<Skeleton>` captured at one viewport width. */
|
|
4
|
+
export interface Capture {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
bones: Bone[];
|
|
8
|
+
}
|
|
9
|
+
/** Captures keyed by viewport width, as written to `<name>.bones.json`. */
|
|
10
|
+
export type Bones = Record<string, Capture>;
|
|
11
|
+
export type Animate = 'pulse' | 'shimmer' | 'solid';
|
|
12
|
+
/** Runtime look, shared by jiplak.yaml and `configureJiplak`. */
|
|
13
|
+
export interface Theme {
|
|
14
|
+
color?: string;
|
|
15
|
+
darkColor?: string;
|
|
16
|
+
shimmerColor?: string;
|
|
17
|
+
darkShimmerColor?: string;
|
|
18
|
+
animate?: Animate;
|
|
19
|
+
speed?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Pick the capture whose captured container width is nearest to `width`.
|
|
23
|
+
* Compares container to container (keys are viewport widths, and a container is
|
|
24
|
+
* usually narrower than its viewport). Nearest, not floor, so a scrollbar that
|
|
25
|
+
* shaves a few px off the live container still picks the matching capture.
|
|
26
|
+
*/
|
|
27
|
+
export declare function pickCapture(bones: Bones, width: number): Capture | undefined;
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the capture whose captured container width is nearest to `width`.
|
|
3
|
+
* Compares container to container (keys are viewport widths, and a container is
|
|
4
|
+
* usually narrower than its viewport). Nearest, not floor, so a scrollbar that
|
|
5
|
+
* shaves a few px off the live container still picks the matching capture.
|
|
6
|
+
*/
|
|
7
|
+
export function pickCapture(bones, width) {
|
|
8
|
+
let best;
|
|
9
|
+
for (const c of Object.values(bones)) {
|
|
10
|
+
if (!best || Math.abs(c.width - width) < Math.abs(best.width - width))
|
|
11
|
+
best = c;
|
|
12
|
+
}
|
|
13
|
+
return best;
|
|
14
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type Bones, type Theme } from './core.js';
|
|
3
|
+
import { snapshot, type SnapshotOptions } from './snapshot.js';
|
|
4
|
+
export type { Bone, Bones, Capture, Theme, Animate } from './core.js';
|
|
5
|
+
export type { SnapshotOptions } from './snapshot.js';
|
|
6
|
+
export { snapshot };
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
__JIPLAK_BUILD?: boolean;
|
|
10
|
+
__JIPLAK_SNAPSHOT?: typeof snapshot;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Called by the generated registry; may also be called by hand. */
|
|
14
|
+
export declare function registerBones(map: Record<string, Bones>): void;
|
|
15
|
+
/** Global look defaults; jiplak.yaml values land here through the generated registry. */
|
|
16
|
+
export declare function configureJiplak(next: Theme): void;
|
|
17
|
+
export interface SkeletonProps extends Theme {
|
|
18
|
+
/** Key used by the CLI to capture and by the runtime to look up bones. */
|
|
19
|
+
name: string;
|
|
20
|
+
loading: boolean;
|
|
21
|
+
children?: ReactNode;
|
|
22
|
+
/** Rendered instead of children while the CLI captures (mock data, no auth). */
|
|
23
|
+
fixture?: ReactNode;
|
|
24
|
+
/** Bones passed directly instead of resolved from the registry. */
|
|
25
|
+
bones?: Bones;
|
|
26
|
+
options?: SnapshotOptions;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function Skeleton({ name, loading, children, fixture, bones, options, className, ...look }: SkeletonProps): import("react").JSX.Element;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { pickCapture } from './core.js';
|
|
5
|
+
import { snapshot } from './snapshot.js';
|
|
6
|
+
export { snapshot };
|
|
7
|
+
const isBuild = typeof window !== 'undefined' && window.__JIPLAK_BUILD === true;
|
|
8
|
+
if (isBuild)
|
|
9
|
+
window.__JIPLAK_SNAPSHOT = snapshot;
|
|
10
|
+
const registry = new Map();
|
|
11
|
+
let theme = {
|
|
12
|
+
color: '#ececec',
|
|
13
|
+
darkColor: '#262626',
|
|
14
|
+
shimmerColor: '#f7f7f7',
|
|
15
|
+
darkShimmerColor: '#323232',
|
|
16
|
+
animate: 'pulse',
|
|
17
|
+
speed: '1.6s',
|
|
18
|
+
};
|
|
19
|
+
/** Called by the generated registry; may also be called by hand. */
|
|
20
|
+
export function registerBones(map) {
|
|
21
|
+
for (const [name, bones] of Object.entries(map))
|
|
22
|
+
registry.set(name, bones);
|
|
23
|
+
}
|
|
24
|
+
/** Global look defaults; jiplak.yaml values land here through the generated registry. */
|
|
25
|
+
export function configureJiplak(next) {
|
|
26
|
+
theme = { ...theme, ...next };
|
|
27
|
+
}
|
|
28
|
+
const KEYFRAMES = '@keyframes jiplak-pulse{50%{opacity:.45}}' +
|
|
29
|
+
'@keyframes jiplak-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}';
|
|
30
|
+
export function Skeleton({ name, loading, children, fixture, bones, options, className, ...look }) {
|
|
31
|
+
const ref = useRef(null);
|
|
32
|
+
const [width, setWidth] = useState(0);
|
|
33
|
+
const [dark, setDark] = useState(false);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const el = ref.current;
|
|
36
|
+
if (!el || isBuild)
|
|
37
|
+
return;
|
|
38
|
+
const ro = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
|
|
39
|
+
ro.observe(el);
|
|
40
|
+
const checkDark = () => setDark(!!el.closest('.dark'));
|
|
41
|
+
checkDark();
|
|
42
|
+
const mo = new MutationObserver(checkDark);
|
|
43
|
+
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
44
|
+
return () => {
|
|
45
|
+
ro.disconnect();
|
|
46
|
+
mo.disconnect();
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
if (isBuild) {
|
|
50
|
+
return (_jsx("div", { ref: ref, className: className, "data-jiplak": name, "data-jiplak-options": options && JSON.stringify(options), children: fixture ?? children }));
|
|
51
|
+
}
|
|
52
|
+
const t = { ...theme, ...look };
|
|
53
|
+
const source = bones ?? registry.get(name);
|
|
54
|
+
const capture = loading && source && width > 0 ? pickCapture(source, width) : undefined;
|
|
55
|
+
const color = dark ? t.darkColor : t.color;
|
|
56
|
+
const highlight = dark ? t.darkShimmerColor : t.shimmerColor;
|
|
57
|
+
return (_jsxs("div", { ref: ref, className: className, "aria-busy": loading || undefined, style: { position: 'relative', minHeight: capture?.height }, children: [_jsx("div", { style: capture ? { visibility: 'hidden' } : undefined, children: children }), capture && (_jsxs("div", { "aria-hidden": true, style: { position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }, children: [_jsx("style", { children: KEYFRAMES }), capture.bones.map(([x, y, w, h, r], i) => {
|
|
58
|
+
const style = {
|
|
59
|
+
position: 'absolute',
|
|
60
|
+
left: `${x}%`,
|
|
61
|
+
top: y,
|
|
62
|
+
// Circles keep their shape when the container is wider or narrower than at capture.
|
|
63
|
+
width: r === '50%' ? h : `${w}%`,
|
|
64
|
+
height: h,
|
|
65
|
+
borderRadius: r,
|
|
66
|
+
background: color,
|
|
67
|
+
};
|
|
68
|
+
if (t.animate === 'pulse')
|
|
69
|
+
style.animation = `jiplak-pulse ${t.speed} ease-in-out infinite`;
|
|
70
|
+
if (t.animate === 'shimmer') {
|
|
71
|
+
style.background = `linear-gradient(110deg, ${color} 30%, ${highlight} 50%, ${color} 70%)`;
|
|
72
|
+
style.backgroundSize = '200% 100%';
|
|
73
|
+
style.animation = `jiplak-shimmer ${t.speed} linear infinite`;
|
|
74
|
+
}
|
|
75
|
+
return _jsx("div", { style: style }, i);
|
|
76
|
+
})] }))] }));
|
|
77
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Capture } from './core.js';
|
|
2
|
+
/** Per-skeleton capture tuning, passed as `<Skeleton options>`. */
|
|
3
|
+
export interface SnapshotOptions {
|
|
4
|
+
/** Extra tags captured as one bone while they hold only inline content. */
|
|
5
|
+
leafTags?: string[];
|
|
6
|
+
/** CSS selectors skipped together with their descendants. */
|
|
7
|
+
exclude?: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Walk the rendered children of `root` and record every visible leaf
|
|
11
|
+
* (text block, media, form control, childless element) as a bone.
|
|
12
|
+
*/
|
|
13
|
+
export declare function snapshot(root: Element, options?: SnapshotOptions): Capture;
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const LEAF_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'th', 'label'];
|
|
2
|
+
const ATOMIC = new Set(['img', 'svg', 'video', 'canvas', 'picture', 'input', 'button', 'textarea', 'select']);
|
|
3
|
+
const pct = (v, total) => (total > 0 ? +((v / total) * 100).toFixed(3) : 0);
|
|
4
|
+
function visible(el) {
|
|
5
|
+
const s = getComputedStyle(el);
|
|
6
|
+
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
|
7
|
+
}
|
|
8
|
+
function radius(el, rect, tag) {
|
|
9
|
+
const square = Math.abs(rect.width - rect.height) < 4;
|
|
10
|
+
const s = getComputedStyle(el);
|
|
11
|
+
const r = parseFloat(s.borderTopLeftRadius) || 0;
|
|
12
|
+
if (r === 0)
|
|
13
|
+
return square && ATOMIC.has(tag) && tag !== 'input' && tag !== 'button' ? '50%' : 6;
|
|
14
|
+
if (s.borderRadius.includes('%') || r >= Math.min(rect.width, rect.height) / 2)
|
|
15
|
+
return square ? '50%' : 9999;
|
|
16
|
+
return s.borderRadius;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Walk the rendered children of `root` and record every visible leaf
|
|
20
|
+
* (text block, media, form control, childless element) as a bone.
|
|
21
|
+
*/
|
|
22
|
+
export function snapshot(root, options = {}) {
|
|
23
|
+
const base = root.getBoundingClientRect();
|
|
24
|
+
const leafTags = new Set([...LEAF_TAGS, ...(options.leafTags ?? [])]);
|
|
25
|
+
const exclude = options.exclude?.join(',');
|
|
26
|
+
const bones = [];
|
|
27
|
+
const walk = (el) => {
|
|
28
|
+
if (!visible(el) || (exclude && el.matches(exclude)))
|
|
29
|
+
return;
|
|
30
|
+
const tag = el.tagName.toLowerCase();
|
|
31
|
+
const kids = [...el.children].filter(visible);
|
|
32
|
+
const inlineOnly = kids.every(k => ['inline', 'contents'].includes(getComputedStyle(k).display));
|
|
33
|
+
if (kids.length > 0 && !ATOMIC.has(tag) && !(leafTags.has(tag) && inlineOnly)) {
|
|
34
|
+
kids.forEach(walk);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const rect = el.getBoundingClientRect();
|
|
38
|
+
if (rect.width < 1 || rect.height < 1)
|
|
39
|
+
return;
|
|
40
|
+
bones.push([
|
|
41
|
+
pct(rect.left - base.left, base.width),
|
|
42
|
+
Math.round(rect.top - base.top),
|
|
43
|
+
pct(rect.width, base.width),
|
|
44
|
+
Math.round(rect.height),
|
|
45
|
+
radius(el, rect, tag),
|
|
46
|
+
]);
|
|
47
|
+
};
|
|
48
|
+
[...root.children].forEach(walk);
|
|
49
|
+
return { width: Math.round(base.width), height: Math.round(base.height), bones };
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jiplak",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pixel-perfect skeleton loading screens, auto-captured from your real React UI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"skeleton",
|
|
7
|
+
"skeleton-loader",
|
|
8
|
+
"loading",
|
|
9
|
+
"placeholder",
|
|
10
|
+
"react",
|
|
11
|
+
"playwright",
|
|
12
|
+
"yaml",
|
|
13
|
+
"ui"
|
|
14
|
+
],
|
|
15
|
+
"author": "Wahyu Agus Arifin (https://github.com/ItPohgero)",
|
|
16
|
+
"homepage": "https://github.com/ItPohgero/jiplak#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/ItPohgero/jiplak.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/ItPohgero/jiplak/issues"
|
|
23
|
+
},
|
|
24
|
+
"funding": [
|
|
25
|
+
{
|
|
26
|
+
"type": "buymeacoffee",
|
|
27
|
+
"url": "https://buymeacoffee.com/wahyuagusarifin"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"type": "sponsor",
|
|
31
|
+
"url": "https://lokio.link"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"bin": {
|
|
37
|
+
"jiplak": "./dist/cli.js"
|
|
38
|
+
},
|
|
39
|
+
"exports": {
|
|
40
|
+
"./react": {
|
|
41
|
+
"types": "./dist/react.d.ts",
|
|
42
|
+
"import": "./dist/react.js"
|
|
43
|
+
},
|
|
44
|
+
"./package.json": "./package.json"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"README.md",
|
|
49
|
+
"README.id.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsc -p tsconfig.json",
|
|
54
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
55
|
+
"test": "bun test",
|
|
56
|
+
"prepublishOnly": "bun run build && bun test"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"playwright": "^1.56.0",
|
|
60
|
+
"yaml": "^2.8.0"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"react": ">=18"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/bun": "latest",
|
|
67
|
+
"@types/node": "^24.0.0",
|
|
68
|
+
"@types/react": "^19.0.0",
|
|
69
|
+
"react": "^19.0.0",
|
|
70
|
+
"react-dom": "^19.0.0",
|
|
71
|
+
"@types/react-dom": "^19.0.0",
|
|
72
|
+
"typescript": "^5.9.0"
|
|
73
|
+
}
|
|
74
|
+
}
|