@yojahny/wp-design-library 1.1.1 → 1.3.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.
Files changed (54) hide show
  1. package/README.md +26 -5
  2. package/bin/library.mjs +1 -0
  3. package/entries/aker/entry.md +107 -0
  4. package/entries/amrit-palace/entry.md +105 -0
  5. package/entries/apple-airpods-5/entry.md +110 -0
  6. package/entries/apple-airpods-pro/entry.md +106 -0
  7. package/entries/apple-watch-se-3/entry.md +107 -0
  8. package/entries/apple-watch-series-12/entry.md +108 -0
  9. package/entries/arsenijs-fabrica/entry.md +100 -0
  10. package/entries/augen-pro/entry.md +100 -0
  11. package/entries/auros/entry.md +110 -0
  12. package/entries/awe-network/entry.md +101 -0
  13. package/entries/ayocin/entry.md +101 -0
  14. package/entries/bmw/entry.md +101 -0
  15. package/entries/busuu/entry.md +100 -0
  16. package/entries/caldera/entry.md +106 -0
  17. package/entries/calendly/entry.md +106 -0
  18. package/entries/custo/entry.md +100 -0
  19. package/entries/dayos-ai/entry.md +109 -0
  20. package/entries/dimension/entry.md +106 -0
  21. package/entries/dovetail/entry.md +108 -0
  22. package/entries/ease-health/entry.md +105 -0
  23. package/entries/factory-ai/entry.md +106 -0
  24. package/entries/flighty/entry.md +105 -0
  25. package/entries/frame-io/entry.md +108 -0
  26. package/entries/hungry-tiger/entry.md +106 -0
  27. package/entries/hyer-aviation/entry.md +108 -0
  28. package/entries/impilo/entry.md +108 -0
  29. package/entries/monad/entry.md +108 -0
  30. package/entries/n8n/entry.md +108 -0
  31. package/entries/palais/entry.md +100 -0
  32. package/entries/partiful/entry.md +104 -0
  33. package/entries/phantom/entry.md +105 -0
  34. package/entries/podia/entry.md +102 -0
  35. package/entries/posthog/entry.md +101 -0
  36. package/entries/redbrick-coffee/entry.md +98 -0
  37. package/entries/samara/entry.md +107 -0
  38. package/entries/sauce-labs/entry.md +110 -0
  39. package/entries/seline-analytics/entry.md +106 -0
  40. package/entries/sequel/entry.md +104 -0
  41. package/entries/slash/entry.md +110 -0
  42. package/entries/supabase/entry.md +107 -0
  43. package/entries/t1-energy/entry.md +101 -0
  44. package/entries/vana/entry.md +100 -0
  45. package/entries/veed/entry.md +98 -0
  46. package/entries/webflow/entry.md +108 -0
  47. package/entries/zkpass/entry.md +103 -0
  48. package/package.json +2 -1
  49. package/src/cli/upload.mjs +57 -0
  50. package/src/ingest/save.mjs +28 -6
  51. package/src/ingest/upload.mjs +110 -0
  52. package/src/mcp/http.mjs +21 -0
  53. package/src/mcp/prompts.mjs +71 -10
  54. package/src/mcp/tools.mjs +104 -8
@@ -0,0 +1,100 @@
1
+ ---
2
+ slug: vana
3
+ title: 'Vana: personal data infrastructure'
4
+ tier: inspiration
5
+ source:
6
+ kind: public-site
7
+ url: https://www.vana.com/
8
+ license: Captured from a Refero-hosted recording and the live site; description only, no redistribution of the source.
9
+ captured:
10
+ method: both
11
+ at: '2026-09-24'
12
+ tool: wp-design-library@1.2.0
13
+ captures:
14
+ - method: frames
15
+ input: https://images.refero.design/styles/refero.design/video/a3563017-dcef-4a36-bd07-9f638e3e4fdf.mp4
16
+ artifacts:
17
+ - key: entries/vana/1/strip.webp
18
+ bytes: 129848
19
+ sha256: 3ed79e491c42f46490b0aee20cec99e99b83319c848a2c307b794610e755d7f2
20
+ uploaded: true
21
+ frames: 18
22
+ duration_ms: 8783
23
+ - method: both
24
+ input: https://www.vana.com/
25
+ artifacts:
26
+ - key: entries/vana/2/strip.webp
27
+ bytes: 261970
28
+ sha256: 0a1eb61643cfe6475cb92a903344deb022bb44e30d53b408946a1d14969097d3
29
+ uploaded: true
30
+ - key: entries/vana/2/measure.json
31
+ bytes: 1554
32
+ sha256: ad5962af583cfc2489fee5073cd43acb5a3e6fc3712857381715b0367bc75af2
33
+ uploaded: true
34
+ - key: entries/vana/2/screen-1440.png
35
+ bytes: 509200
36
+ sha256: 35e85bd7c81da6402dc39c9f07100b9174c50ea1c54c38c2b8acbd5f3cf19b12
37
+ uploaded: true
38
+ - key: entries/vana/2/screen-390.png
39
+ bytes: 353891
40
+ sha256: cfda7194da8a0482b9e58a3d40dc06edf62fbe2ed37f9c5bea362c3a8cb004e2
41
+ uploaded: true
42
+ media:
43
+ frames: 18
44
+ duration_ms: 8783
45
+ roles:
46
+ - hero
47
+ - explainer
48
+ - capability
49
+ - feature
50
+ - proof
51
+ - closing
52
+ - footer
53
+ feel:
54
+ - dark
55
+ - cold
56
+ - saas
57
+ - depth
58
+ - premium
59
+ palette:
60
+ canvas: '#090a13'
61
+ ink: '#f1f1fc'
62
+ accent: '#6959ff'
63
+ type:
64
+ display: large neutral sans, tight leading
65
+ body: compact neutral sans, technical data measure
66
+ motion:
67
+ devices:
68
+ - reveal
69
+ - pan
70
+ - stagger
71
+ - spotlight
72
+ notes: >-
73
+ Eyeballed from 18 sampled frames, not measured. The Refero recording opens on a dark globe and purple data points,
74
+ then pans through ownership cards, a developer stack diagram, and a bright protocol chapter. The current site at
75
+ vana.org has a lavender hero and redesigned content grid. Exact map dynamics, card interactions, pointer response,
76
+ and reduced-motion behavior are not measurable from sampled frames.
77
+ ported_from: null
78
+ ---
79
+
80
+ ## What it does
81
+
82
+ An archived data-ownership infrastructure story that argues people should control and reuse the information they create. Dark global imagery and modular cards visualize an otherwise invisible data layer.
83
+
84
+ ## Section roster
85
+
86
+ 1. Dark globe and AI ownership claim.
87
+ 2. Personal data use cases.
88
+ 3. Developer and participation paths.
89
+ 4. Protocol stack diagram.
90
+ 5. Open-data chapter and closing.
91
+
92
+ ## Why it works
93
+
94
+ The globe and layered cards turn a policy-like subject into a spatial system, while a bright final chapter gives the technical story a clear turn. The cost is that abstract imagery and small labels need plain-language support and strong contrast.
95
+
96
+ ## Sources
97
+
98
+ - [Refero style page](https://styles.refero.design/style/2a3cdb2e-effe-406b-932e-37e4dc88ab1d) and its [recorded MP4](https://images.refero.design/styles/refero.design/video/a3563017-dcef-4a36-bd07-9f638e3e4fdf.mp4) supply the sampled motion and section sequence.
99
+ - The live capture at https://vana.org/ supplies current copy, responsive screenshots, and measured layout.
100
+ - The submitted vana.com redirects to vana.org. The live site is a substantial lavender redesign, so trust the recording for this archived dark visual system and vana.org for current positioning.
@@ -0,0 +1,98 @@
1
+ ---
2
+ slug: veed
3
+ title: 'VEED: AI video creation platform'
4
+ tier: inspiration
5
+ source:
6
+ kind: public-site
7
+ url: https://www.veed.io/
8
+ license: Captured from a Refero-hosted recording and the live site; description only, no redistribution of the source.
9
+ captured:
10
+ method: both
11
+ at: '2026-09-24'
12
+ tool: wp-design-library@1.2.0
13
+ captures:
14
+ - method: frames
15
+ input: https://images.refero.design/styles/refero.design/video/78c29fbf-be4e-4fc4-ad25-b377b1c6c751.mp4
16
+ artifacts:
17
+ - key: entries/veed/1/strip.webp
18
+ bytes: 239780
19
+ sha256: d18b9392525a700aadb1a55172390c8cfd55706e7035a1a093915653f097cede
20
+ uploaded: true
21
+ frames: 23
22
+ duration_ms: 11267
23
+ - method: both
24
+ input: https://www.veed.io/
25
+ artifacts:
26
+ - key: entries/veed/2/strip.webp
27
+ bytes: 389818
28
+ sha256: a203e2016d1ed93a2ca3f51297f0f2151d8f4fb18b6724269c5fbeb4001ed120
29
+ uploaded: true
30
+ - key: entries/veed/2/measure.json
31
+ bytes: 5957
32
+ sha256: 68777c4b3c38d474c2a5cebccabff6b0c76ab507ff3d446a1af02d781c4a2ded
33
+ uploaded: true
34
+ - key: entries/veed/2/screen-1440.png
35
+ bytes: 372084
36
+ sha256: d3d62a04e1adb830c125804e298e3df6ffc0f0ae56bfe6afe7e1c3ee1cb6514e
37
+ uploaded: true
38
+ - key: entries/veed/2/screen-390.png
39
+ bytes: 90686
40
+ sha256: 166d40b1f9597e84a82d89cc1994e168052df2eda906a570c3aafca1fd7ec255
41
+ uploaded: true
42
+ media:
43
+ frames: 23
44
+ duration_ms: 11267
45
+ roles:
46
+ - hero
47
+ - proof
48
+ - feature
49
+ - capability
50
+ - closing
51
+ - footer
52
+ feel:
53
+ - light
54
+ - dark
55
+ - saas
56
+ - playful
57
+ - depth
58
+ palette:
59
+ canvas: '#ffffff'
60
+ ink: '#111111'
61
+ accent: '#9cff39'
62
+ type:
63
+ display: bold neutral sans, compact leading
64
+ body: neutral sans, concise product measure
65
+ motion:
66
+ devices:
67
+ - reveal
68
+ - pan
69
+ - stack
70
+ - stagger
71
+ notes: >-
72
+ Eyeballed from 23 sampled frames, not measured. A sparse AI-video hero gives way to a moving row of creator
73
+ portraits and generated-video previews. Dark product panels stack examples, then editor controls and use-case cards
74
+ reveal in sequence before testimonial and footer content.
75
+ ported_from: null
76
+ ---
77
+
78
+ ## What it does
79
+
80
+ A video creation platform landing page that demonstrates social-ready output before explaining the AI generation and editing workflow. Creator faces and sample videos make the abstract promise tangible.
81
+
82
+ ## Section roster
83
+
84
+ 1. AI-video hero and creation action.
85
+ 2. Creator portraits and team proof.
86
+ 3. Generated video examples.
87
+ 4. Editing and brand controls.
88
+ 5. Use-case cards and closing action.
89
+
90
+ ## Why it works
91
+
92
+ Showing sample output early makes the offer credible, while the restrained white canvas and green accent keep product UI easy to read. The cost is that video previews and dense editing controls need efficient loading and accessible alternatives.
93
+
94
+ ## Sources
95
+
96
+ - [Refero style page](https://styles.refero.design/style/fff821ec-a3bf-41a5-aea2-626185bcd227) and its [recorded MP4](https://images.refero.design/styles/refero.design/video/78c29fbf-be4e-4fc4-ad25-b377b1c6c751.mp4) supply the sampled motion and section sequence.
97
+ - The live capture at https://www.veed.io/ supplies current copy, responsive screenshots, and measured layout.
98
+ - The sources share the same AI-video direction. Trust the recording for example-card movement and the live site for current tools and pricing.
@@ -0,0 +1,108 @@
1
+ ---
2
+ slug: webflow
3
+ title: 'Webflow: layered website-experience platform'
4
+ tier: inspiration
5
+ source:
6
+ kind: public-site
7
+ url: https://webflow.com/
8
+ license: Captured from a Refero-hosted recording and the live site; description only, no redistribution of the source.
9
+ captured:
10
+ method: both
11
+ at: '2026-09-22'
12
+ tool: wp-design-library@1.2.0
13
+ captures:
14
+ - method: frames
15
+ input: 'https://images.refero.design/styles/refero.design/video/4271252f-7d14-4594-8489-b8c23a843bd9.mp4'
16
+ artifacts:
17
+ - key: entries/webflow/1/strip.webp
18
+ bytes: 367942
19
+ sha256: c2c5e860ff83f814d4db8c7a9e83a9a6c0a2b2aca69171dd984cea4540846a97
20
+ uploaded: true
21
+ frames: 36
22
+ duration_ms: 29320
23
+ - method: both
24
+ input: https://webflow.com/
25
+ artifacts:
26
+ - key: entries/webflow/2/strip.webp
27
+ bytes: 316688
28
+ sha256: 4ae2299a39d0f8f06ce898d9dafc7ef75bcf172eeba99d582f4018ed9037902b
29
+ uploaded: true
30
+ - key: entries/webflow/2/measure.json
31
+ bytes: 5564
32
+ sha256: 01dde97a6e81d37aed1aa3684707be2b6cc0b0cea3193243e0d194a94dbe4077
33
+ uploaded: true
34
+ - key: entries/webflow/2/screen-1440.png
35
+ bytes: 284671
36
+ sha256: 326c76e7779a2ce13eb2687ad017d7709cc3f34e21b79108dd075c26cc275404
37
+ uploaded: true
38
+ - key: entries/webflow/2/screen-390.png
39
+ bytes: 54072
40
+ sha256: 03b140fef199644dafdd8aa1576769a5fcce2c9c57bf2761a7919c821897f16f
41
+ uploaded: true
42
+ media:
43
+ frames: 36
44
+ duration_ms: 29320
45
+ roles:
46
+ - hero
47
+ - feature
48
+ - proof
49
+ - capability
50
+ - testimonial
51
+ - explainer
52
+ - closing
53
+ - footer
54
+ feel:
55
+ - light
56
+ - saas
57
+ - premium
58
+ - minimal
59
+ - depth
60
+ palette:
61
+ canvas: '#ffffff'
62
+ ink: '#080808'
63
+ accent: '#146ef5'
64
+ type:
65
+ display: bold modern sans, heavy weight, tight tracking
66
+ body: neutral sans, regular weight, open product-marketing measure
67
+ motion:
68
+ devices:
69
+ - reveal
70
+ - pin
71
+ - pan
72
+ - parallax
73
+ - stagger
74
+ - stack
75
+ - tabs
76
+ notes: >-
77
+ Eyeballed from 36 sampled frames, not measured. The recorded hero layers a blue gradient bed behind product-choice
78
+ cards, then pins a browser canvas while Webflow UI panels pan and stack around a live site preview. Capability
79
+ sections reveal as staggered image-and-metric columns, customer proof advances through portrait cards, and later AI
80
+ and collaboration canvases slide into place before updates, community work, and the footer. The current live page
81
+ has changed its hero and carries a persistent cookie panel; exact tab, carousel, and pointer behavior cannot be
82
+ established from sampled frames.
83
+ ported_from: null
84
+ ---
85
+
86
+ ## What it does
87
+
88
+ Webflow presents its website-experience platform through a choice-led hero, visual building canvas, publishing and optimization capabilities, customer outcomes, AI-assisted workflows, recent updates, and community-made work.
89
+
90
+ ## Section roster
91
+
92
+ 1. Product-choice hero and primary conversion actions.
93
+ 2. Layered builder canvas showing design and code workflows.
94
+ 3. Build, publish, and optimize feature columns.
95
+ 4. Brand-scale proof and customer outcome cards.
96
+ 5. Agentic web-marketing and collaboration explanation.
97
+ 6. AI knowledge and business workflow canvas.
98
+ 7. Updates, conference material, Made in Webflow gallery, and footer.
99
+
100
+ ## Why it works
101
+
102
+ Large direct typography and real product canvases explain a technically broad platform without hiding the interface, while blue accents keep conversion and navigation obvious across a long page. The cost to weigh is that stacked UI screenshots, customer cards, and changing campaign content create a heavy page that can age quickly and compete for attention.
103
+
104
+ ## Sources
105
+
106
+ - The [Refero style page](https://styles.refero.design/style/31471407-598a-45fd-a505-d921980d8855) and its MP4 supply the earlier growth-engine hero, builder-canvas choreography, feature stacking, customer-card sequence, and complete scroll narrative.
107
+ - The live capture at https://webflow.com/ supplies the current Build for what’s next hero, present campaign content, navigation, responsive screenshots, and measured layout.
108
+ - The hero and some downstream campaign sections differ materially; the live page is trusted for current structure and copy, while the recording is retained as the motion reference for the captured design version.
@@ -0,0 +1,103 @@
1
+ ---
2
+ slug: zkpass
3
+ title: 'zkPass: cryptographic trust-network landing'
4
+ tier: inspiration
5
+ source:
6
+ kind: public-site
7
+ url: https://zkpass.org/
8
+ license: Captured from a Refero-hosted recording and the live site; description only, no redistribution of the source.
9
+ captured:
10
+ method: both
11
+ at: '2026-09-24'
12
+ tool: wp-design-library@1.2.0
13
+ captures:
14
+ - method: frames
15
+ input: https://images.refero.design/styles/refero.design/video/225d4ed7-26d4-4495-b8d3-c6b32d9af232.mp4
16
+ artifacts:
17
+ - key: entries/zkpass/1/strip.webp
18
+ bytes: 246594
19
+ sha256: 35b485103534d1ac10bc1116b644e19eb41cdbb066d381f366de71b9295b3d63
20
+ uploaded: true
21
+ frames: 36
22
+ duration_ms: 20717
23
+ - method: both
24
+ input: https://zkpass.org/
25
+ artifacts:
26
+ - key: entries/zkpass/2/strip.webp
27
+ bytes: 264812
28
+ sha256: 473a2275e5161b08fca4d61ac72d3f2507589197e9e32a09e66a1ea48619e296
29
+ uploaded: true
30
+ - key: entries/zkpass/2/measure.json
31
+ bytes: 4020
32
+ sha256: 2bbe4c4567883a5b1e1d3ef48dc51cf2a981837fb654dd5c87ea2adbfe61cc43
33
+ uploaded: true
34
+ - key: entries/zkpass/2/screen-1440.png
35
+ bytes: 450714
36
+ sha256: c5d496cde94be116f563652bb4fb9c6ada3506383d6102f0fd758191fdfd246b
37
+ uploaded: true
38
+ - key: entries/zkpass/2/screen-390.png
39
+ bytes: 68539
40
+ sha256: 6adc33d258808e9085e2db57ac09f88eb774287440db9048ea8226b987be6a1a
41
+ uploaded: true
42
+ media:
43
+ frames: 36
44
+ duration_ms: 20717
45
+ roles:
46
+ - hero
47
+ - explainer
48
+ - capability
49
+ - proof
50
+ - feature
51
+ - closing
52
+ - footer
53
+ feel:
54
+ - dark
55
+ - brutalist
56
+ - cold
57
+ - premium
58
+ - depth
59
+ palette:
60
+ canvas: '#050605'
61
+ ink: '#eeeee8'
62
+ accent: '#c4ff35'
63
+ type:
64
+ display: high-contrast editorial serif and technical sans
65
+ body: compact technical sans, dense protocol measure
66
+ motion:
67
+ devices:
68
+ - reveal
69
+ - wipe
70
+ - pan
71
+ - stagger
72
+ - spotlight
73
+ notes: >-
74
+ Eyeballed from 36 sampled frames, not measured. A black particle field and neon green symbol open the page.
75
+ Oversized Proof and Credit statements wipe between dark product panels; verification flows, code-like diagrams, and
76
+ principle rows reveal in sequence before the green closing mark. Exact particle simulation, verification demo
77
+ behavior, pointer response, and reduced-motion behavior are not measurable from sampled frames.
78
+ ported_from: null
79
+ ---
80
+
81
+ ## What it does
82
+
83
+ A privacy and verification protocol landing page that organizes a technical product around proof, credit, and reusable trust. Near-black surfaces and acid-green signals make the system feel cryptographic while large headings give dense material a readable hierarchy.
84
+
85
+ ## Section roster
86
+
87
+ 1. Particle-field hero and privacy claim.
88
+ 2. Proof and credit product framing.
89
+ 3. Three verification methods.
90
+ 4. Human and agent audience paths.
91
+ 5. Technical primitives and protocol principles.
92
+ 6. Product diagrams and dispatches.
93
+ 7. Closing proof statement and footer.
94
+
95
+ ## Why it works
96
+
97
+ The green signal color guides attention through otherwise dense technical panels, while oversized serif assertions make each concept memorable. The cost is that small diagram labels and low-light UI require strong contrast and responsive simplification.
98
+
99
+ ## Sources
100
+
101
+ - [Refero style page](https://styles.refero.design/style/3a398f82-579e-4ec4-a442-9962bf007edf) and its [recorded MP4](https://images.refero.design/styles/refero.design/video/225d4ed7-26d4-4495-b8d3-c6b32d9af232.mp4) supply the opening particle field, product transitions, verification panels, protocol chapters, and closing.
102
+ - The live capture at https://zkpass.org/ supplies current terminology, responsive screenshots, and measured layout.
103
+ - The sources agree closely. Trust the recording for transition continuity and the live page for current protocol claims.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yojahny/wp-design-library",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Design reference corpus and MCP server for claude-wp-builder",
5
5
  "repository": {
6
6
  "type": "git",
@@ -65,6 +65,7 @@
65
65
  "src/cli/refresh.mjs",
66
66
  "src/cli/save.mjs",
67
67
  "src/cli/serve.mjs",
68
+ "src/cli/upload.mjs",
68
69
  "src/cli/ui.mjs",
69
70
  "src/entry.mjs",
70
71
  "src/index/",
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Readable } from 'node:stream';
4
+
5
+ function opt(args, name) {
6
+ const i = args.indexOf(name);
7
+ return i === -1 ? undefined : args[i + 1];
8
+ }
9
+
10
+ export async function uploadFile({ file, server, token, source, license, url, title, fetcher = fetch }) {
11
+ if (!file) throw new Error('usage: library upload <file> --server <url> [--source kind --license text --url source-url --title title]');
12
+ if (!server) throw new Error('--server or LIBRARY_SERVER is required');
13
+ if (!token) throw new Error('LIBRARY_TOKEN is required');
14
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`file does not exist: ${file}`);
15
+ if ((source && !license) || (!source && license)) throw new Error('--source and --license must be provided together');
16
+
17
+ const name = path.basename(file);
18
+ const endpoint = new URL(`/inbox/${encodeURIComponent(name)}`, server);
19
+ if (!['http:', 'https:'].includes(endpoint.protocol)) throw new Error('--server must be an HTTP(S) URL');
20
+ const headers = {
21
+ authorization: `Bearer ${token}`,
22
+ 'content-type': 'application/octet-stream',
23
+ 'content-length': String(fs.statSync(file).size),
24
+ };
25
+ if (source) headers['x-library-source'] = source;
26
+ if (license) headers['x-library-license'] = license;
27
+ if (url) headers['x-library-url'] = url;
28
+ if (title) headers['x-library-title'] = title;
29
+
30
+ const response = await fetcher(endpoint, {
31
+ method: 'PUT', headers,
32
+ body: Readable.toWeb(fs.createReadStream(file)),
33
+ duplex: 'half',
34
+ });
35
+ const text = await response.text();
36
+ if (!response.ok) throw new Error(`upload refused (${response.status}): ${text}`);
37
+ return JSON.parse(text);
38
+ }
39
+
40
+ export async function run(args) {
41
+ try {
42
+ const result = await uploadFile({
43
+ file: args[0],
44
+ server: opt(args, '--server') ?? process.env.LIBRARY_SERVER,
45
+ token: process.env.LIBRARY_TOKEN,
46
+ source: opt(args, '--source'),
47
+ license: opt(args, '--license'),
48
+ url: opt(args, '--url'),
49
+ title: opt(args, '--title'),
50
+ });
51
+ process.stdout.write(`${JSON.stringify(result)}\n`);
52
+ return 0;
53
+ } catch (err) {
54
+ process.stderr.write(`${err.message}\n`);
55
+ return 1;
56
+ }
57
+ }
@@ -1,11 +1,33 @@
1
+ import path from 'node:path';
1
2
  import { parseEntry, validateEntry } from '../entry.mjs';
2
3
  import { buildIndex } from '../index/build.mjs';
3
4
 
4
5
  export async function saveEntry({ dir, vocab, indexPath, entriesDir, models, p }) {
5
- const e = parseEntry(dir);
6
- const r = validateEntry(e, vocab);
7
- if (!r.ok) return { ok: false, slug: e.slug, errors: r.errors, refused: r.errors.join('; ') };
8
- // Phase 1: a full rebuild is cheap; single-entry upsert arrives with the vector arms.
9
- await buildIndex({ entriesDir, indexPath, vocab, models, p });
10
- return { ok: true, slug: e.slug, errors: [] };
6
+ const batch = await saveEntries({ dirs: [dir], vocab, indexPath, entriesDir, models, p });
7
+ return batch.results[0];
8
+ }
9
+
10
+ export async function saveEntries({ dirs, vocab, indexPath, entriesDir, models, p }) {
11
+ const seen = new Set();
12
+ const results = dirs.map((dir) => {
13
+ const requested = path.basename(dir);
14
+ if (seen.has(requested)) {
15
+ return { ok: false, slug: requested, errors: ['duplicate slug in batch'], refused: 'duplicate slug in batch' };
16
+ }
17
+ seen.add(requested);
18
+ try {
19
+ const e = parseEntry(dir);
20
+ const r = validateEntry(e, vocab);
21
+ if (!r.ok) return { ok: false, slug: e.slug, errors: r.errors, refused: r.errors.join('; ') };
22
+ return { ok: true, slug: e.slug, errors: [] };
23
+ } catch (err) {
24
+ return { ok: false, slug: requested, errors: [err.message], refused: err.message };
25
+ }
26
+ });
27
+
28
+ const saved = results.filter((r) => r.ok).length;
29
+ // A batch rebuilds once after every draft has been validated. Rebuilding after each
30
+ // slug makes a 2,000-entry approval quadratic even though the final index is identical.
31
+ if (saved) await buildIndex({ entriesDir, indexPath, vocab, models, p });
32
+ return { results, saved, refused: results.length - saved };
11
33
  }
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+
5
+ export const MEDIA_EXTENSIONS = new Set(['.webp', '.gif', '.mp4', '.mov', '.webm', '.mkv']);
6
+ export const DEFAULT_UPLOAD_MAX_BYTES = 512 * 1024 * 1024;
7
+
8
+ export class UploadError extends Error {
9
+ constructor(status, message) {
10
+ super(message);
11
+ this.status = status;
12
+ }
13
+ }
14
+
15
+ function oneLine(value, name) {
16
+ if (value == null || value === '') return undefined;
17
+ if (typeof value !== 'string' || /[\r\n]/.test(value)) throw new UploadError(400, `${name} must be one line`);
18
+ return value;
19
+ }
20
+
21
+ export function uploadMetadata(headers) {
22
+ const kind = oneLine(headers['x-library-source'], 'x-library-source');
23
+ const license = oneLine(headers['x-library-license'], 'x-library-license');
24
+ const title = oneLine(headers['x-library-title'], 'x-library-title');
25
+ const url = oneLine(headers['x-library-url'], 'x-library-url');
26
+
27
+ if ((kind && !license) || (!kind && license)) {
28
+ throw new UploadError(400, 'x-library-source and x-library-license must be provided together');
29
+ }
30
+ if (kind && !['own', 'open', 'paid', 'public-site'].includes(kind)) {
31
+ throw new UploadError(400, 'x-library-source must be own, open, paid, or public-site');
32
+ }
33
+ if (url) {
34
+ try {
35
+ const parsed = new URL(url);
36
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
37
+ } catch {
38
+ throw new UploadError(400, 'x-library-url must be a full HTTP(S) URL');
39
+ }
40
+ }
41
+ return kind ? { kind, license, ...(title ? { title } : {}), ...(url ? { url } : {}) } : null;
42
+ }
43
+
44
+ export function uploadFilename(raw) {
45
+ let filename;
46
+ try { filename = decodeURIComponent(raw); } catch { throw new UploadError(400, 'invalid encoded filename'); }
47
+ if (!filename || filename !== path.basename(filename) || filename === '.' || filename === '..' || /[\0\r\n]/.test(filename)) {
48
+ throw new UploadError(400, 'filename must be one file name without a path');
49
+ }
50
+ if (!MEDIA_EXTENSIONS.has(path.extname(filename).toLowerCase())) {
51
+ throw new UploadError(415, `unsupported media extension; use ${[...MEDIA_EXTENSIONS].join(', ')}`);
52
+ }
53
+ return filename;
54
+ }
55
+
56
+ export async function receiveInboxUpload({ req, dataDir, rawFilename, maxBytes = DEFAULT_UPLOAD_MAX_BYTES }) {
57
+ const filename = uploadFilename(rawFilename);
58
+ const metadata = uploadMetadata(req.headers);
59
+ const declared = Number(req.headers['content-length']);
60
+ if (Number.isFinite(declared) && declared > maxBytes) {
61
+ throw new UploadError(413, `upload exceeds ${maxBytes} byte limit`);
62
+ }
63
+
64
+ const inbox = path.join(dataDir, 'inbox');
65
+ const destination = path.join(inbox, filename);
66
+ const sidecar = `${destination}.json`;
67
+ fs.mkdirSync(inbox, { recursive: true });
68
+ if (fs.existsSync(destination) || fs.existsSync(sidecar)) {
69
+ throw new UploadError(409, `${filename} already exists in inbox`);
70
+ }
71
+
72
+ const temporary = path.join(inbox, `.${filename}.${randomUUID()}.upload`);
73
+ const handle = await fs.promises.open(temporary, 'wx');
74
+ const digest = createHash('sha256');
75
+ let bytes = 0;
76
+ try {
77
+ for await (const chunk of req) {
78
+ bytes += chunk.length;
79
+ if (bytes > maxBytes) throw new UploadError(413, `upload exceeds ${maxBytes} byte limit`);
80
+ digest.update(chunk);
81
+ await handle.write(chunk);
82
+ }
83
+ if (bytes === 0) throw new UploadError(400, 'upload body is empty');
84
+ await handle.sync();
85
+ await handle.close();
86
+
87
+ // link() publishes atomically and refuses a concurrent upload with the same name.
88
+ try {
89
+ await fs.promises.link(temporary, destination);
90
+ } catch (err) {
91
+ if (err.code === 'EEXIST') throw new UploadError(409, `${filename} already exists in inbox`);
92
+ throw err;
93
+ }
94
+ if (metadata) {
95
+ try {
96
+ await fs.promises.writeFile(sidecar, `${JSON.stringify(metadata)}\n`, { flag: 'wx' });
97
+ } catch (err) {
98
+ await fs.promises.unlink(destination).catch(() => {});
99
+ if (err.code === 'EEXIST') throw new UploadError(409, `${filename}.json already exists in inbox`);
100
+ throw err;
101
+ }
102
+ }
103
+ await fs.promises.unlink(temporary);
104
+ return { ok: true, file: filename, bytes, sha256: digest.digest('hex'), sidecar: Boolean(metadata) };
105
+ } catch (err) {
106
+ await handle.close().catch(() => {});
107
+ await fs.promises.unlink(temporary).catch(() => {});
108
+ throw err;
109
+ }
110
+ }
package/src/mcp/http.mjs CHANGED
@@ -7,6 +7,7 @@ import { paths } from '../paths.mjs';
7
7
  import { openIndex } from '../index/query.mjs';
8
8
  import { getEmbedder } from '../index/embed.mjs';
9
9
  import { serveFile } from '../ui/serve.mjs';
10
+ import { receiveInboxUpload, UploadError, DEFAULT_UPLOAD_MAX_BYTES } from '../ingest/upload.mjs';
10
11
 
11
12
  function authorized(header, token) {
12
13
  const want = Buffer.from(`Bearer ${token}`), got = Buffer.from(header ?? '');
@@ -41,6 +42,26 @@ async function handle(req, res, p, token, embedder) {
41
42
  res.writeHead(200, { 'content-type': 'application/json' });
42
43
  return res.end(JSON.stringify({ ok: true, entries, arms, artifacts, version: p.version }));
43
44
  }
45
+ if (url.pathname.startsWith('/inbox/')) {
46
+ if (!authorized(req.headers.authorization, token)) { res.writeHead(401); return res.end('unauthorized'); }
47
+ if (req.method !== 'PUT') { res.writeHead(405, { allow: 'PUT' }); return res.end('method not allowed'); }
48
+ const configured = Number(process.env.LIBRARY_UPLOAD_MAX_BYTES);
49
+ const maxBytes = Number.isSafeInteger(configured) && configured > 0 ? configured : DEFAULT_UPLOAD_MAX_BYTES;
50
+ try {
51
+ const result = await receiveInboxUpload({
52
+ req,
53
+ dataDir: p.data,
54
+ rawFilename: url.pathname.slice('/inbox/'.length),
55
+ maxBytes,
56
+ });
57
+ res.writeHead(201, { 'content-type': 'application/json' });
58
+ return res.end(JSON.stringify(result));
59
+ } catch (err) {
60
+ if (!(err instanceof UploadError)) throw err;
61
+ res.writeHead(err.status, { 'content-type': 'application/json' });
62
+ return res.end(JSON.stringify({ ok: false, refused: err.message }));
63
+ }
64
+ }
44
65
  if (url.pathname === '/mcp') {
45
66
  if (!authorized(req.headers.authorization, token)) { res.writeHead(401); return res.end('unauthorized'); }
46
67
  // ponytail: SDK 1.30's stateless transport (sessionIdGenerator: undefined)