@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.
- package/.env.example +7 -0
- package/README.md +52 -9
- package/bin/library.mjs +11 -0
- package/entries/ada/entry.md +55 -0
- package/entries/ada/strip.webp +0 -0
- package/entries/air/captures/2/measure.json +325 -0
- package/entries/air/captures/2/strip.webp +0 -0
- package/entries/air/entry.md +62 -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 +91 -0
- package/entries/apple-home/captures/2/strip.webp +0 -0
- package/entries/apple-home/entry.md +64 -0
- package/entries/apple-home/strip.webp +0 -0
- package/entries/apple-macbook-neo/captures/2/measure.json +570 -0
- package/entries/apple-macbook-neo/captures/2/strip.webp +0 -0
- package/entries/apple-macbook-neo/entry.md +62 -0
- package/entries/apple-macbook-neo/strip.webp +0 -0
- package/entries/apple-watch-ultra-4/entry.md +63 -0
- package/entries/apple-watch-ultra-4/measure.json +481 -0
- 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/entry.md +81 -0
- package/entries/bpco/strip.webp +0 -0
- package/entries/column/captures/2/measure.json +996 -0
- package/entries/column/captures/2/strip.webp +0 -0
- package/entries/column/entry.md +82 -0
- package/entries/column/strip.webp +0 -0
- package/entries/cthdrl/captures/2/measure.json +251 -0
- package/entries/cthdrl/captures/2/strip.webp +0 -0
- package/entries/cthdrl/entry.md +86 -0
- package/entries/cthdrl/strip.webp +0 -0
- package/entries/dala-craftedbygc/entry.md +69 -0
- package/entries/dala-craftedbygc/strip.webp +0 -0
- package/entries/dock/captures/2/measure.json +206 -0
- package/entries/dock/captures/2/strip.webp +0 -0
- package/entries/dock/entry.md +62 -0
- package/entries/dock/strip.webp +0 -0
- package/entries/dub/captures/2/measure.json +37 -0
- package/entries/dub/captures/2/strip.webp +0 -0
- package/entries/dub/entry.md +61 -0
- package/entries/dub/strip.webp +0 -0
- package/entries/duolingo/captures/2/measure.json +138 -0
- package/entries/duolingo/captures/2/strip.webp +0 -0
- package/entries/duolingo/entry.md +65 -0
- package/entries/duolingo/strip.webp +0 -0
- package/entries/elevenlabs/captures/2/measure.json +160 -0
- package/entries/elevenlabs/captures/2/strip.webp +0 -0
- package/entries/elevenlabs/entry.md +62 -0
- package/entries/elevenlabs/strip.webp +0 -0
- package/entries/flying-papers/captures/2/measure.json +291 -0
- package/entries/flying-papers/captures/2/strip.webp +0 -0
- package/entries/flying-papers/entry.md +61 -0
- package/entries/flying-papers/strip.webp +0 -0
- package/entries/gsap/captures/2/measure.json +120 -0
- package/entries/gsap/captures/2/strip.webp +0 -0
- package/entries/gsap/entry.md +62 -0
- package/entries/gsap/strip.webp +0 -0
- package/entries/letters/captures/2/measure.json +20 -0
- package/entries/letters/captures/2/strip.webp +0 -0
- package/entries/letters/entry.md +62 -0
- package/entries/letters/strip.webp +0 -0
- package/entries/micro/captures/2/measure.json +852 -0
- package/entries/micro/captures/2/strip.webp +0 -0
- package/entries/micro/entry.md +61 -0
- package/entries/micro/strip.webp +0 -0
- package/entries/monopo-saigon/captures/2/measure.json +145 -0
- package/entries/monopo-saigon/captures/2/strip.webp +0 -0
- package/entries/monopo-saigon/entry.md +64 -0
- package/entries/monopo-saigon/strip.webp +0 -0
- package/entries/notion/captures/2/measure.json +338 -0
- package/entries/notion/captures/2/strip.webp +0 -0
- package/entries/notion/entry.md +66 -0
- package/entries/notion/strip.webp +0 -0
- package/entries/origin-financial/captures/2/measure.json +169 -0
- package/entries/origin-financial/captures/2/strip.webp +0 -0
- package/entries/origin-financial/entry.md +65 -0
- package/entries/origin-financial/strip.webp +0 -0
- package/entries/oryzo-ai/captures/2/measure.json +39 -0
- package/entries/oryzo-ai/captures/2/strip.webp +0 -0
- package/entries/oryzo-ai/entry.md +76 -0
- package/entries/oryzo-ai/strip.webp +0 -0
- package/entries/silencio/captures/2/strip.webp +0 -0
- package/entries/silencio/entry.md +114 -0
- package/entries/silencio/motion/product-story/frame-0.webp +0 -0
- package/entries/silencio/motion/product-story/frame-1.webp +0 -0
- package/entries/silencio/motion/product-story/frame-2.webp +0 -0
- package/entries/silencio/motion/product-story/frame-3.webp +0 -0
- package/entries/silencio/motion/product-story/frame-4.webp +0 -0
- package/entries/silencio/motion/product-story/frame-5.webp +0 -0
- package/entries/silencio/motion/product-story/frame-6.webp +0 -0
- package/entries/silencio/motion/product-story/frame-7.webp +0 -0
- package/entries/silencio/strip.webp +0 -0
- package/entries/steep/captures/2/measure.json +20 -0
- package/entries/steep/captures/2/strip.webp +0 -0
- package/entries/steep/entry.md +63 -0
- package/entries/steep/strip.webp +0 -0
- package/entries/stripe/captures/2/measure.json +892 -0
- package/entries/stripe/captures/2/strip.webp +0 -0
- package/entries/stripe/entry.md +61 -0
- package/entries/stripe/strip.webp +0 -0
- package/entries/superpower/captures/2/measure.json +179 -0
- package/entries/superpower/captures/2/strip.webp +0 -0
- package/entries/superpower/entry.md +64 -0
- package/entries/superpower/strip.webp +0 -0
- package/entries/superr/entry.md +54 -0
- package/entries/superr/strip.webp +0 -0
- package/entries/svz-weare/captures/2/measure.json +129 -0
- package/entries/svz-weare/captures/2/strip.webp +0 -0
- package/entries/svz-weare/entry.md +147 -0
- package/entries/svz-weare/motion/hero-transition/frame-0.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-1.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-2.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-3.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-4.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-5.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-6.webp +0 -0
- package/entries/svz-weare/motion/hero-transition/frame-7.webp +0 -0
- package/entries/svz-weare/strip.webp +0 -0
- package/entries/thoughtlab/captures/2/measure.json +93 -0
- package/entries/thoughtlab/captures/2/strip.webp +0 -0
- package/entries/thoughtlab/entry.md +62 -0
- package/entries/thoughtlab/strip.webp +0 -0
- package/entries/ventriloc/entry.md +54 -0
- package/entries/ventriloc/strip.webp +0 -0
- package/entries/vivid-and-co/entry.md +58 -0
- package/entries/vivid-and-co/strip.webp +0 -0
- package/package.json +24 -5
- package/src/cli/add.mjs +100 -24
- package/src/cli/motion.mjs +168 -0
- package/src/cli/refresh.mjs +6 -0
- package/src/cli/ui.mjs +2 -1
- package/src/entry.mjs +14 -1
- package/src/index/build.mjs +9 -3
- package/src/index/embed.mjs +1 -1
- package/src/ingest/agreement.mjs +33 -0
- package/src/ingest/attach.mjs +67 -0
- package/src/ingest/draft.mjs +5 -2
- package/src/ingest/frames.mjs +20 -6
- package/src/ingest/measure.mjs +34 -1
- package/src/ingest/observed.mjs +29 -0
- package/src/mcp/http.mjs +1 -1
- package/src/mcp/instructions.mjs +2 -1
- package/src/mcp/prompts.mjs +12 -1
- package/src/mcp/resources.mjs +14 -0
- package/src/mcp/tools.mjs +48 -5
- package/src/motion.mjs +94 -0
- package/src/ui/build.mjs +83 -7
- package/src/ui/serve.mjs +29 -4
- package/src/ui/templates/entry.html +9 -1
- package/entries/ais-community-dark-depth/strip.png +0 -0
- package/tests/README.md +0 -33
- package/tests/checks/cli-check.sh +0 -13
- package/tests/checks/docker.sh +0 -10
- package/tests/checks/entry-detail.sh +0 -52
- package/tests/checks/entry.sh +0 -55
- package/tests/checks/export.sh +0 -16
- package/tests/checks/fetch-on-start.sh +0 -26
- package/tests/checks/frames-dense.sh +0 -27
- package/tests/checks/http.sh +0 -22
- package/tests/checks/hygiene.sh +0 -29
- package/tests/checks/inbox.sh +0 -27
- package/tests/checks/index-degrade.sh +0 -32
- package/tests/checks/index.sh +0 -36
- package/tests/checks/ingest-mp4.sh +0 -47
- package/tests/checks/ingest.sh +0 -39
- package/tests/checks/instructions.sh +0 -31
- package/tests/checks/licence-gate.sh +0 -24
- package/tests/checks/mcp-stdout.sh +0 -31
- package/tests/checks/measure.sh +0 -79
- package/tests/checks/minors.sh +0 -84
- package/tests/checks/optional-deps.sh +0 -55
- package/tests/checks/prompt-add-entry.sh +0 -28
- package/tests/checks/recommend.sh +0 -107
- package/tests/checks/resources.sh +0 -47
- package/tests/checks/rrf.sh +0 -120
- package/tests/checks/seed-sync.sh +0 -22
- package/tests/checks/similar.sh +0 -62
- package/tests/checks/ui-build.sh +0 -59
- package/tests/checks/vocab.sh +0 -30
- package/tests/fixtures/entry-ok/entry.md +0 -41
- package/tests/fixtures/entry-ok/strip.png +0 -0
- package/tests/fixtures/page/index.html +0 -47
- package/tests/fixtures/three-frame.webp +0 -0
- 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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
164
|
+
const stripSrc = path.join(dir, 'strip.webp');
|
|
97
165
|
const hasStrip = fs.existsSync(stripSrc);
|
|
98
|
-
if (hasStrip)
|
|
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}.
|
|
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
|
-
|
|
26
|
-
|
|
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">← 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}}
|
|
Binary file
|
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
|
package/tests/checks/docker.sh
DELETED
|
@@ -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
|
package/tests/checks/entry.sh
DELETED
|
@@ -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
|
package/tests/checks/export.sh
DELETED
|
@@ -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
|