@yojahny/wp-design-library 0.5.1 → 0.6.0
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/bin/library.mjs +1 -0
- package/entries/ada/entry.md +24 -6
- package/entries/air/entry.md +39 -10
- package/entries/ais-community-dark-depth/entry.md +44 -19
- package/entries/apple-home/entry.md +43 -10
- package/entries/apple-macbook-neo/entry.md +39 -10
- package/entries/apple-watch-ultra-4/entry.md +36 -11
- package/entries/bpco/entry.md +15 -7
- package/entries/column/entry.md +22 -9
- package/entries/cthdrl/entry.md +41 -19
- package/entries/dala-craftedbygc/entry.md +41 -16
- package/entries/dock/entry.md +36 -10
- package/entries/dub/entry.md +35 -10
- package/entries/duolingo/entry.md +42 -10
- package/entries/elevenlabs/entry.md +42 -10
- package/entries/flying-papers/entry.md +32 -10
- package/entries/gsap/entry.md +40 -10
- package/entries/letters/entry.md +40 -10
- package/entries/micro/entry.md +34 -10
- package/entries/monopo-saigon/entry.md +41 -12
- package/entries/notion/entry.md +42 -10
- package/entries/origin-financial/entry.md +42 -9
- package/entries/oryzo-ai/entry.md +46 -11
- package/entries/silencio/entry.md +6 -2
- package/entries/steep/entry.md +38 -12
- package/entries/stripe/entry.md +34 -10
- package/entries/superpower/entry.md +43 -9
- package/entries/superr/entry.md +22 -6
- package/entries/svz-weare/entry.md +15 -5
- package/entries/thoughtlab/entry.md +42 -10
- package/entries/ventriloc/entry.md +22 -6
- package/entries/vivid-and-co/entry.md +30 -5
- package/package.json +4 -1
- package/src/artifacts.mjs +31 -0
- package/src/cli/add.mjs +55 -24
- package/src/cli/index.mjs +1 -1
- package/src/cli/migrate-artifacts.mjs +92 -0
- package/src/cli/motion.mjs +19 -39
- package/src/cli/refresh.mjs +40 -10
- package/src/cli/save.mjs +1 -1
- package/src/cli/serve.mjs +27 -7
- package/src/cli/ui.mjs +1 -1
- package/src/entry.mjs +15 -11
- package/src/index/build.mjs +13 -3
- package/src/ingest/agreement.mjs +14 -1
- package/src/ingest/attach.mjs +8 -15
- package/src/ingest/save.mjs +2 -2
- package/src/mcp/tools.mjs +29 -17
- package/src/r2.mjs +95 -0
- package/src/ui/build.mjs +30 -12
- package/src/ui/templates/index.html +2 -0
- package/entries/ada/strip.webp +0 -0
- package/entries/air/captures/2/measure.json +0 -325
- package/entries/air/captures/2/strip.webp +0 -0
- package/entries/air/strip.webp +0 -0
- package/entries/ais-community-dark-depth/strip.webp +0 -0
- package/entries/apple-home/captures/2/measure.json +0 -91
- package/entries/apple-home/captures/2/strip.webp +0 -0
- package/entries/apple-home/strip.webp +0 -0
- package/entries/apple-macbook-neo/captures/2/measure.json +0 -570
- package/entries/apple-macbook-neo/captures/2/strip.webp +0 -0
- package/entries/apple-macbook-neo/strip.webp +0 -0
- package/entries/apple-watch-ultra-4/measure.json +0 -481
- package/entries/apple-watch-ultra-4/strip.webp +0 -0
- package/entries/bpco/captures/2/strip.webp +0 -0
- package/entries/bpco/captures/3/strip.webp +0 -0
- package/entries/bpco/strip.webp +0 -0
- package/entries/column/captures/2/measure.json +0 -996
- package/entries/column/captures/2/strip.webp +0 -0
- package/entries/column/strip.webp +0 -0
- package/entries/cthdrl/captures/2/measure.json +0 -251
- package/entries/cthdrl/captures/2/strip.webp +0 -0
- package/entries/cthdrl/strip.webp +0 -0
- package/entries/dala-craftedbygc/strip.webp +0 -0
- package/entries/dock/captures/2/measure.json +0 -206
- package/entries/dock/captures/2/strip.webp +0 -0
- package/entries/dock/strip.webp +0 -0
- package/entries/dub/captures/2/measure.json +0 -37
- package/entries/dub/captures/2/strip.webp +0 -0
- package/entries/dub/strip.webp +0 -0
- package/entries/duolingo/captures/2/measure.json +0 -138
- package/entries/duolingo/captures/2/strip.webp +0 -0
- package/entries/duolingo/strip.webp +0 -0
- package/entries/elevenlabs/captures/2/measure.json +0 -160
- package/entries/elevenlabs/captures/2/strip.webp +0 -0
- package/entries/elevenlabs/strip.webp +0 -0
- package/entries/flying-papers/captures/2/measure.json +0 -291
- package/entries/flying-papers/captures/2/strip.webp +0 -0
- package/entries/flying-papers/strip.webp +0 -0
- package/entries/gsap/captures/2/measure.json +0 -120
- package/entries/gsap/captures/2/strip.webp +0 -0
- package/entries/gsap/strip.webp +0 -0
- package/entries/letters/captures/2/measure.json +0 -20
- package/entries/letters/captures/2/strip.webp +0 -0
- package/entries/letters/strip.webp +0 -0
- package/entries/micro/captures/2/measure.json +0 -852
- package/entries/micro/captures/2/strip.webp +0 -0
- package/entries/micro/strip.webp +0 -0
- package/entries/monopo-saigon/captures/2/measure.json +0 -145
- package/entries/monopo-saigon/captures/2/strip.webp +0 -0
- package/entries/monopo-saigon/strip.webp +0 -0
- package/entries/notion/captures/2/measure.json +0 -338
- package/entries/notion/captures/2/strip.webp +0 -0
- package/entries/notion/strip.webp +0 -0
- package/entries/origin-financial/captures/2/measure.json +0 -169
- package/entries/origin-financial/captures/2/strip.webp +0 -0
- package/entries/origin-financial/strip.webp +0 -0
- package/entries/oryzo-ai/captures/2/measure.json +0 -39
- package/entries/oryzo-ai/captures/2/strip.webp +0 -0
- package/entries/oryzo-ai/strip.webp +0 -0
- package/entries/silencio/captures/2/strip.webp +0 -0
- package/entries/silencio/strip.webp +0 -0
- package/entries/steep/captures/2/measure.json +0 -20
- package/entries/steep/captures/2/strip.webp +0 -0
- package/entries/steep/strip.webp +0 -0
- package/entries/stripe/captures/2/measure.json +0 -892
- package/entries/stripe/captures/2/strip.webp +0 -0
- package/entries/stripe/strip.webp +0 -0
- package/entries/superpower/captures/2/measure.json +0 -179
- package/entries/superpower/captures/2/strip.webp +0 -0
- package/entries/superpower/strip.webp +0 -0
- package/entries/superr/strip.webp +0 -0
- package/entries/svz-weare/captures/2/measure.json +0 -129
- package/entries/svz-weare/captures/2/strip.webp +0 -0
- package/entries/svz-weare/strip.webp +0 -0
- package/entries/thoughtlab/captures/2/measure.json +0 -93
- package/entries/thoughtlab/captures/2/strip.webp +0 -0
- package/entries/thoughtlab/strip.webp +0 -0
- package/entries/ventriloc/strip.webp +0 -0
- package/entries/vivid-and-co/strip.webp +0 -0
package/entries/superr/entry.md
CHANGED
|
@@ -5,7 +5,9 @@ tier: inspiration
|
|
|
5
5
|
source:
|
|
6
6
|
kind: public-site
|
|
7
7
|
url: https://www.superr.ai/
|
|
8
|
-
license:
|
|
8
|
+
license: >-
|
|
9
|
+
Captured from a Refero-hosted recording and the live site; description only, no redistribution of the
|
|
10
|
+
source.
|
|
9
11
|
captured:
|
|
10
12
|
method: frames
|
|
11
13
|
at: '2026-09-16'
|
|
@@ -14,14 +16,23 @@ captured:
|
|
|
14
16
|
- method: frames
|
|
15
17
|
input: /tmp/superr-refero.mp4
|
|
16
18
|
artifacts:
|
|
17
|
-
- strip.webp
|
|
19
|
+
- key: entries/superr/1/strip.webp
|
|
20
|
+
bytes: 274670
|
|
21
|
+
sha256: aba7bdda980d83f1996a50f7a65cbd370aa0466bc802941c347c873b0c7371f3
|
|
18
22
|
frames: 36
|
|
19
23
|
duration_ms: 20217
|
|
20
24
|
media:
|
|
21
25
|
frames: 36
|
|
22
26
|
duration_ms: 20217
|
|
23
|
-
roles:
|
|
24
|
-
|
|
27
|
+
roles:
|
|
28
|
+
- hero
|
|
29
|
+
- explainer
|
|
30
|
+
- feature
|
|
31
|
+
feel:
|
|
32
|
+
- light
|
|
33
|
+
- playful
|
|
34
|
+
- warm
|
|
35
|
+
- premium
|
|
25
36
|
palette:
|
|
26
37
|
canvas: '#fdfbf9'
|
|
27
38
|
ink: '#171717'
|
|
@@ -30,8 +41,13 @@ type:
|
|
|
30
41
|
display: gelica, "gelica Fallback", Georgia, serif
|
|
31
42
|
body: gelica, "gelica Fallback", Georgia, serif
|
|
32
43
|
motion:
|
|
33
|
-
devices:
|
|
34
|
-
|
|
44
|
+
devices:
|
|
45
|
+
- reveal
|
|
46
|
+
- pan
|
|
47
|
+
notes: >-
|
|
48
|
+
Eyeballed from 36 sampled frames, not measured. A large notebook dominates the hero, then reveals
|
|
49
|
+
handwritten labels, stickers, and an opened-book application scene. No scroll behavior or individual
|
|
50
|
+
triggers are visible.
|
|
35
51
|
ported_from: null
|
|
36
52
|
---
|
|
37
53
|
|
|
@@ -14,16 +14,26 @@ captured:
|
|
|
14
14
|
- method: frames
|
|
15
15
|
input: /home/yojahny/Desktop/inspiration/weare.mp4
|
|
16
16
|
artifacts:
|
|
17
|
-
- strip.webp
|
|
17
|
+
- key: entries/svz-weare/1/strip.webp
|
|
18
|
+
bytes: 219198
|
|
19
|
+
sha256: 98c48537b768405a47817cbf398892ce026d1202ff50d833916b26e780ba62d7
|
|
18
20
|
frames: 36
|
|
19
21
|
duration_ms: 21900
|
|
20
22
|
- method: both
|
|
21
23
|
input: https://www.svz.io/
|
|
22
24
|
artifacts:
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
25
|
+
- key: entries/svz-weare/2/strip.webp
|
|
26
|
+
bytes: 163242
|
|
27
|
+
sha256: fc794258d46dc28bd7d905f19380c2941775996bcb48f7f49953028b3b361ec4
|
|
28
|
+
- key: entries/svz-weare/2/measure.json
|
|
29
|
+
bytes: 2499
|
|
30
|
+
sha256: c2291e55af1be7426047b2a55188db7fcbd951b98af439aa8ed26161b9fca1e9
|
|
31
|
+
- key: entries/svz-weare/2/screen-1440.png
|
|
32
|
+
bytes: 59470
|
|
33
|
+
sha256: 14abebdc6411792eff6afd1e5692693f857d3ae07363c4ee29a1f24bb8b83efa
|
|
34
|
+
- key: entries/svz-weare/2/screen-390.png
|
|
35
|
+
bytes: 20897
|
|
36
|
+
sha256: 54228ad1733da9a9b403e703e00906942ea2acdb768a092d7d8a42af84286c72
|
|
27
37
|
media:
|
|
28
38
|
frames: 36
|
|
29
39
|
duration_ms: 21900
|
|
@@ -5,7 +5,9 @@ tier: inspiration
|
|
|
5
5
|
source:
|
|
6
6
|
kind: public-site
|
|
7
7
|
url: https://www.thoughtlab.com/
|
|
8
|
-
license:
|
|
8
|
+
license: >-
|
|
9
|
+
Captured from a Refero-hosted recording and the live site; description only, no redistribution of the
|
|
10
|
+
source.
|
|
9
11
|
captured:
|
|
10
12
|
method: both
|
|
11
13
|
at: '2026-09-16'
|
|
@@ -14,21 +16,42 @@ captured:
|
|
|
14
16
|
- method: frames
|
|
15
17
|
input: /tmp/thoughtlab-refero.mp4
|
|
16
18
|
artifacts:
|
|
17
|
-
- strip.webp
|
|
19
|
+
- key: entries/thoughtlab/1/strip.webp
|
|
20
|
+
bytes: 260958
|
|
21
|
+
sha256: 8d8e191dd3fecd18a394c54da3a275c90c78bffca607c87df5f1eca7dc052061
|
|
18
22
|
frames: 36
|
|
19
23
|
duration_ms: 24533
|
|
20
24
|
- method: both
|
|
21
25
|
input: https://www.thoughtlab.com/
|
|
22
26
|
artifacts:
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
+
- key: entries/thoughtlab/2/strip.webp
|
|
28
|
+
bytes: 230142
|
|
29
|
+
sha256: 844c95156c28937ed5709b14bb47b28ec6d26e35a0703c8f39a5e93f5df7a82a
|
|
30
|
+
- key: entries/thoughtlab/2/measure.json
|
|
31
|
+
bytes: 1669
|
|
32
|
+
sha256: 033fcecae1af22f5c822f74fc3beff60c7fade187413fd0ad04e8dea2b7e42a4
|
|
33
|
+
- key: entries/thoughtlab/2/screen-1440.png
|
|
34
|
+
bytes: 145595
|
|
35
|
+
sha256: 1c36b785010a7de3d012b553a7029e5c5b7a57596edde81edf100323c152dc7f
|
|
36
|
+
- key: entries/thoughtlab/2/screen-390.png
|
|
37
|
+
bytes: 77652
|
|
38
|
+
sha256: 3d8dd9d72c2ae7a43cee2d62b5659a73b7d19f308297c4eed2fae551fa454310
|
|
27
39
|
media:
|
|
28
40
|
frames: 36
|
|
29
41
|
duration_ms: 24533
|
|
30
|
-
roles:
|
|
31
|
-
|
|
42
|
+
roles:
|
|
43
|
+
- hero
|
|
44
|
+
- feature
|
|
45
|
+
- capability
|
|
46
|
+
- proof
|
|
47
|
+
- closing
|
|
48
|
+
- footer
|
|
49
|
+
feel:
|
|
50
|
+
- dark
|
|
51
|
+
- premium
|
|
52
|
+
- editorial
|
|
53
|
+
- depth
|
|
54
|
+
- cold
|
|
32
55
|
palette:
|
|
33
56
|
canvas: '#000000'
|
|
34
57
|
ink: '#cccccc'
|
|
@@ -37,8 +60,17 @@ type:
|
|
|
37
60
|
display: sui
|
|
38
61
|
body: sui
|
|
39
62
|
motion:
|
|
40
|
-
devices:
|
|
41
|
-
|
|
63
|
+
devices:
|
|
64
|
+
- reveal
|
|
65
|
+
- pan
|
|
66
|
+
- drift
|
|
67
|
+
- wipe
|
|
68
|
+
- tabs
|
|
69
|
+
notes: >-
|
|
70
|
+
The supplied recording moves through a sequence of project names while irregular glass-like image portals
|
|
71
|
+
change their internal imagery and edge light. Large condensed labels reveal beside each portal, then the
|
|
72
|
+
composition wipes to the next case. The live capture confirms the hero portal and fixed header, but does
|
|
73
|
+
not expose CSS animation records or individual input triggers.
|
|
42
74
|
ported_from: null
|
|
43
75
|
---
|
|
44
76
|
|
|
@@ -5,7 +5,9 @@ tier: inspiration
|
|
|
5
5
|
source:
|
|
6
6
|
kind: public-site
|
|
7
7
|
url: https://ventriloc.ca/en
|
|
8
|
-
license:
|
|
8
|
+
license: >-
|
|
9
|
+
Captured from a Refero-hosted recording and the live site; description only, no redistribution of the
|
|
10
|
+
source.
|
|
9
11
|
captured:
|
|
10
12
|
method: frames
|
|
11
13
|
at: '2026-09-16'
|
|
@@ -14,14 +16,23 @@ captured:
|
|
|
14
16
|
- method: frames
|
|
15
17
|
input: /tmp/ventriloc-refero.mp4
|
|
16
18
|
artifacts:
|
|
17
|
-
- strip.webp
|
|
19
|
+
- key: entries/ventriloc/1/strip.webp
|
|
20
|
+
bytes: 225040
|
|
21
|
+
sha256: 7f9e5830cb7fbd754cc82132dba195e7fccec7c6893f5c7e6cf9200712e5de90
|
|
18
22
|
frames: 36
|
|
19
23
|
duration_ms: 6567
|
|
20
24
|
media:
|
|
21
25
|
frames: 36
|
|
22
26
|
duration_ms: 6567
|
|
23
|
-
roles:
|
|
24
|
-
|
|
27
|
+
roles:
|
|
28
|
+
- hero
|
|
29
|
+
- proof
|
|
30
|
+
- feature
|
|
31
|
+
feel:
|
|
32
|
+
- light
|
|
33
|
+
- premium
|
|
34
|
+
- saas
|
|
35
|
+
- minimal
|
|
25
36
|
palette:
|
|
26
37
|
canvas: '#f5f5f5'
|
|
27
38
|
ink: '#202020'
|
|
@@ -30,8 +41,13 @@ type:
|
|
|
30
41
|
display: PolySans, sans-serif
|
|
31
42
|
body: Inter, sans-serif
|
|
32
43
|
motion:
|
|
33
|
-
devices:
|
|
34
|
-
|
|
44
|
+
devices:
|
|
45
|
+
- reveal
|
|
46
|
+
- pan
|
|
47
|
+
notes: >-
|
|
48
|
+
Eyeballed from 36 sampled frames, not measured. Dashboard cards and chart lines animate into the right
|
|
49
|
+
side of the hero while the headline stays anchored on the left; later sampled frames show the metric
|
|
50
|
+
states changing. Individual triggers are not visible.
|
|
35
51
|
ported_from: null
|
|
36
52
|
---
|
|
37
53
|
|
|
@@ -14,14 +14,28 @@ captured:
|
|
|
14
14
|
- method: frames
|
|
15
15
|
input: /home/yojahny/Desktop/inspiration/evivid.mp4
|
|
16
16
|
artifacts:
|
|
17
|
-
- strip.webp
|
|
17
|
+
- key: entries/vivid-and-co/1/strip.webp
|
|
18
|
+
bytes: 190158
|
|
19
|
+
sha256: 881005f80102d0e249067ce9aa89a61c6afaf576f4d5867687f2209081a3caa7
|
|
18
20
|
frames: 36
|
|
19
21
|
duration_ms: 36833
|
|
20
22
|
media:
|
|
21
23
|
frames: 36
|
|
22
24
|
duration_ms: 36833
|
|
23
|
-
roles:
|
|
24
|
-
|
|
25
|
+
roles:
|
|
26
|
+
- hero
|
|
27
|
+
- explainer
|
|
28
|
+
- capability
|
|
29
|
+
- proof
|
|
30
|
+
- closing
|
|
31
|
+
- footer
|
|
32
|
+
feel:
|
|
33
|
+
- dark
|
|
34
|
+
- premium
|
|
35
|
+
- editorial
|
|
36
|
+
- minimal
|
|
37
|
+
- depth
|
|
38
|
+
- cold
|
|
25
39
|
palette:
|
|
26
40
|
canvas: '#101010'
|
|
27
41
|
ink: '#fffdf9'
|
|
@@ -30,8 +44,19 @@ type:
|
|
|
30
44
|
display: '"Neue Montreal"'
|
|
31
45
|
body: '"Neue Montreal", sans-serif'
|
|
32
46
|
motion:
|
|
33
|
-
devices:
|
|
34
|
-
|
|
47
|
+
devices:
|
|
48
|
+
- reveal
|
|
49
|
+
- pan
|
|
50
|
+
- drift
|
|
51
|
+
- stagger
|
|
52
|
+
- wipe
|
|
53
|
+
notes: >-
|
|
54
|
+
The supplied recording shows a refractive block assembly slowly rotating and drifting through the opening
|
|
55
|
+
statements, while large type changes in staged fragments around it. Each chapter uses a restrained fade or
|
|
56
|
+
wipe into a new text composition; the prismatic light field remains as a connecting layer. The live
|
|
57
|
+
capture records a long sequence of SVG animations across the page. It confirms that the chromatic object
|
|
58
|
+
and surrounding graphic treatment are animated, but this capture does not establish individual interaction
|
|
59
|
+
triggers.
|
|
35
60
|
ported_from: null
|
|
36
61
|
---
|
|
37
62
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yojahny/wp-design-library",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Design reference corpus and MCP server for claude-wp-builder",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -55,10 +55,12 @@
|
|
|
55
55
|
"docker-compose.yml",
|
|
56
56
|
"docker/",
|
|
57
57
|
"entries/",
|
|
58
|
+
"src/artifacts.mjs",
|
|
58
59
|
"src/cli/add.mjs",
|
|
59
60
|
"src/cli/check.mjs",
|
|
60
61
|
"src/cli/export.mjs",
|
|
61
62
|
"src/cli/index.mjs",
|
|
63
|
+
"src/cli/migrate-artifacts.mjs",
|
|
62
64
|
"src/cli/motion.mjs",
|
|
63
65
|
"src/cli/refresh.mjs",
|
|
64
66
|
"src/cli/save.mjs",
|
|
@@ -70,6 +72,7 @@
|
|
|
70
72
|
"src/mcp/",
|
|
71
73
|
"src/motion.mjs",
|
|
72
74
|
"src/paths.mjs",
|
|
75
|
+
"src/r2.mjs",
|
|
73
76
|
"src/ui/",
|
|
74
77
|
"src/vocab.mjs",
|
|
75
78
|
"vocab.yaml",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The seam every reader goes through. Callers ask for a file; whether it came from the cache or
|
|
2
|
+
// from R2 is not their business. The cache mirrors motion's .library/media so there is one place
|
|
3
|
+
// on disk where fetched bytes live.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { isArtifactKey, objectUrl, readConfig, sha256 } from './r2.mjs';
|
|
7
|
+
|
|
8
|
+
export function cachePath(p, key) {
|
|
9
|
+
if (!isArtifactKey(key)) throw new Error(`invalid artifact key: ${key}`);
|
|
10
|
+
return path.join(p.data, '.library', 'media', key);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// config is resolved lazily, inside the cache MISS, and through readConfig — which asks for
|
|
14
|
+
// R2_PUBLIC_BASE_URL and nothing else. As a default PARAMETER (`config = r2Config()`) it evaluated
|
|
15
|
+
// on entry, before the cache-hit branch below, so a warm cache was unusable without credentials
|
|
16
|
+
// and a read-only host had to hold R2_SECRET_ACCESS_KEY to render a thumbnail. Same bug class
|
|
17
|
+
// migrate-artifacts.mjs documents at length; the fix had not reached this file.
|
|
18
|
+
export async function artifactFile(p, artifact, { config, fetcher = fetch } = {}) {
|
|
19
|
+
const file = cachePath(p, artifact.key);
|
|
20
|
+
if (fs.existsSync(file) && fs.statSync(file).size === artifact.bytes) return file;
|
|
21
|
+
config ??= readConfig();
|
|
22
|
+
const url = objectUrl(config, artifact.key);
|
|
23
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(30000) });
|
|
24
|
+
if (!response.ok) throw new Error(`${artifact.key}: R2 returned HTTP ${response.status}`);
|
|
25
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
26
|
+
if (body.length !== artifact.bytes) throw new Error(`${artifact.key}: expected ${artifact.bytes} bytes, got ${body.length}`);
|
|
27
|
+
if (sha256(body) !== artifact.sha256) throw new Error(`${artifact.key}: digest does not match what the entry records`);
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
|
+
fs.writeFileSync(file, body);
|
|
30
|
+
return file;
|
|
31
|
+
}
|
package/src/cli/add.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { attachCapture } from '../ingest/attach.mjs';
|
|
|
8
8
|
import { measure } from '../ingest/measure.mjs';
|
|
9
9
|
import { loadVocab, resolveTerm } from '../vocab.mjs';
|
|
10
10
|
import { parseEntry } from '../entry.mjs';
|
|
11
|
+
import { artifactKey, putAndVerify, r2Config, s3Upload } from '../r2.mjs';
|
|
11
12
|
|
|
12
13
|
function opt(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
|
|
13
14
|
|
|
@@ -40,9 +41,9 @@ async function stripFromFrames(framePaths, out) {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
// URL/file:// input: measure the live page with system Chrome instead of sampling
|
|
43
|
-
// frames from a recording. Scratch-dir-first, same as the frame path:
|
|
44
|
-
//
|
|
45
|
-
async function addFromUrl({ p, input, slug, title, source, dir, record, force, attach }) {
|
|
44
|
+
// frames from a recording. Scratch-dir-first, same as the frame path: nothing under
|
|
45
|
+
// entries/ is created OR destroyed until every byte is captured AND uploaded.
|
|
46
|
+
async function addFromUrl({ p, input, slug, title, source, dir, record, force, attach, config, upload, fetcher }) {
|
|
46
47
|
const fs = await import('node:fs');
|
|
47
48
|
const os = await import('node:os');
|
|
48
49
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'wpdl-add-'));
|
|
@@ -82,27 +83,43 @@ async function addFromUrl({ p, input, slug, title, source, dir, record, force, a
|
|
|
82
83
|
await stripFromFrames(result.frames, scratchStrip);
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// Upload BEFORE the wipe, straight out of scratch. putAndVerify proves the object is publicly
|
|
87
|
+
// readable and byte-identical, so an entry can never name a key nobody can fetch — but it is a
|
|
88
|
+
// network round trip, and it throws for reasons this process does not control (absent
|
|
89
|
+
// credentials, a reset connection, a digest mismatch). Uploading after the wipe made every one
|
|
90
|
+
// of those a data-loss bug: `--force` deleted the entry and put nothing in its place.
|
|
91
|
+
// config/upload/fetcher are resolved lazily, here, and not as addEntry's defaults, so a refusal
|
|
92
|
+
// earlier in the flow never needs R2 credentials.
|
|
93
|
+
const names = ['strip.webp'];
|
|
94
|
+
if (result.ok) names.push('measure.json', 'screen-1440.png', 'screen-390.png');
|
|
95
|
+
const types = { '.webp': 'image/webp', '.png': 'image/png', '.json': 'application/json' };
|
|
96
|
+
const artifacts = [];
|
|
97
|
+
try {
|
|
98
|
+
config ??= r2Config();
|
|
99
|
+
if (!upload) upload = await s3Upload(config);
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
const body = fs.readFileSync(path.join(scratch, name));
|
|
102
|
+
const key = artifactKey(slug, index, name);
|
|
103
|
+
artifacts.push(await putAndVerify({ config, key, body, contentType: types[path.extname(name)], upload, fetcher }));
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
// Gated on index, not force directly: index===1 is the same decision the attach-vs-recreate
|
|
86
111
|
// branch below acts on, so the two can never disagree — a regression that breaks the line
|
|
87
112
|
// above (force stops forcing index to 1) also stops this wipe from firing, instead of wiping
|
|
88
113
|
// a directory out from under a capture the buggy code is about to attach to. It fires only
|
|
89
|
-
// here, below the compositing, so nothing
|
|
90
|
-
//
|
|
114
|
+
// here, below the compositing AND below the upload, so nothing that can fail stands between
|
|
115
|
+
// the wipe and a written entry; what follows is copies out of scratch.
|
|
91
116
|
if (index === 1) fs.rmSync(dir, { recursive: true, force: true });
|
|
92
117
|
const outDir = captureDir(dir, index);
|
|
93
118
|
fs.mkdirSync(outDir, { recursive: true });
|
|
94
119
|
const strip = path.join(outDir, 'strip.webp');
|
|
95
|
-
fs.copyFileSync(
|
|
96
|
-
if (result.ok) {
|
|
97
|
-
for (const f of ['measure.json', 'screen-1440.png', 'screen-390.png']) {
|
|
98
|
-
fs.copyFileSync(path.join(scratch, f), path.join(outDir, f));
|
|
99
|
-
}
|
|
100
|
-
}
|
|
120
|
+
for (const name of names) fs.copyFileSync(path.join(scratch, name), path.join(outDir, name));
|
|
101
121
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
102
122
|
|
|
103
|
-
const artifactNames = ['strip.webp'];
|
|
104
|
-
if (result.ok) artifactNames.push('measure.json', 'screen-1440.png', 'screen-390.png');
|
|
105
|
-
const artifacts = artifactNames.map((f) => path.relative(dir, path.join(outDir, f)));
|
|
106
123
|
const capture = { method: captured.method, input, artifacts };
|
|
107
124
|
|
|
108
125
|
if (index > 1) {
|
|
@@ -114,7 +131,7 @@ async function addFromUrl({ p, input, slug, title, source, dir, record, force, a
|
|
|
114
131
|
return { dir: d.dir, strip, blanks: d.blanks, measured: result.ok };
|
|
115
132
|
}
|
|
116
133
|
|
|
117
|
-
export async function addEntry({ input, slug, title, source, force = false, frames, record = false, attach = false }) {
|
|
134
|
+
export async function addEntry({ input, slug, title, source, force = false, frames, record = false, attach = false, config, upload, fetcher = fetch }) {
|
|
118
135
|
const p = paths();
|
|
119
136
|
if (!input || !slug || !title || !source?.kind || !source?.license) throw new Error('add: input, slug, title, --source and --license are required');
|
|
120
137
|
// Refused here (draft time), not first at save: resolveTerm is the single vocab gate
|
|
@@ -123,14 +140,14 @@ export async function addEntry({ input, slug, title, source, force = false, fram
|
|
|
123
140
|
return { refused: `source.kind: unknown source term "${source.kind}"` };
|
|
124
141
|
}
|
|
125
142
|
const dir = path.join(p.entries, slug);
|
|
126
|
-
const { mkdirSync, mkdtempSync, renameSync, copyFileSync, unlinkSync, rmSync } = await import('node:fs');
|
|
143
|
+
const { mkdirSync, mkdtempSync, renameSync, copyFileSync, unlinkSync, rmSync, readFileSync } = await import('node:fs');
|
|
127
144
|
const os = await import('node:os');
|
|
128
145
|
const exists = existsSync(path.join(dir, 'entry.md'));
|
|
129
146
|
if (exists && !force && !attach) {
|
|
130
147
|
return { refused: `entry ${slug} exists; pass attach to add a capture, or force to overwrite` };
|
|
131
148
|
}
|
|
132
149
|
if (URL_RE.test(input)) {
|
|
133
|
-
return addFromUrl({ p, input, slug, title, source, dir, record, force, attach });
|
|
150
|
+
return addFromUrl({ p, input, slug, title, source, dir, record, force, attach, config, upload, fetcher });
|
|
134
151
|
}
|
|
135
152
|
// Sample into a scratch dir first: a refusal must leave nothing under entries/.
|
|
136
153
|
const scratch = mkdtempSync(path.join(os.tmpdir(), 'wpdl-add-'));
|
|
@@ -146,13 +163,28 @@ export async function addEntry({ input, slug, title, source, force = false, fram
|
|
|
146
163
|
// force always wipes and recreates as capture 1, exactly as it does today, regardless of
|
|
147
164
|
// attach; only a plain attach (no force) merges onto the existing capture list.
|
|
148
165
|
const index = (!force && attach) ? nextCaptureIndex(dir) : 1;
|
|
166
|
+
// Same upload-before-anything-is-destroyed contract as the URL path: the bytes are already in
|
|
167
|
+
// scratch, and putAndVerify's network round trip must not stand between the wipe and a written
|
|
168
|
+
// entry. Resolved lazily, after every refusal above, so those never need R2 credentials.
|
|
169
|
+
let stripArtifact;
|
|
170
|
+
try {
|
|
171
|
+
config ??= r2Config();
|
|
172
|
+
if (!upload) upload = await s3Upload(config);
|
|
173
|
+
stripArtifact = await putAndVerify({
|
|
174
|
+
config, key: artifactKey(slug, index, 'strip.webp'), body: readFileSync(scratchStrip),
|
|
175
|
+
contentType: 'image/webp', upload, fetcher,
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
|
|
149
182
|
// Gated on index, not force directly: index===1 is the same decision the attach-vs-recreate
|
|
150
183
|
// branch below acts on, so the two can never disagree — a regression that breaks the line
|
|
151
184
|
// above (force stops forcing index to 1) also stops this wipe from firing, instead of wiping
|
|
152
185
|
// a directory out from under a capture the buggy code is about to attach to. The wipe happens
|
|
153
|
-
// only here — after sampleFrames has
|
|
154
|
-
//
|
|
155
|
-
// leave nothing in its place.
|
|
186
|
+
// only here — after sampleFrames has succeeded into scratch AND after the upload has been
|
|
187
|
+
// verified — so it can never destroy an existing entry and leave nothing in its place.
|
|
156
188
|
if (index === 1) rmSync(dir, { recursive: true, force: true });
|
|
157
189
|
const outDir = captureDir(dir, index);
|
|
158
190
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -165,9 +197,8 @@ export async function addEntry({ input, slug, title, source, force = false, fram
|
|
|
165
197
|
}
|
|
166
198
|
rmSync(scratch, { recursive: true, force: true });
|
|
167
199
|
|
|
168
|
-
const rel = path.relative(dir, strip);
|
|
169
200
|
const capture = {
|
|
170
|
-
method: 'frames', input, artifacts: [
|
|
201
|
+
method: 'frames', input, artifacts: [stripArtifact],
|
|
171
202
|
frames: media.frames, duration_ms: media.durationMs,
|
|
172
203
|
};
|
|
173
204
|
const mediaFields = { frames: media.frames, duration_ms: media.durationMs };
|
|
@@ -183,7 +214,7 @@ export async function addEntry({ input, slug, title, source, force = false, fram
|
|
|
183
214
|
return { dir: d.dir, strip, blanks: d.blanks, frames: media.frames };
|
|
184
215
|
}
|
|
185
216
|
|
|
186
|
-
export async function addInbox({ source }) {
|
|
217
|
+
export async function addInbox({ source, config, upload, fetcher }) {
|
|
187
218
|
const p = paths();
|
|
188
219
|
const fs = await import('node:fs');
|
|
189
220
|
const inbox = path.join(p.data, 'inbox');
|
|
@@ -204,7 +235,7 @@ export async function addInbox({ source }) {
|
|
|
204
235
|
const kind = meta.kind ?? source?.kind, license = meta.license ?? source?.license;
|
|
205
236
|
if (!kind || !license) { out.push({ file, slug, status: 'refused', reason: 'no source kind/license: add a <file>.json sidecar or pass --source and --license' }); continue; }
|
|
206
237
|
try {
|
|
207
|
-
const r = await addEntry({ input: full, slug, title: meta.title ?? slug.replace(/-/g, ' '), source: { kind, url: meta.url ?? source?.url, license } });
|
|
238
|
+
const r = await addEntry({ input: full, slug, title: meta.title ?? slug.replace(/-/g, ' '), source: { kind, url: meta.url ?? source?.url, license }, config, upload, fetcher });
|
|
208
239
|
out.push(r.refused ? { file, slug, status: 'refused', reason: r.refused } : { file, slug, status: 'drafted', frames: r.frames });
|
|
209
240
|
} catch (e) { out.push({ file, slug, status: 'refused', reason: e.message }); }
|
|
210
241
|
}
|
package/src/cli/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { buildIndex } from '../index/build.mjs';
|
|
|
4
4
|
|
|
5
5
|
export async function run() {
|
|
6
6
|
const p = paths();
|
|
7
|
-
const r = await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models });
|
|
7
|
+
const r = await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models, p });
|
|
8
8
|
for (const m of r.refused) process.stderr.write(`refused ${m}\n`);
|
|
9
9
|
process.stdout.write(`indexed ${r.entries} entries, refused: ${r.refused.length}, arms: ${r.arms.join(',')}\n`);
|
|
10
10
|
for (const s of r.skipped) process.stderr.write(`embeddings: skipped (${s.reason})\n`);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// One-way move. Nothing is recomputed: agreement blocks, observed colours and captured.at record
|
|
2
|
+
// what was true at capture time, and re-deriving them to match today's files would rewrite history
|
|
3
|
+
// to fit the present. This only changes where the bytes live.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
import { paths } from '../paths.mjs';
|
|
8
|
+
import { listEntries, parseEntry } from '../entry.mjs';
|
|
9
|
+
import { artifactKey, putAndVerify, r2Config, s3Upload, sha256 } from '../r2.mjs';
|
|
10
|
+
|
|
11
|
+
const TYPES = { '.webp': 'image/webp', '.png': 'image/png', '.json': 'application/json' };
|
|
12
|
+
|
|
13
|
+
// config is resolved lazily, only once we know a real upload is about to happen -- the same
|
|
14
|
+
// contract add.mjs already established (`config ??= r2Config()`, "so those never need R2
|
|
15
|
+
// credentials"). A `config = r2Config()` default *parameter* would evaluate eagerly on every
|
|
16
|
+
// call, --dry-run included, since JS runs a default expression whenever the argument is
|
|
17
|
+
// omitted; that would make a dry run impossible without credentials, defeating its purpose
|
|
18
|
+
// as a no-credentials preflight.
|
|
19
|
+
export async function migrateArtifacts({ p = paths(), config, upload, fetcher = fetch, dryRun = false } = {}) {
|
|
20
|
+
if (!dryRun) config ??= r2Config();
|
|
21
|
+
if (!upload && !dryRun) upload = await s3Upload(config);
|
|
22
|
+
// `records` carries every {key, bytes, sha256} the run produced, in both modes. Counts alone
|
|
23
|
+
// could not be diffed, which is the one thing a preflight is for -- and they left the dry run's
|
|
24
|
+
// digest unobservable, so nothing could tell a real hash from a placeholder.
|
|
25
|
+
const report = { entries: 0, uploaded: 0, skipped: 0, failed: [], records: [] };
|
|
26
|
+
for (const dir of listEntries(p.entries)) {
|
|
27
|
+
const entry = parseEntry(dir);
|
|
28
|
+
const slug = path.basename(dir);
|
|
29
|
+
// An entry written before captures[] existed has no field to read at all -- attach.mjs's
|
|
30
|
+
// existingCaptureFrom used to synthesize this on the fly ("migration writes them" is the
|
|
31
|
+
// comment left in its place), so this is the last point capture 1 can be recovered instead
|
|
32
|
+
// of leaving the entry permanently invalid. Only what is actually recorded is used: method
|
|
33
|
+
// (captured.method) and frames/duration_ms (media), if present. `input` is deliberately
|
|
34
|
+
// never invented -- source.url is a fair guess for a URL-sourced entry and actively wrong
|
|
35
|
+
// for a local-file one (media.frames/duration_ms without a recorded path), and validateEntry
|
|
36
|
+
// never reads capture.input, so omitting it honestly signals "not recorded".
|
|
37
|
+
const captured = entry.fm.captured ?? (entry.fm.captured = {});
|
|
38
|
+
if (captured.captures == null) {
|
|
39
|
+
const method = captured.method;
|
|
40
|
+
const artifacts = ['strip.webp'];
|
|
41
|
+
if (method === 'measured' || method === 'both') artifacts.push('measure.json', 'screen-1440.png', 'screen-390.png');
|
|
42
|
+
const capture = { method, artifacts };
|
|
43
|
+
if (entry.fm.media?.frames != null) capture.frames = entry.fm.media.frames;
|
|
44
|
+
if (entry.fm.media?.duration_ms != null) capture.duration_ms = entry.fm.media.duration_ms;
|
|
45
|
+
captured.captures = [capture];
|
|
46
|
+
}
|
|
47
|
+
const captures = captured.captures;
|
|
48
|
+
report.entries++;
|
|
49
|
+
try {
|
|
50
|
+
for (const [i, capture] of captures.entries()) {
|
|
51
|
+
const migrated = [];
|
|
52
|
+
for (const artifact of capture.artifacts ?? []) {
|
|
53
|
+
if (artifact && typeof artifact === 'object' && artifact.key) { migrated.push(artifact); report.skipped++; continue; }
|
|
54
|
+
const rel = String(artifact);
|
|
55
|
+
const file = path.join(dir, rel);
|
|
56
|
+
const body = fs.readFileSync(file);
|
|
57
|
+
const key = artifactKey(slug, i + 1, path.basename(rel));
|
|
58
|
+
// A dry run already has the bytes in hand, so it reports the real digest putAndVerify
|
|
59
|
+
// would record, not a placeholder -- a preflight worth diffing against the real run,
|
|
60
|
+
// which is why the record goes into the report and not only into the entry.
|
|
61
|
+
const record = dryRun
|
|
62
|
+
? { key, bytes: body.length, sha256: sha256(body) }
|
|
63
|
+
: await putAndVerify({ config, key, body, contentType: TYPES[path.extname(rel)], upload, fetcher });
|
|
64
|
+
migrated.push(record);
|
|
65
|
+
report.records.push(record);
|
|
66
|
+
report.uploaded++;
|
|
67
|
+
}
|
|
68
|
+
capture.artifacts = migrated;
|
|
69
|
+
}
|
|
70
|
+
if (!dryRun) {
|
|
71
|
+
fs.writeFileSync(path.join(dir, 'entry.md'),
|
|
72
|
+
`---\n${yaml.dump(entry.fm, { lineWidth: 110, noRefs: true })}---\n\n${entry.body}\n`);
|
|
73
|
+
for (const capture of captures) {
|
|
74
|
+
for (const a of capture.artifacts) {
|
|
75
|
+
const local = path.join(dir, path.basename(a.key));
|
|
76
|
+
if (fs.existsSync(local)) fs.rmSync(local);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
fs.rmSync(path.join(dir, 'captures'), { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
report.failed.push({ slug, reason: err.message });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return report;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function run(args) {
|
|
89
|
+
const report = await migrateArtifacts({ dryRun: args.includes('--dry-run') });
|
|
90
|
+
process.stdout.write(JSON.stringify(report) + '\n');
|
|
91
|
+
return report.failed.length ? 1 : 0;
|
|
92
|
+
}
|