@yojahny/wp-design-library 0.3.0 → 0.5.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.
Files changed (186) hide show
  1. package/.env.example +7 -0
  2. package/README.md +52 -9
  3. package/bin/library.mjs +11 -0
  4. package/entries/ada/entry.md +55 -0
  5. package/entries/ada/strip.webp +0 -0
  6. package/entries/air/captures/2/measure.json +325 -0
  7. package/entries/air/captures/2/strip.webp +0 -0
  8. package/entries/air/entry.md +62 -0
  9. package/entries/air/strip.webp +0 -0
  10. package/entries/ais-community-dark-depth/strip.webp +0 -0
  11. package/entries/apple-home/captures/2/measure.json +91 -0
  12. package/entries/apple-home/captures/2/strip.webp +0 -0
  13. package/entries/apple-home/entry.md +64 -0
  14. package/entries/apple-home/strip.webp +0 -0
  15. package/entries/apple-macbook-neo/captures/2/measure.json +570 -0
  16. package/entries/apple-macbook-neo/captures/2/strip.webp +0 -0
  17. package/entries/apple-macbook-neo/entry.md +62 -0
  18. package/entries/apple-macbook-neo/strip.webp +0 -0
  19. package/entries/apple-watch-ultra-4/entry.md +63 -0
  20. package/entries/apple-watch-ultra-4/measure.json +481 -0
  21. package/entries/apple-watch-ultra-4/strip.webp +0 -0
  22. package/entries/bpco/captures/2/strip.webp +0 -0
  23. package/entries/bpco/captures/3/strip.webp +0 -0
  24. package/entries/bpco/entry.md +81 -0
  25. package/entries/bpco/strip.webp +0 -0
  26. package/entries/column/captures/2/measure.json +996 -0
  27. package/entries/column/captures/2/strip.webp +0 -0
  28. package/entries/column/entry.md +82 -0
  29. package/entries/column/strip.webp +0 -0
  30. package/entries/cthdrl/captures/2/measure.json +251 -0
  31. package/entries/cthdrl/captures/2/strip.webp +0 -0
  32. package/entries/cthdrl/entry.md +86 -0
  33. package/entries/cthdrl/strip.webp +0 -0
  34. package/entries/dala-craftedbygc/entry.md +69 -0
  35. package/entries/dala-craftedbygc/strip.webp +0 -0
  36. package/entries/dock/captures/2/measure.json +206 -0
  37. package/entries/dock/captures/2/strip.webp +0 -0
  38. package/entries/dock/entry.md +62 -0
  39. package/entries/dock/strip.webp +0 -0
  40. package/entries/dub/captures/2/measure.json +37 -0
  41. package/entries/dub/captures/2/strip.webp +0 -0
  42. package/entries/dub/entry.md +61 -0
  43. package/entries/dub/strip.webp +0 -0
  44. package/entries/duolingo/captures/2/measure.json +138 -0
  45. package/entries/duolingo/captures/2/strip.webp +0 -0
  46. package/entries/duolingo/entry.md +65 -0
  47. package/entries/duolingo/strip.webp +0 -0
  48. package/entries/elevenlabs/captures/2/measure.json +160 -0
  49. package/entries/elevenlabs/captures/2/strip.webp +0 -0
  50. package/entries/elevenlabs/entry.md +62 -0
  51. package/entries/elevenlabs/strip.webp +0 -0
  52. package/entries/flying-papers/captures/2/measure.json +291 -0
  53. package/entries/flying-papers/captures/2/strip.webp +0 -0
  54. package/entries/flying-papers/entry.md +61 -0
  55. package/entries/flying-papers/strip.webp +0 -0
  56. package/entries/gsap/captures/2/measure.json +120 -0
  57. package/entries/gsap/captures/2/strip.webp +0 -0
  58. package/entries/gsap/entry.md +62 -0
  59. package/entries/gsap/strip.webp +0 -0
  60. package/entries/letters/captures/2/measure.json +20 -0
  61. package/entries/letters/captures/2/strip.webp +0 -0
  62. package/entries/letters/entry.md +62 -0
  63. package/entries/letters/strip.webp +0 -0
  64. package/entries/micro/captures/2/measure.json +852 -0
  65. package/entries/micro/captures/2/strip.webp +0 -0
  66. package/entries/micro/entry.md +61 -0
  67. package/entries/micro/strip.webp +0 -0
  68. package/entries/monopo-saigon/captures/2/measure.json +145 -0
  69. package/entries/monopo-saigon/captures/2/strip.webp +0 -0
  70. package/entries/monopo-saigon/entry.md +64 -0
  71. package/entries/monopo-saigon/strip.webp +0 -0
  72. package/entries/notion/captures/2/measure.json +338 -0
  73. package/entries/notion/captures/2/strip.webp +0 -0
  74. package/entries/notion/entry.md +66 -0
  75. package/entries/notion/strip.webp +0 -0
  76. package/entries/origin-financial/captures/2/measure.json +169 -0
  77. package/entries/origin-financial/captures/2/strip.webp +0 -0
  78. package/entries/origin-financial/entry.md +65 -0
  79. package/entries/origin-financial/strip.webp +0 -0
  80. package/entries/oryzo-ai/captures/2/measure.json +39 -0
  81. package/entries/oryzo-ai/captures/2/strip.webp +0 -0
  82. package/entries/oryzo-ai/entry.md +76 -0
  83. package/entries/oryzo-ai/strip.webp +0 -0
  84. package/entries/silencio/captures/2/strip.webp +0 -0
  85. package/entries/silencio/entry.md +114 -0
  86. package/entries/silencio/motion/product-story/frame-0.webp +0 -0
  87. package/entries/silencio/motion/product-story/frame-1.webp +0 -0
  88. package/entries/silencio/motion/product-story/frame-2.webp +0 -0
  89. package/entries/silencio/motion/product-story/frame-3.webp +0 -0
  90. package/entries/silencio/motion/product-story/frame-4.webp +0 -0
  91. package/entries/silencio/motion/product-story/frame-5.webp +0 -0
  92. package/entries/silencio/motion/product-story/frame-6.webp +0 -0
  93. package/entries/silencio/motion/product-story/frame-7.webp +0 -0
  94. package/entries/silencio/strip.webp +0 -0
  95. package/entries/steep/captures/2/measure.json +20 -0
  96. package/entries/steep/captures/2/strip.webp +0 -0
  97. package/entries/steep/entry.md +63 -0
  98. package/entries/steep/strip.webp +0 -0
  99. package/entries/stripe/captures/2/measure.json +892 -0
  100. package/entries/stripe/captures/2/strip.webp +0 -0
  101. package/entries/stripe/entry.md +61 -0
  102. package/entries/stripe/strip.webp +0 -0
  103. package/entries/superpower/captures/2/measure.json +179 -0
  104. package/entries/superpower/captures/2/strip.webp +0 -0
  105. package/entries/superpower/entry.md +64 -0
  106. package/entries/superpower/strip.webp +0 -0
  107. package/entries/superr/entry.md +54 -0
  108. package/entries/superr/strip.webp +0 -0
  109. package/entries/svz-weare/captures/2/measure.json +129 -0
  110. package/entries/svz-weare/captures/2/strip.webp +0 -0
  111. package/entries/svz-weare/entry.md +147 -0
  112. package/entries/svz-weare/motion/hero-transition/frame-0.webp +0 -0
  113. package/entries/svz-weare/motion/hero-transition/frame-1.webp +0 -0
  114. package/entries/svz-weare/motion/hero-transition/frame-2.webp +0 -0
  115. package/entries/svz-weare/motion/hero-transition/frame-3.webp +0 -0
  116. package/entries/svz-weare/motion/hero-transition/frame-4.webp +0 -0
  117. package/entries/svz-weare/motion/hero-transition/frame-5.webp +0 -0
  118. package/entries/svz-weare/motion/hero-transition/frame-6.webp +0 -0
  119. package/entries/svz-weare/motion/hero-transition/frame-7.webp +0 -0
  120. package/entries/svz-weare/strip.webp +0 -0
  121. package/entries/thoughtlab/captures/2/measure.json +93 -0
  122. package/entries/thoughtlab/captures/2/strip.webp +0 -0
  123. package/entries/thoughtlab/entry.md +62 -0
  124. package/entries/thoughtlab/strip.webp +0 -0
  125. package/entries/ventriloc/entry.md +54 -0
  126. package/entries/ventriloc/strip.webp +0 -0
  127. package/entries/vivid-and-co/entry.md +58 -0
  128. package/entries/vivid-and-co/strip.webp +0 -0
  129. package/package.json +24 -5
  130. package/src/cli/add.mjs +100 -24
  131. package/src/cli/motion.mjs +168 -0
  132. package/src/cli/refresh.mjs +6 -0
  133. package/src/cli/ui.mjs +2 -1
  134. package/src/entry.mjs +14 -1
  135. package/src/index/build.mjs +9 -3
  136. package/src/index/embed.mjs +1 -1
  137. package/src/ingest/agreement.mjs +33 -0
  138. package/src/ingest/attach.mjs +67 -0
  139. package/src/ingest/draft.mjs +5 -2
  140. package/src/ingest/frames.mjs +20 -6
  141. package/src/ingest/measure.mjs +34 -1
  142. package/src/ingest/observed.mjs +29 -0
  143. package/src/mcp/http.mjs +1 -1
  144. package/src/mcp/instructions.mjs +2 -1
  145. package/src/mcp/prompts.mjs +12 -1
  146. package/src/mcp/resources.mjs +14 -0
  147. package/src/mcp/tools.mjs +48 -5
  148. package/src/motion.mjs +94 -0
  149. package/src/ui/build.mjs +83 -7
  150. package/src/ui/serve.mjs +29 -4
  151. package/src/ui/templates/entry.html +9 -1
  152. package/entries/ais-community-dark-depth/strip.png +0 -0
  153. package/tests/README.md +0 -33
  154. package/tests/checks/cli-check.sh +0 -13
  155. package/tests/checks/docker.sh +0 -10
  156. package/tests/checks/entry-detail.sh +0 -52
  157. package/tests/checks/entry.sh +0 -55
  158. package/tests/checks/export.sh +0 -16
  159. package/tests/checks/fetch-on-start.sh +0 -26
  160. package/tests/checks/frames-dense.sh +0 -27
  161. package/tests/checks/http.sh +0 -22
  162. package/tests/checks/hygiene.sh +0 -29
  163. package/tests/checks/inbox.sh +0 -27
  164. package/tests/checks/index-degrade.sh +0 -32
  165. package/tests/checks/index.sh +0 -36
  166. package/tests/checks/ingest-mp4.sh +0 -47
  167. package/tests/checks/ingest.sh +0 -39
  168. package/tests/checks/instructions.sh +0 -31
  169. package/tests/checks/licence-gate.sh +0 -24
  170. package/tests/checks/mcp-stdout.sh +0 -31
  171. package/tests/checks/measure.sh +0 -79
  172. package/tests/checks/minors.sh +0 -84
  173. package/tests/checks/optional-deps.sh +0 -55
  174. package/tests/checks/prompt-add-entry.sh +0 -28
  175. package/tests/checks/recommend.sh +0 -107
  176. package/tests/checks/resources.sh +0 -47
  177. package/tests/checks/rrf.sh +0 -120
  178. package/tests/checks/seed-sync.sh +0 -22
  179. package/tests/checks/similar.sh +0 -62
  180. package/tests/checks/ui-build.sh +0 -59
  181. package/tests/checks/vocab.sh +0 -30
  182. package/tests/fixtures/entry-ok/entry.md +0 -41
  183. package/tests/fixtures/entry-ok/strip.png +0 -0
  184. package/tests/fixtures/page/index.html +0 -47
  185. package/tests/fixtures/three-frame.webp +0 -0
  186. package/tests/run.sh +0 -16
package/src/mcp/tools.mjs CHANGED
@@ -4,6 +4,8 @@ import { z } from 'zod';
4
4
  import { openIndex, search, getEntry, similar } from '../index/query.mjs';
5
5
  import { resolveTerm } from '../vocab.mjs';
6
6
  import { refuseUnsafeUrl } from '../ingest/url-guard.mjs';
7
+ import { getMotion, motionFrame } from '../motion.mjs';
8
+ import { parseEntry } from '../entry.mjs';
7
9
 
8
10
  const facetList = z.array(z.string()).optional();
9
11
  const FILTER_FACETS = ['role', 'feel', 'motion', 'source'];
@@ -76,7 +78,7 @@ export function makeTools(ctx) {
76
78
  },
77
79
  {
78
80
  name: 'get_entry',
79
- description: "Return one entry: frontmatter, prose body, and the path to its strip.png. detail:'summary' returns the body's first paragraph instead of the whole body — enough to decide whether to fetch the rest.",
81
+ description: "Return one entry: frontmatter, prose body, and the path to its strip.webp. detail:'summary' returns the body's first paragraph instead of the whole body — enough to decide whether to fetch the rest.",
80
82
  schema: z.object({
81
83
  slug: z.string(),
82
84
  detail: z.enum(['summary', 'full']).default('full'),
@@ -84,7 +86,22 @@ export function makeTools(ctx) {
84
86
  handler: withDb((db, { slug, detail }) => {
85
87
  const e = getEntry(db, slug);
86
88
  if (!e) return { refused: `no entry with slug ${slug}` };
87
- const out = { ...e, strip: `${e.dir}/strip.png` };
89
+ // Every capture's strip, one slot per capture, capture 1 first — strips[i] always
90
+ // describes captures[i]. `strip` is derived from e.dir alone, independently of what
91
+ // a capture's own artifacts happen to list, so a capture that names no strip falls
92
+ // back to its conventional path (entry root for capture 1, captures/<n>/strip.webp
93
+ // after) instead of being silently dropped: that keeps strips[0] === strip true by
94
+ // construction, not by writer discipline. Match on basename exactly, not by suffix —
95
+ // a suffix match would let an unrelated file like extra-strip.webp count as a strip.
96
+ const captures = e.fm?.captured?.captures ?? [];
97
+ const conventionalStrip = (i) => (i === 0 ? 'strip.webp' : `captures/${i + 1}/strip.webp`);
98
+ const strips = captures.length
99
+ ? captures.map((c, i) => {
100
+ const named = (c.artifacts ?? []).find((a) => path.basename(a) === 'strip.webp');
101
+ return `${e.dir}/${named ?? conventionalStrip(i)}`;
102
+ })
103
+ : [`${e.dir}/strip.webp`];
104
+ const out = { ...e, strip: `${e.dir}/strip.webp`, strips };
88
105
  if (detail !== 'summary') return out;
89
106
  // The first PROSE paragraph. Bodies conventionally open with `## What it does`,
90
107
  // so splitting on the first blank line alone would return a bare heading, which
@@ -100,9 +117,34 @@ export function makeTools(ctx) {
100
117
  return { ...out, body: first.trim(), detail: 'summary' };
101
118
  }),
102
119
  },
120
+ {
121
+ name: 'get_motion',
122
+ description: 'Inspect one motion clip at original playback speed. Returns video URL when published, provenance, observed motion notes, and timestamped frames as native images by default. Use clip to select from available clips; timestamps are relative to the clip. A video URL alone does not provide video understanding.',
123
+ schema: z.object({
124
+ slug: z.string().regex(/^[a-z0-9-]+$/),
125
+ clip: z.string().regex(/^[a-z0-9-]+$/).optional(),
126
+ include_frames: z.boolean().default(true),
127
+ }),
128
+ handler: async ({ slug, clip, include_frames }) => {
129
+ const dir = path.join(ctx.paths.entries, slug);
130
+ if (!fs.existsSync(path.join(dir, 'entry.md'))) return { refused: `no entry with slug ${slug}` };
131
+ return { ...getMotion(parseEntry(dir), clip), frames_included: include_frames };
132
+ },
133
+ content: (out) => {
134
+ if (out.refused || !out.frames_included) return [];
135
+ const entry = parseEntry(path.join(ctx.paths.entries, out.slug));
136
+ return out.clip.frames.flatMap((_, i) => {
137
+ const frame = motionFrame(entry, out.clip.id, i);
138
+ return [
139
+ { type: 'text', text: `Frame ${i}: ${(frame.at_ms / 1000).toFixed(3)} seconds into ${out.clip.id}` },
140
+ { type: 'image', mimeType: 'image/webp', data: frame.data },
141
+ ];
142
+ });
143
+ },
144
+ },
103
145
  {
104
146
  name: 'similar',
105
- description: "Find entries visually similar to a slug's strip.png, or to an image file, by vector distance on vec_image. Exactly one of slug/image is required.",
147
+ description: "Find entries visually similar to a slug's strip.webp, or to an image file, by vector distance on vec_image. Exactly one of slug/image is required.",
106
148
  schema: z.object({
107
149
  slug: z.string().optional(),
108
150
  image: z.string().optional(),
@@ -161,13 +203,14 @@ export function makeTools(ctx) {
161
203
  },
162
204
  {
163
205
  name: 'add',
164
- description: 'Ingest a video/animated image, or measure a live https:// URL with the system Chrome: sample frames or measure into strip.png/measure.json and write a draft entry. Returns the blank fields the caller must fill before save_entry.',
206
+ description: 'Ingest a video/animated image, or measure a live https:// URL with the system Chrome: sample frames or measure into strip.webp/measure.json and write a draft entry. Returns the blank fields the caller must fill before save_entry. Pass attach to add a second capture of the same site — a video beside a live URL — instead of refusing; the existing entry\'s tags, body, palette and type are left untouched.',
165
207
  schema: z.object({
166
208
  input: z.string(), slug: z.string().regex(/^[a-z0-9-]+$/), title: z.string().min(1),
167
209
  source: z.object({ kind: z.enum(['own', 'open', 'paid', 'public-site']), url: z.string().url().optional(), license: z.string().min(1) }),
168
210
  force: z.boolean().default(false),
169
211
  frames: z.number().int().min(6).max(36).optional(),
170
212
  record: z.boolean().default(false),
213
+ attach: z.boolean().default(false),
171
214
  }),
172
215
  handler: async (a) => {
173
216
  // A URL is fetched over the network by Chrome, not read off disk on the
@@ -205,7 +248,7 @@ export function registerTools(server, tools) {
205
248
  for (const t of tools) {
206
249
  server.registerTool(t.name, { description: t.description, inputSchema: t.schema.shape }, async (args) => {
207
250
  const out = await t.handler(args);
208
- return { content: [{ type: 'text', text: JSON.stringify(out) }], structuredContent: out };
251
+ return { content: [{ type: 'text', text: JSON.stringify(out) }, ...(t.content?.(out) ?? [])], structuredContent: out };
209
252
  });
210
253
  }
211
254
  }
package/src/motion.mjs ADDED
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+ const KEY = /^motion\/[a-z0-9-]+\/[a-z0-9-]+-[a-f0-9]{16}\.mp4$/;
6
+ const MAX_IMAGE_BYTES = 512 * 1024;
7
+
8
+ export function isHttpsUrl(value) {
9
+ try {
10
+ const u = new URL(value);
11
+ return u.protocol === 'https:' && !u.username && !u.password;
12
+ } catch { return false; }
13
+ }
14
+
15
+ // Only generated WebP evidence inside this entry is readable by the MCP server.
16
+ // realpath prevents a symlink in an otherwise safe-looking path escaping the entry.
17
+ export function motionImagePath(dir, relative) {
18
+ if (typeof relative !== 'string' || !/^motion\/[a-z0-9-]+\/[a-z0-9-]+\.webp$/.test(relative))
19
+ throw new Error('invalid motion image path');
20
+ const root = fs.realpathSync(dir);
21
+ const file = fs.realpathSync(path.join(root, relative));
22
+ if (!file.startsWith(root + path.sep)) throw new Error('motion image escapes entry');
23
+ const stat = fs.statSync(file);
24
+ if (!stat.isFile() || stat.size > MAX_IMAGE_BYTES) throw new Error('motion image missing or too large');
25
+ return file;
26
+ }
27
+
28
+ export function localMotionPath(mediaDir, key) {
29
+ if (!KEY.test(key)) throw new Error('invalid motion object key');
30
+ return path.join(mediaDir, key);
31
+ }
32
+
33
+ export function validateMotion(entry) {
34
+ const clips = entry.fm.motion?.clips;
35
+ if (clips === undefined) return [];
36
+ if (!Array.isArray(clips) || clips.length > 12) return ['motion.clips: expected at most 12 clips'];
37
+ const errors = [], seen = new Set();
38
+ for (const [i, c] of clips.entries()) {
39
+ const prefix = `motion.clips[${i}]`;
40
+ if (!c || typeof c !== 'object') { errors.push(`${prefix}: expected a clip`); continue; }
41
+ const fail = (s) => errors.push(`${prefix}.${s}`);
42
+ if (typeof c.id !== 'string' || !ID.test(c.id) || seen.has(c.id)) fail('id: invalid or duplicate');
43
+ seen.add(c.id);
44
+ for (const field of ['title', 'notes'])
45
+ if (typeof c[field] !== 'string' || !c[field].trim()) fail(`${field}: required`);
46
+ if (!Number.isInteger(c.duration_ms) || c.duration_ms <= 0 || c.duration_ms > 15050) fail('duration_ms: expected up to 15 seconds');
47
+ if (!Number.isInteger(c.width) || c.width <= 0 || c.width > 1280) fail('width: expected up to 1280');
48
+ if (!Number.isInteger(c.height) || c.height <= 0 || c.height > 720) fail('height: expected up to 720');
49
+ if (!Number.isFinite(c.fps) || c.fps <= 0 || c.fps > 30) fail('fps: expected up to 30');
50
+ if (!Number.isInteger(c.bytes) || c.bytes <= 0) fail('bytes: required');
51
+ if (!/^[a-f0-9]{64}$/.test(c.sha256)) fail('sha256: required');
52
+ if (!KEY.test(c.key) || c.key !== `motion/${path.basename(entry.dir)}/${c.id}-${String(c.sha256).slice(0, 16)}.mp4`) fail('key: invalid');
53
+ if (c.url !== undefined && !isHttpsUrl(c.url)) fail('url: expected HTTPS without credentials');
54
+ if (!Number.isInteger(c.source?.capture) || c.source.capture < 1 || c.source.capture > (entry.fm.captured?.captures?.length ?? 1)) fail('source.capture: unknown capture');
55
+ if (!Number.isInteger(c.source?.start_ms) || c.source.start_ms < 0) fail('source.start_ms: invalid');
56
+ if (typeof c.source?.filename !== 'string' || !c.source.filename.trim() || /[/\\]/.test(c.source.filename)) fail('source.filename: expected basename');
57
+ const checkImage = (image) => {
58
+ try { motionImagePath(entry.dir, image); } catch (e) { fail(`image: ${e.message}`); }
59
+ };
60
+ checkImage(c.poster);
61
+ if (!Array.isArray(c.frames) || c.frames.length < 2 || c.frames.length > 12) { fail('frames: expected 2–12 timestamped frames'); continue; }
62
+ let previous = -1;
63
+ for (const f of c.frames) {
64
+ if (!Number.isInteger(f?.at_ms) || f.at_ms <= previous || f.at_ms >= c.duration_ms) fail('frames: timestamps must increase within the clip');
65
+ previous = f?.at_ms;
66
+ checkImage(f?.image);
67
+ }
68
+ }
69
+ return errors;
70
+ }
71
+
72
+ export function getMotion(entry, id) {
73
+ const errors = validateMotion(entry);
74
+ if (errors.length) return { refused: errors.join('; ') };
75
+ const clips = entry.fm.motion?.clips ?? [];
76
+ const clip = id ? clips.find((c) => c.id === id) : clips[0];
77
+ if (!clip) return { refused: id ? `no motion clip ${id}` : 'no motion clips for this entry' };
78
+ const slug = path.basename(entry.dir);
79
+ return {
80
+ slug, available: clips.map(({ id, title }) => ({ id, title })),
81
+ clip: { ...clip, frames: clip.frames.map((f, i) => ({
82
+ ...f, resource_uri: `library://motion/${slug}/${clip.id}/${i}`,
83
+ })) },
84
+ timing: 'Timestamps are relative to this clip. Playback is at original speed; motion notes are observed, not instrumented measurements.',
85
+ };
86
+ }
87
+
88
+ export function motionFrame(entry, clipId, frameIndex) {
89
+ const result = getMotion(entry, clipId);
90
+ if (result.refused) throw new Error(result.refused);
91
+ const frame = result.clip.frames[frameIndex];
92
+ if (!frame) throw new Error('no motion frame');
93
+ return { at_ms: frame.at_ms, data: fs.readFileSync(motionImagePath(entry.dir, frame.image)).toString('base64') };
94
+ }
package/src/ui/build.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import sharp from 'sharp';
4
5
  import { listEntries, parseEntry, validateEntry } from '../entry.mjs';
5
6
  import { loadVocab, FACETS } from '../vocab.mjs';
7
+ import { validateMotion, motionImagePath, localMotionPath } from '../motion.mjs';
6
8
 
7
9
  const DIR = path.dirname(fileURLToPath(import.meta.url));
8
10
  const TPL = path.join(DIR, 'templates');
@@ -43,7 +45,7 @@ function filterFieldset(facet, terms) {
43
45
  }
44
46
 
45
47
  function card(row) {
46
- const img = row.strip ? `<img src="${row.strip}" alt="">` : `<div class="ph"></div>`;
48
+ const img = row.thumbnail ? `<img src="${row.thumbnail}" alt="" loading="lazy" decoding="async">` : `<div class="ph"></div>`;
47
49
  const badge = row.draft ? ' <span class="badge">draft</span>' : '';
48
50
  const tags = esc([...row.roles, ...row.feel, ...row.motion].join(', '));
49
51
  const data = `data-role="${esc(row.roles.join(' '))}" data-feel="${esc(row.feel.join(' '))}" data-motion="${esc(row.motion.join(' '))}"`;
@@ -61,7 +63,68 @@ function measurementsHtml(dir) {
61
63
  `<p>${(m.sections ?? []).length} sections measured.</p>`;
62
64
  }
63
65
 
64
- function writeEntryPage(outDir, entry, row) {
66
+ // source.url and capture.input come out of an entry file — data this server did not write.
67
+ // esc() stops attribute breakout but not a `javascript:` or `data:` scheme in an href, and
68
+ // this is the first data-driven href in the template. Parse with URL and check protocol
69
+ // rather than matching a prefix, because URL normalises the cases a prefix check misses.
70
+ function isWebUrl(s) {
71
+ try { return ['http:', 'https:'].includes(new URL(s).protocol); } catch { return false; }
72
+ }
73
+
74
+ function linkify(value) {
75
+ const s = String(value ?? '').trim();
76
+ if (!s) return '';
77
+ return isWebUrl(s) ? `<a href="${esc(s)}" rel="noreferrer">${esc(s)}</a>` : esc(s);
78
+ }
79
+
80
+ // Named fieldRow, not row: writeEntryPage's third parameter is already called `row` (the
81
+ // gallery row object), and that parameter would shadow a module-level `row` for the whole
82
+ // function body — silently turning `row('Site', ...)` into a call on a plain object.
83
+ function fieldRow(label, html) {
84
+ return html ? `<dt>${esc(label)}</dt><dd>${html}</dd>` : '';
85
+ }
86
+
87
+ // A local capture shows its basename only: an absolute path on the ingesting machine is
88
+ // neither useful to a reader nor something to publish. A capture input is entry-file data
89
+ // exactly like source.url, so it goes through the same protocol guard — not a second,
90
+ // looser one. A prefix match would also miss `HTTPS://…`, which URL normalises.
91
+ function captureLabel(input) {
92
+ const s = String(input ?? '').trim();
93
+ if (!s) return '';
94
+ return isWebUrl(s) ? linkify(s) : esc(s.split('/').pop());
95
+ }
96
+
97
+ function motionHtml(outDir, entry, previewMediaDir) {
98
+ const clips = entry.fm.motion?.clips ?? [];
99
+ if (!clips.length || validateMotion(entry).length) return '';
100
+ const slug = path.basename(entry.dir);
101
+ const figures = clips.map((clip) => {
102
+ const targetDir = path.join(outDir, 'motion', slug, clip.id);
103
+ fs.mkdirSync(targetDir, { recursive: true });
104
+ const imageUrl = (relative) => {
105
+ const name = path.basename(relative);
106
+ fs.copyFileSync(motionImagePath(entry.dir, relative), path.join(targetDir, name));
107
+ return `../motion/${slug}/${clip.id}/${name}`;
108
+ };
109
+ const poster = imageUrl(clip.poster);
110
+ let video = clip.url;
111
+ if (!video && previewMediaDir) {
112
+ const local = localMotionPath(previewMediaDir, clip.key);
113
+ if (fs.existsSync(local)) {
114
+ fs.copyFileSync(local, path.join(targetDir, 'clip.mp4'));
115
+ video = `../motion/${slug}/${clip.id}/clip.mp4`;
116
+ }
117
+ }
118
+ const player = video
119
+ ? `<video controls playsinline preload="none" poster="${esc(poster)}" width="${clip.width}" height="${clip.height}" aria-label="${esc(clip.title)}"><source src="${esc(video)}" type="video/mp4"></video>`
120
+ : `<img src="${esc(poster)}" alt="${esc(clip.title)}" loading="lazy" decoding="async">`;
121
+ const frames = clip.frames.map((frame) => `<figure><img src="${esc(imageUrl(frame.image))}" alt="${esc(clip.title)} at ${(frame.at_ms / 1000).toFixed(2)} seconds" loading="lazy" decoding="async"><figcaption>${(frame.at_ms / 1000).toFixed(2)}s</figcaption></figure>`).join('');
122
+ return `<section class="motion-clip"><h3>${esc(clip.title)}</h3>${player}<p>${esc(clip.notes)}</p><p>${(clip.duration_ms / 1000).toFixed(1)} seconds · Original speed</p><details><summary>Timestamped frames</summary><div class="motion-frames">${frames}</div></details></section>`;
123
+ });
124
+ return `<section aria-label="Motion previews"><h2>Motion previews</h2>${figures.join('\n')}</section>`;
125
+ }
126
+
127
+ function writeEntryPage(outDir, entry, row, previewMediaDir) {
65
128
  const tpl = fs.readFileSync(path.join(TPL, 'entry.html'), 'utf8');
66
129
  const html = render(tpl, {
67
130
  TITLE: esc(row.title), TIER: esc(row.tier), ROLES: esc(row.roles.join(', ')), FEEL: esc(row.feel.join(', ')),
@@ -69,6 +132,10 @@ function writeEntryPage(outDir, entry, row) {
69
132
  CAPTURED: esc(row.captured), STRIP_IMG: row.strip ? `<img src="../${row.strip}" alt="">` : '',
70
133
  DRAFT_BADGE: row.draft ? '<span class="badge">draft</span>' : '', BODY: mdToHtml(entry.body),
71
134
  MEASUREMENTS: measurementsHtml(entry.dir),
135
+ MOTION_CLIPS: motionHtml(outDir, entry, previewMediaDir),
136
+ SITE_ROW: fieldRow('Site', linkify(entry.fm.source?.url)),
137
+ CAPTURES_ROW: fieldRow('Captures', (entry.fm.captured?.captures ?? [])
138
+ .map((c) => `${esc(c.method)}: ${captureLabel(c.input)}`).join('<br>')),
72
139
  });
73
140
  fs.writeFileSync(path.join(outDir, 'entry', `${row.slug}.html`), html);
74
141
  }
@@ -76,12 +143,13 @@ function writeEntryPage(outDir, entry, row) {
76
143
  // `indexPath` is accepted for interface symmetry with buildIndex but unused: a draft is an
77
144
  // entry that fails validateEntry, and buildIndex never puts a failing entry in the sqlite
78
145
  // index — so entries/ is the one place both valid and draft entries can be read from.
79
- export async function buildUi({ indexPath, entriesDir, outDir }) {
146
+ export async function buildUi({ indexPath, entriesDir, outDir, previewMediaDir }) {
80
147
  void indexPath;
81
148
  const vocab = loadVocab();
82
149
  fs.rmSync(outDir, { recursive: true, force: true });
83
150
  fs.mkdirSync(path.join(outDir, 'entry'), { recursive: true });
84
151
  fs.mkdirSync(path.join(outDir, 'strips'), { recursive: true });
152
+ fs.mkdirSync(path.join(outDir, 'thumbnails'), { recursive: true });
85
153
 
86
154
  const rows = [];
87
155
  for (const dir of listEntries(entriesDir)) {
@@ -93,9 +161,15 @@ export async function buildUi({ indexPath, entriesDir, outDir }) {
93
161
  // slug === dir name, so fm.slug can carry path-traversal or markup and must never
94
162
  // reach a path or an unescaped attribute.
95
163
  const slug = path.basename(dir);
96
- const stripSrc = path.join(dir, 'strip.png');
164
+ const stripSrc = path.join(dir, 'strip.webp');
97
165
  const hasStrip = fs.existsSync(stripSrc);
98
- if (hasStrip) fs.copyFileSync(stripSrc, path.join(outDir, 'strips', `${slug}.png`));
166
+ if (hasStrip) {
167
+ fs.copyFileSync(stripSrc, path.join(outDir, 'strips', `${slug}.webp`));
168
+ // The index is a browsing surface. Its previews must not make each visitor
169
+ // download a multi-frame source sheet; the full strip remains on the detail page.
170
+ await sharp(stripSrc).resize(640, 480, { fit: 'cover', position: 'north' })
171
+ .webp({ quality: 72, effort: 4 }).toFile(path.join(outDir, 'thumbnails', `${slug}.webp`));
172
+ }
99
173
  const fm = entry.fm;
100
174
  const capturedAt = fm.captured?.at;
101
175
  const captured = capturedAt instanceof Date ? capturedAt.toISOString().slice(0, 10) : String(capturedAt ?? '');
@@ -104,10 +178,12 @@ export async function buildUi({ indexPath, entriesDir, outDir }) {
104
178
  const row = {
105
179
  slug, title: fm.title || fm.slug || slug, tier: fm.tier || '', roles: fm.roles ?? [], feel: fm.feel ?? [],
106
180
  motion: fm.motion?.devices ?? [], source: fm.source?.kind || '', captured,
107
- strip: hasStrip ? `strips/${slug}.png` : null, draft: !ok,
181
+ strip: hasStrip ? `strips/${slug}.webp` : null,
182
+ thumbnail: hasStrip ? `thumbnails/${slug}.webp` : null,
183
+ draft: !ok,
108
184
  };
109
185
  rows.push(row);
110
- writeEntryPage(outDir, entry, row);
186
+ writeEntryPage(outDir, entry, row, previewMediaDir);
111
187
  }
112
188
  rows.sort((a, b) => b.captured.localeCompare(a.captured));
113
189
 
package/src/ui/serve.mjs CHANGED
@@ -7,6 +7,8 @@ const MIME = {
7
7
  '.js': 'text/javascript; charset=utf-8',
8
8
  '.json': 'application/json; charset=utf-8',
9
9
  '.png': 'image/png',
10
+ '.webp': 'image/webp',
11
+ '.mp4': 'video/mp4',
10
12
  };
11
13
 
12
14
  // Serves one file under `root`. Takes the raw request-target (`req.url`, or the
@@ -14,7 +16,7 @@ const MIME = {
14
16
  // `%2e%2e` and `..` to the same normalized path *before* any code here runs, which
15
17
  // would make a prefix check look load-bearing while actually never seeing a path
16
18
  // that leaves `root`. Decoding and joining ourselves is what actually blocks it.
17
- export function serveFile(root, rawPath, res) {
19
+ export function serveFile(root, rawPath, res, req = {}) {
18
20
  const pathname = rawPath.split('?')[0];
19
21
  let rel;
20
22
  try { rel = decodeURIComponent(pathname); } catch { res.writeHead(404); return res.end(); }
@@ -22,15 +24,38 @@ export function serveFile(root, rawPath, res) {
22
24
  const file = path.join(root, rel);
23
25
  if (file !== root && !file.startsWith(root + path.sep)) { res.writeHead(404); return res.end(); }
24
26
  if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end(); }
25
- res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' });
26
- fs.createReadStream(file).pipe(res);
27
+ const size = fs.statSync(file).size;
28
+ const headers = { 'content-type': MIME[path.extname(file)] || 'application/octet-stream', 'content-length': size };
29
+ let start = 0, end = size - 1, status = 200;
30
+ if (path.extname(file) === '.mp4') {
31
+ headers['accept-ranges'] = 'bytes';
32
+ if (req.headers?.range && req.method !== 'HEAD') {
33
+ const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
34
+ if (match && (match[1] || match[2])) {
35
+ start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]));
36
+ end = match[1] && match[2] ? Math.min(size - 1, Number(match[2])) : size - 1;
37
+ } else start = size;
38
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= size || end < start) {
39
+ res.writeHead(416, { 'content-range': `bytes */${size}` }); return res.end();
40
+ }
41
+ status = 206;
42
+ headers['content-range'] = `bytes ${start}-${end}/${size}`;
43
+ headers['content-length'] = end - start + 1;
44
+ }
45
+ }
46
+ res.writeHead(status, headers);
47
+ if (req.method === 'HEAD' || size === 0) return res.end();
48
+ const stream = fs.createReadStream(file, { start, end });
49
+ stream.on('error', () => res.destroy());
50
+ res.on('close', () => stream.destroy());
51
+ stream.pipe(res);
27
52
  }
28
53
 
29
54
  // A bare static server for `library ui --serve` — same content http.mjs serves at `/`,
30
55
  // without `/mcp` or `/healthz`.
31
56
  export async function serveStatic(root, port) {
32
57
  const srv = http.createServer((req, res) => {
33
- serveFile(root, req.url, res);
58
+ serveFile(root, req.url, res, req);
34
59
  });
35
60
  await new Promise((resolve, reject) => { srv.once('error', reject); srv.listen(port, resolve); });
36
61
  process.stderr.write(`wp-design-library: ui on :${port}\n`);
@@ -6,7 +6,12 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1">
7
7
  <style>
8
8
  body { font: 14px/1.5 system-ui, sans-serif; max-width: 60rem; margin: 0 auto; padding: 1rem; }
9
- img { max-width: 100%; border-radius: 8px; }
9
+ img, video { max-width: 100%; border-radius: 8px; }
10
+ video { display: block; width: 100%; height: auto; background: #171717; }
11
+ .motion-clip { margin: 1.5rem 0; }
12
+ .motion-frames { display: flex; gap: .75rem; overflow-x: auto; padding: .75rem 0; }
13
+ .motion-frames figure { flex: 0 0 200px; margin: 0; }
14
+ .motion-frames figcaption { font-variant-numeric: tabular-nums; }
10
15
  dl { display: grid; grid-template-columns: 8rem 1fr; gap: .25rem 1rem; }
11
16
  dt { font-weight: 600; }
12
17
  .badge { background: #d33; color: #fff; font-size: .7em; padding: .1em .4em; border-radius: 4px; }
@@ -15,6 +20,7 @@
15
20
  <body>
16
21
  <p><a href="../index.html">&larr; back to the gallery</a></p>
17
22
  <h1>{{TITLE}} {{DRAFT_BADGE}}</h1>
23
+ {{MOTION_CLIPS}}
18
24
  {{STRIP_IMG}}
19
25
  <dl>
20
26
  <dt>Tier</dt><dd>{{TIER}}</dd>
@@ -22,8 +28,10 @@
22
28
  <dt>Feel</dt><dd>{{FEEL}}</dd>
23
29
  <dt>Motion</dt><dd>{{MOTION}}</dd>
24
30
  <dt>Source</dt><dd>{{SOURCE}}</dd>
31
+ {{SITE_ROW}}
25
32
  <dt>Licence</dt><dd>{{LICENSE}}</dd>
26
33
  <dt>Captured</dt><dd>{{CAPTURED}}</dd>
34
+ {{CAPTURES_ROW}}
27
35
  </dl>
28
36
  {{MEASUREMENTS}}
29
37
  {{BODY}}
package/tests/README.md DELETED
@@ -1,33 +0,0 @@
1
- # Check ports
2
-
3
- A check that starts `serve --http` picks its own `PORT`, unique across the suite, so
4
- checks can run concurrently without colliding on a listening socket.
5
-
6
- | Check | Port |
7
- |---|---|
8
- | fetch-on-start.sh | 4189 |
9
- | minors.sh | 4190 |
10
- | similar.sh | 4191 |
11
- | ui-build.sh | 4193 |
12
- | hygiene.sh | 4195 |
13
- | seed-sync.sh | 4196 |
14
- | docker.sh | 4198 |
15
- | http.sh | 4199 |
16
-
17
- A check that never calls `serve --http` (everything else under `tests/checks/`) needs
18
- no port; it exercises `add`/`export`/`index`/`check`/`save` directly or drives `serve`
19
- (no `--http`) over stdio.
20
-
21
- A new serve-based check takes the next free port below 4191.
22
-
23
- ## Checks with no port
24
-
25
- `instructions.sh`, `entry-detail.sh` and `recommend.sh` drive the server over stdio
26
- (`serve`, no `--http`) and start no server on a fixed port, so the table above is
27
- unchanged by them.
28
-
29
- | Check | Asserts |
30
- |---|---|
31
- | instructions.sh | the `initialize` response's `instructions`: present, under the 1500-char budget, and carrying the colour fence, the project-wins line, the call budget, and the `ranks.fts` explanation |
32
- | entry-detail.sh | `get_entry`'s `detail: "summary"` returns a strictly shorter body than `detail: "full"` for the same slug, with matching frontmatter and strip path |
33
- | recommend.sh | `recommend` returns one group per vocabulary role, weak hits marked, no prose bodies, and the same named-reason degradation `search` already gives when the vector arm is absent |
@@ -1,13 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -eu
3
- cd "$(dirname "$0")/../.."
4
- tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
5
- mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
6
- LIBRARY_DATA="$tmp" node bin/library.mjs check | grep -q "1/1 entries ok" || { echo "FAIL: clean corpus"; exit 1; }
7
- sed -i 's/kind: open/kind: paid/; s/tier: inspiration/tier: ported/' "$tmp/entries/entry-ok/entry.md"
8
- if LIBRARY_DATA="$tmp" node bin/library.mjs check >"$tmp/out" 2>&1; then echo "FAIL: gate not enforced by check"; exit 1; fi
9
- grep -q "entry-ok: licence gate" "$tmp/out" || { echo "FAIL: error line shape"; cat "$tmp/out"; exit 1; }
10
- mkdir -p "$tmp/entries/broken"; echo "no frontmatter here" > "$tmp/entries/broken/entry.md"
11
- if LIBRARY_DATA="$tmp" node bin/library.mjs check >"$tmp/out" 2>&1; then echo "FAIL: parse error not detected"; exit 1; fi
12
- grep -q "^broken: " "$tmp/out" || { echo "FAIL: parse error line shape"; cat "$tmp/out"; exit 1; }
13
- echo PASS
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -eu
3
- cd "$(dirname "$0")/../.."
4
- command -v docker >/dev/null || { echo "SKIP: docker not installed"; exit 0; }
5
- docker build -q -t wp-design-library:check . >/dev/null
6
- cid=""; trap '[ -n "$cid" ] && docker stop "$cid" >/dev/null 2>&1 || true' EXIT
7
- cid=$(docker run -d --rm -e LIBRARY_TOKEN=t0k -p 4198:4180 wp-design-library:check)
8
- for i in $(seq 1 60); do curl -sf localhost:4198/healthz >/dev/null 2>&1 && break; sleep 1; done
9
- curl -sf localhost:4198/healthz | grep -q '"ok":true' || { echo "FAIL: container healthz"; docker logs "$cid"; exit 1; }
10
- echo PASS
@@ -1,52 +0,0 @@
1
- #!/usr/bin/env bash
2
- # summary returns a strictly shorter body than full for the same slug, and both
3
- # carry the same frontmatter and strip path — a summary that dropped either would
4
- # be a different tool, not a cheaper one.
5
- set -eu
6
- cd "$(dirname "$0")/../.."
7
- tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; trap 'rm -rf "$tmp"' EXIT
8
- mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
9
- LIBRARY_DATA="$tmp" node bin/library.mjs index >/dev/null 2>&1
10
- {
11
- printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
12
- printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
13
- printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_entry","arguments":{"slug":"entry-ok"}}}'
14
- printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_entry","arguments":{"slug":"entry-ok","detail":"summary"}}}'
15
- sleep 1
16
- } | LIBRARY_DATA="$tmp" timeout 20 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
17
-
18
- node --input-type=module -e '
19
- import fs from "node:fs";
20
- const byId = {};
21
- for (const line of fs.readFileSync(process.argv[1], "utf8").split("\n")) {
22
- if (!line.startsWith("{")) continue;
23
- const d = JSON.parse(line);
24
- if (d.id) byId[d.id] = d;
25
- }
26
- const fail = (m) => { console.error("FAIL: " + m); process.exit(1); };
27
- const full = byId[2]?.result?.structuredContent;
28
- const sum = byId[3]?.result?.structuredContent;
29
- if (!full) fail("no full result");
30
- if (!sum) fail("no summary result");
31
- if (typeof sum.body !== "string") fail("summary has no body");
32
- if (!(sum.body.length < full.body.length)) fail(`summary body (${sum.body.length}) is not shorter than full (${full.body.length})`);
33
- if (/^\s*#/.test(sum.body)) fail(`summary is a bare heading, not prose: ${JSON.stringify(sum.body)}`);
34
- if (/\n\s*\n/.test(sum.body)) fail(`summary spans a paragraph break, so it is a truncation not a paragraph: ${JSON.stringify(sum.body)}`);
35
- if (/^\s*#/m.test(sum.body)) fail(`summary contains a heading line: ${JSON.stringify(sum.body)}`);
36
- if (sum.body.trim().length < 20) fail(`summary is too short to decide from: ${JSON.stringify(sum.body)}`);
37
- if (sum.detail !== "summary") fail("summary result is not labelled detail:summary");
38
- if (sum.strip !== full.strip) fail("summary lost the strip path");
39
- if (JSON.stringify(sum.fm) !== JSON.stringify(full.fm)) fail("summary changed the frontmatter");
40
- // A splitter that FILTERS every prose paragraph and joins them (instead of taking
41
- // just the first) can still pass every assertion above: joined with a space instead
42
- // of a blank line, it dodges the paragraph-break check, and it is still shorter than
43
- // full because only headings were dropped. Pin the actual promise: summary is a
44
- // PREFIX of full body, and a later prose paragraph never appears in it.
45
- if (!full.body.includes(sum.body)) fail(`summary is not a substring of full body: ${JSON.stringify(sum.body)}`);
46
- const fullProse = full.body.trim().split(/\n\s*\n/).filter((x) => !/^\s*#/.test(x));
47
- if (fullProse.length < 2) fail("fixture needs more than one prose paragraph to exercise this assertion");
48
- const later = fullProse[fullProse.length - 1];
49
- if (sum.body.includes(later)) fail(`summary includes a later prose paragraph, so it did not stop at the first: ${JSON.stringify(later)}`);
50
- console.log(`full ${full.body.length} chars, summary ${sum.body.length}`);
51
- ' "$tmp/out" || { cat "$tmp/err"; exit 1; }
52
- echo PASS
@@ -1,55 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -eu
3
- cd "$(dirname "$0")/../.."
4
- node --input-type=module -e '
5
- import { parseEntry, validateEntry } from "./src/entry.mjs";
6
- import { loadVocab } from "./src/vocab.mjs";
7
- const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
8
- const v = loadVocab();
9
- const e = parseEntry("tests/fixtures/entry-ok");
10
- must(e.slug === "entry-ok", "slug from frontmatter");
11
- must(e.body.includes("## Section roster"), "body parsed");
12
- let r = validateEntry(e, v);
13
- must(r.ok, "fixture validates: " + r.errors.join("; "));
14
- e.fm.roles = ["hero", "neon"];
15
- r = validateEntry(e, v);
16
- must(!r.ok && r.errors.some(x => x.includes("role") && x.includes("neon")), "unknown tag names facet and term");
17
- e.fm.roles = ["plans"];
18
- r = validateEntry(e, v);
19
- must(r.ok && e.fm.roles[0] === "offer", "alias resolved to canonical in place");
20
- e.fm.title = "";
21
- r = validateEntry(e, v);
22
- must(!r.ok && r.errors.some(x => x.includes("title")), "blank required field named");
23
- '
24
-
25
- # Test CRLF line endings support
26
- tmpdir=$(mktemp -d)
27
- trap "rm -rf $tmpdir" EXIT
28
- mkdir -p "$tmpdir/entry-ok"
29
- cp tests/fixtures/entry-ok/entry.md "$tmpdir/entry-ok/entry.md"
30
- sed -i "s/$/\r/" "$tmpdir/entry-ok/entry.md"
31
- cp tests/fixtures/entry-ok/strip.png "$tmpdir/entry-ok/strip.png"
32
- node --input-type=module -e "
33
- import { parseEntry, validateEntry } from './src/entry.mjs';
34
- import { loadVocab } from './src/vocab.mjs';
35
- const must = (c, m) => { if (!c) { console.error('FAIL: ' + m); process.exit(1); } };
36
- const v = loadVocab();
37
- const e = parseEntry('$tmpdir/entry-ok');
38
- must(e.slug === 'entry-ok', 'CRLF: slug parsed');
39
- must(validateEntry(e, v).ok, 'CRLF: validates');
40
- "
41
-
42
- # slug must equal the directory name
43
- mkdir -p "$tmpdir/other-name"
44
- cp tests/fixtures/entry-ok/entry.md tests/fixtures/entry-ok/strip.png "$tmpdir/other-name/"
45
- node --input-type=module -e "
46
- import { parseEntry, validateEntry } from './src/entry.mjs';
47
- import { loadVocab } from './src/vocab.mjs';
48
- const must = (c, m) => { if (!c) { console.error('FAIL: ' + m); process.exit(1); } };
49
- const v = loadVocab();
50
- const e = parseEntry('$tmpdir/other-name');
51
- const r = validateEntry(e, v);
52
- must(!r.ok && r.errors.some(x => x.includes('slug: must equal')), 'slug mismatch refused');
53
- "
54
-
55
- echo PASS
@@ -1,16 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -eu
3
- cd "$(dirname "$0")/../.."
4
- node_bin=$(command -v node)
5
- tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
6
- mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
7
- LIBRARY_DATA="$tmp" node bin/library.mjs export > "$tmp/out.tar" 2>"$tmp/err"
8
- tar -tf "$tmp/out.tar" | grep -q 'entries/entry-ok/entry.md' || { echo "FAIL: tar content"; exit 1; }
9
- grep -q "export: 1 entries" "$tmp/err" || { echo "FAIL: count on stderr"; exit 1; }
10
-
11
- emptybin="$tmp/emptybin"; mkdir -p "$emptybin"
12
- code=0
13
- PATH="$emptybin" LIBRARY_DATA="$tmp" "$node_bin" bin/library.mjs export > "$tmp/out2.tar" 2>"$tmp/err2" || code=$?
14
- [ "$code" -ne 0 ] || { echo "FAIL: missing tar should exit non-zero"; exit 1; }
15
- grep -q "tar is required" "$tmp/err2" || { echo "FAIL: missing tar message"; cat "$tmp/err2"; exit 1; }
16
- echo PASS