@yojahny/wp-design-library 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +11 -0
- package/.env.example +7 -0
- package/Dockerfile +27 -0
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/bin/library.mjs +19 -0
- package/docker/entrypoint.sh +11 -0
- package/docker-compose.yml +18 -0
- package/entries/.gitkeep +0 -0
- package/entries/ais-community-dark-depth/entry.md +76 -0
- package/entries/ais-community-dark-depth/strip.png +0 -0
- package/package.json +26 -0
- package/src/cli/add.mjs +163 -0
- package/src/cli/check.mjs +26 -0
- package/src/cli/export.mjs +41 -0
- package/src/cli/index.mjs +12 -0
- package/src/cli/refresh.mjs +63 -0
- package/src/cli/save.mjs +16 -0
- package/src/cli/serve.mjs +64 -0
- package/src/cli/ui.mjs +18 -0
- package/src/entry.mjs +69 -0
- package/src/index/build.mjs +78 -0
- package/src/index/embed.mjs +69 -0
- package/src/index/query.mjs +103 -0
- package/src/index/schema-vec.sql +4 -0
- package/src/index/schema.sql +10 -0
- package/src/ingest/draft.mjs +32 -0
- package/src/ingest/frames.mjs +93 -0
- package/src/ingest/measure.mjs +158 -0
- package/src/ingest/save.mjs +11 -0
- package/src/ingest/url-guard.mjs +76 -0
- package/src/mcp/http.mjs +72 -0
- package/src/mcp/prompts.mjs +147 -0
- package/src/mcp/resources.mjs +24 -0
- package/src/mcp/server.mjs +28 -0
- package/src/mcp/tools.mjs +152 -0
- package/src/paths.mjs +21 -0
- package/src/ui/app.js +34 -0
- package/src/ui/build.mjs +122 -0
- package/src/ui/serve.mjs +38 -0
- package/src/ui/templates/entry.html +31 -0
- package/src/ui/templates/index.html +32 -0
- package/src/vocab.mjs +34 -0
- package/tests/README.md +21 -0
- package/tests/checks/cli-check.sh +13 -0
- package/tests/checks/docker.sh +10 -0
- package/tests/checks/entry.sh +55 -0
- package/tests/checks/export.sh +16 -0
- package/tests/checks/fetch-on-start.sh +26 -0
- package/tests/checks/frames-dense.sh +26 -0
- package/tests/checks/http.sh +22 -0
- package/tests/checks/hygiene.sh +29 -0
- package/tests/checks/inbox.sh +27 -0
- package/tests/checks/index-degrade.sh +32 -0
- package/tests/checks/index.sh +36 -0
- package/tests/checks/ingest-mp4.sh +47 -0
- package/tests/checks/ingest.sh +39 -0
- package/tests/checks/licence-gate.sh +24 -0
- package/tests/checks/mcp-stdout.sh +31 -0
- package/tests/checks/measure.sh +75 -0
- package/tests/checks/minors.sh +84 -0
- package/tests/checks/prompt-add-entry.sh +28 -0
- package/tests/checks/resources.sh +44 -0
- package/tests/checks/rrf.sh +120 -0
- package/tests/checks/seed-sync.sh +22 -0
- package/tests/checks/similar.sh +62 -0
- package/tests/checks/ui-build.sh +59 -0
- package/tests/checks/vocab.sh +30 -0
- package/tests/fixtures/entry-ok/entry.md +41 -0
- package/tests/fixtures/entry-ok/strip.png +0 -0
- package/tests/fixtures/page/index.html +47 -0
- package/tests/fixtures/three-frame.webp +0 -0
- package/tests/run.sh +10 -0
- package/vocab.yaml +16 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; pid=""
|
|
5
|
+
trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
|
|
6
|
+
|
|
7
|
+
# Item 1: --inbox, two files that slugify identically in one run -> the second refuses,
|
|
8
|
+
# naming the first file.
|
|
9
|
+
d1="$tmp/item1"; mkdir -p "$d1/inbox" "$d1/entries"
|
|
10
|
+
cp tests/fixtures/three-frame.webp "$d1/inbox/A.webp"
|
|
11
|
+
cp tests/fixtures/three-frame.webp "$d1/inbox/a.webp"
|
|
12
|
+
out=$(LIBRARY_DATA="$d1" node bin/library.mjs add --inbox --source public-site --license x 2>&1 || true)
|
|
13
|
+
echo "$out" | grep -qF '{"file":"a.webp","slug":"a","status":"refused","reason":"slug a collides with A.webp in this run"}' \
|
|
14
|
+
|| { echo "FAIL: item1 same-run slug collision"; echo "$out"; exit 1; }
|
|
15
|
+
|
|
16
|
+
# Item 2: source.kind outside the vocabulary is refused at draft time (resolveTerm),
|
|
17
|
+
# both for `add` directly and for `--inbox`.
|
|
18
|
+
d2="$tmp/item2"; mkdir -p "$d2/entries"
|
|
19
|
+
out=$(LIBRARY_DATA="$d2" node bin/library.mjs add tests/fixtures/three-frame.webp --slug bad-kind --title t --source bogus --license x 2>&1 || true)
|
|
20
|
+
echo "$out" | grep -q 'unknown source term' || { echo "FAIL: item2 add refuses unknown source.kind"; echo "$out"; exit 1; }
|
|
21
|
+
[ ! -e "$d2/entries/bad-kind" ] || { echo "FAIL: item2 draft written despite unknown source.kind"; exit 1; }
|
|
22
|
+
|
|
23
|
+
d2b="$tmp/item2b"; mkdir -p "$d2b/inbox" "$d2b/entries"
|
|
24
|
+
cp tests/fixtures/three-frame.webp "$d2b/inbox/x.webp"
|
|
25
|
+
out=$(LIBRARY_DATA="$d2b" node bin/library.mjs add --inbox --source bogus --license x 2>&1 || true)
|
|
26
|
+
echo "$out" | grep -q '"reason":"source.kind: unknown source term' || { echo "FAIL: item2 inbox refuses unknown source.kind at draft time"; echo "$out"; exit 1; }
|
|
27
|
+
[ ! -e "$d2b/entries/x" ] || { echo "FAIL: item2 inbox draft written despite unknown source.kind"; exit 1; }
|
|
28
|
+
|
|
29
|
+
# Item 3: over http, a dangling symlink under inbox is refused, for both add and similar.
|
|
30
|
+
d3="$tmp/item3"; mkdir -p "$d3/inbox" "$d3/entries"
|
|
31
|
+
ln -s "$d3/inbox/does-not-exist.webp" "$d3/inbox/dangling.webp"
|
|
32
|
+
LIBRARY_DATA="$d3" PORT=4190 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$d3/log" 2>&1 & pid=$!
|
|
33
|
+
for i in $(seq 1 30); do curl -sf localhost:4190/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
34
|
+
init='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}'
|
|
35
|
+
curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$init" >/dev/null
|
|
36
|
+
call_add="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"input\":\"$d3/inbox/dangling.webp\",\"slug\":\"x\",\"title\":\"x\",\"source\":{\"kind\":\"open\",\"license\":\"MIT\"}}}}"
|
|
37
|
+
curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_add" \
|
|
38
|
+
| grep -q '"refused":"input does not exist"' || { echo "FAIL: http add did not refuse a dangling symlink under inbox"; exit 1; }
|
|
39
|
+
call_sim="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"similar\",\"arguments\":{\"image\":\"$d3/inbox/dangling.webp\"}}}"
|
|
40
|
+
curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_sim" \
|
|
41
|
+
| grep -q '"refused":"input does not exist"' || { echo "FAIL: http similar did not refuse a dangling symlink under inbox"; exit 1; }
|
|
42
|
+
kill $pid 2>/dev/null || true; pid=""
|
|
43
|
+
|
|
44
|
+
# Item 4: slugify('___.webp') derives an empty slug -> refused, naming --slug.
|
|
45
|
+
d4="$tmp/item4"; mkdir -p "$d4/inbox" "$d4/entries"
|
|
46
|
+
cp tests/fixtures/three-frame.webp "$d4/inbox/___.webp"
|
|
47
|
+
out=$(LIBRARY_DATA="$d4" node bin/library.mjs add --inbox --source public-site --license x 2>&1 || true)
|
|
48
|
+
echo "$out" | grep -qF '"reason":"cannot derive a slug from the file name; pass --slug"' \
|
|
49
|
+
|| { echo "FAIL: item4 empty slug refused"; echo "$out"; exit 1; }
|
|
50
|
+
|
|
51
|
+
# Item 5: export --saved-only skips drafts (entries failing validateEntry) and reports
|
|
52
|
+
# how many on stderr; the tar still goes to stdout.
|
|
53
|
+
d5="$tmp/item5"; mkdir -p "$d5/entries"
|
|
54
|
+
cp -r tests/fixtures/entry-ok "$d5/entries/entry-ok"
|
|
55
|
+
LIBRARY_DATA="$d5" node bin/library.mjs add tests/fixtures/three-frame.webp --slug draft-one --title t --source own --license x >/dev/null
|
|
56
|
+
LIBRARY_DATA="$d5" node bin/library.mjs export --saved-only >"$d5/out.tar" 2>"$d5/err"
|
|
57
|
+
tar -tf "$d5/out.tar" | grep -q 'entries/entry-ok/entry.md' || { echo "FAIL: item5 saved entry missing from tar"; exit 1; }
|
|
58
|
+
tar -tf "$d5/out.tar" | grep -q 'entries/draft-one' && { echo "FAIL: item5 draft included in tar"; exit 1; }
|
|
59
|
+
grep -q 'skipped 1 drafts' "$d5/err" || { echo "FAIL: item5 skipped count on stderr"; cat "$d5/err"; exit 1; }
|
|
60
|
+
# all drafts -> a named refusal, never tar's own "empty archive" wording
|
|
61
|
+
rm -rf "$d5/entries/entry-ok"
|
|
62
|
+
LIBRARY_DATA="$d5" node bin/library.mjs export --saved-only >/dev/null 2>"$d5/err2" && { echo "FAIL: item5 all-drafts export exited 0"; exit 1; }
|
|
63
|
+
grep -q 'export: no saved entries to export' "$d5/err2" || { echo "FAIL: item5 all-drafts refusal not named"; cat "$d5/err2"; exit 1; }
|
|
64
|
+
|
|
65
|
+
# Item 6: over http, `add` with a URL refuses before Chrome ever launches — a plain
|
|
66
|
+
# http:// scheme, a literal private IP, and a hostname that resolves to loopback.
|
|
67
|
+
d6="$tmp/item6"; mkdir -p "$d6/entries"
|
|
68
|
+
LIBRARY_DATA="$d6" PORT=4190 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$d6/log" 2>&1 & pid=$!
|
|
69
|
+
for i in $(seq 1 30); do curl -sf localhost:4190/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
70
|
+
curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$init" >/dev/null
|
|
71
|
+
call_scheme="{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"input\":\"http://127.0.0.1:1/\",\"slug\":\"ssrf-scheme\",\"title\":\"t\",\"source\":{\"kind\":\"public-site\",\"license\":\"x\"}}}}"
|
|
72
|
+
curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_scheme" \
|
|
73
|
+
| grep -qF 'only https://' || { echo "FAIL: item6 http:// scheme not refused"; exit 1; }
|
|
74
|
+
call_ip="{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"input\":\"https://127.0.0.1:1/\",\"slug\":\"ssrf-ip\",\"title\":\"t\",\"source\":{\"kind\":\"public-site\",\"license\":\"x\"}}}}"
|
|
75
|
+
curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_ip" \
|
|
76
|
+
| grep -qF 'resolves to a private address' || { echo "FAIL: item6 literal private IP not refused"; exit 1; }
|
|
77
|
+
call_host="{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"input\":\"https://localhost:1/\",\"slug\":\"ssrf-host\",\"title\":\"t\",\"source\":{\"kind\":\"public-site\",\"license\":\"x\"}}}}"
|
|
78
|
+
curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_host" \
|
|
79
|
+
| grep -qF 'resolves to a private address' || { echo "FAIL: item6 localhost (resolves to loopback) not refused by the guard"; exit 1; }
|
|
80
|
+
kill $pid 2>/dev/null || true; pid=""
|
|
81
|
+
[ ! -e "$d6/entries/ssrf-scheme" ] && [ ! -e "$d6/entries/ssrf-ip" ] && [ ! -e "$d6/entries/ssrf-host" ] \
|
|
82
|
+
|| { echo "FAIL: item6 a draft was written despite an SSRF refusal"; exit 1; }
|
|
83
|
+
|
|
84
|
+
echo PASS
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The server ships the add-entry prompt: prompts/list names it, prompts/get renders the
|
|
3
|
+
# order of operations and the tagging rubric with the vocabulary read from vocab.yaml.
|
|
4
|
+
set -eu
|
|
5
|
+
cd "$(dirname "$0")/../.."
|
|
6
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; trap 'rm -rf "$tmp"' EXIT
|
|
7
|
+
mkdir -p "$tmp/entries"
|
|
8
|
+
{
|
|
9
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
10
|
+
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
11
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"prompts/list"}'
|
|
12
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"add-entry","arguments":{"path":"/x/site.webp","source":"public-site"}}}'
|
|
13
|
+
sleep 1
|
|
14
|
+
} | LIBRARY_DATA="$tmp" timeout 10 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
|
|
15
|
+
grep -q '"name":"add-entry"' "$tmp/out" || { echo "FAIL: prompts/list lacks add-entry"; cat "$tmp/err"; exit 1; }
|
|
16
|
+
get=$(grep '"id":3' "$tmp/out")
|
|
17
|
+
need() { echo "$get" | grep -qF "$1" || { echo "FAIL: prompt text lacks: $1"; exit 1; }; }
|
|
18
|
+
need 'The session is the tagger'
|
|
19
|
+
need 'show the draft and wait for a yes'
|
|
20
|
+
need 'never remove a field to make it pass'
|
|
21
|
+
need 'Eyeballed from N sampled frames, not measured'
|
|
22
|
+
need 'added by a PR to `vocab.yaml`, never by an ingest'
|
|
23
|
+
need 'can only read files already in the server'
|
|
24
|
+
need '/x/site.webp'
|
|
25
|
+
need 'public-site'
|
|
26
|
+
# vocabulary is rendered from vocab.yaml, so every role term appears
|
|
27
|
+
for t in $(node --input-type=module -e 'import { loadVocab } from "./src/vocab.mjs"; console.log([...loadVocab().facets.role].join(" "))'); do need "$t"; done
|
|
28
|
+
echo PASS
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The server ships two resources (vocab.yaml, an entry's entry.md) and the
|
|
3
|
+
# brief-from-library prompt: resources/list, resources/templates/list and
|
|
4
|
+
# resources/read cover both; prompts/list and prompts/get cover the prompt.
|
|
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
|
+
{
|
|
10
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
11
|
+
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
12
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"resources/list"}'
|
|
13
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"resources/templates/list"}'
|
|
14
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"library://vocab"}}'
|
|
15
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"library://entry/entry-ok"}}'
|
|
16
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"library://entry/nope"}}'
|
|
17
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"library://entry/bad.slug"}}'
|
|
18
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":7,"method":"prompts/list"}'
|
|
19
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":8,"method":"prompts/get","params":{"name":"brief-from-library","arguments":{"brief":"a hero and a pricing table, dark and premium"}}}'
|
|
20
|
+
sleep 1
|
|
21
|
+
} | LIBRARY_DATA="$tmp" timeout 10 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
|
|
22
|
+
|
|
23
|
+
grep '"id":2' "$tmp/out" | grep -q 'library://vocab' || { echo "FAIL: resources/list lacks library://vocab"; cat "$tmp/err"; exit 1; }
|
|
24
|
+
grep '"id":3' "$tmp/out" | grep -q 'library://entry/{slug}' || { echo "FAIL: resources/templates/list lacks library://entry/{slug}"; exit 1; }
|
|
25
|
+
grep '"id":4' "$tmp/out" | grep -q 'role:' || { echo "FAIL: vocab resource read lacks role:"; exit 1; }
|
|
26
|
+
grep '"id":5' "$tmp/out" | grep -q 'slug: entry-ok' || { echo "FAIL: entry resource read lacks slug: entry-ok"; exit 1; }
|
|
27
|
+
nope=$(grep '"id":6' "$tmp/out")
|
|
28
|
+
echo "$nope" | grep -q '"error"' || { echo "FAIL: unknown slug did not error"; exit 1; }
|
|
29
|
+
echo "$nope" | grep -q 'no entry nope' || { echo "FAIL: unknown slug error lacks 'no entry nope'"; exit 1; }
|
|
30
|
+
bad=$(grep '"id":9' "$tmp/out")
|
|
31
|
+
echo "$bad" | grep -q '"error"' || { echo "FAIL: bad slug did not error"; exit 1; }
|
|
32
|
+
echo "$bad" | grep -qF 'no entry bad.slug' || { echo "FAIL: bad slug error lacks 'no entry bad.slug'"; exit 1; }
|
|
33
|
+
grep '"id":7' "$tmp/out" | grep -q '"name":"brief-from-library"' || { echo "FAIL: prompts/list lacks brief-from-library"; exit 1; }
|
|
34
|
+
get=$(grep '"id":8' "$tmp/out")
|
|
35
|
+
echo "$get" | grep -qF 'limit: 3' || { echo "FAIL: prompt text lacks: limit: 3"; exit 1; }
|
|
36
|
+
echo "$get" | grep -qF '## References' || { echo "FAIL: prompt text lacks: ## References"; exit 1; }
|
|
37
|
+
echo "$get" | grep -qF 'ranks.fts' || { echo "FAIL: prompt text lacks the weak-vector-match rule (ranks.fts)"; exit 1; }
|
|
38
|
+
echo "$get" | grep -qF 'having no reference rather than filling it' || { echo "FAIL: prompt text lacks the weak-match-only role guidance"; exit 1; }
|
|
39
|
+
echo "$get" | grep -qi 'hero' || { echo "FAIL: prompt text lacks the word hero"; exit 1; }
|
|
40
|
+
# vocabulary is rendered from vocab.yaml, so every feel term appears
|
|
41
|
+
for t in $(node --input-type=module -e 'import { loadVocab } from "./src/vocab.mjs"; console.log([...loadVocab().facets.feel].join(" "))'); do
|
|
42
|
+
echo "$get" | grep -qF "$t" || { echo "FAIL: prompt text lacks feel term: $t"; exit 1; }
|
|
43
|
+
done
|
|
44
|
+
echo PASS
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
5
|
+
export LIBRARY_EMBED=stub
|
|
6
|
+
mkdir -p "$tmp/entries"
|
|
7
|
+
|
|
8
|
+
# Four entries sharing role/feel/motion so tags never leak "parallax"/"bed" (both are
|
|
9
|
+
# also valid vocab terms elsewhere) into the FTS/vector text: only title+body
|
|
10
|
+
# distinguish them from the query "parallax bed". Keyword relevance (verified by
|
|
11
|
+
# querying bm25() directly): y is best (both terms in title and twice in body),
|
|
12
|
+
# z is medium (both terms once, in body only), w is weak (both terms once, in a
|
|
13
|
+
# title padded long with unrelated words so document-length normalisation costs it),
|
|
14
|
+
# x has no keyword match at all (never appears in the FTS arm's results).
|
|
15
|
+
#
|
|
16
|
+
# The stub embedder has no real semantics, so instead of hoping title/body text
|
|
17
|
+
# happens to produce a useful vector spread, the vectors are overwritten directly in
|
|
18
|
+
# vec_text after indexing so the two arms are made to actively disagree: x (worst
|
|
19
|
+
# keywords) gets the *closest* vector, y (best keywords) gets the *farthest*
|
|
20
|
+
# (negated) vector, z and w sit in between in keyword order. Only z is good on both
|
|
21
|
+
# arms, so only RRF's fusion — not either arm alone — puts it first.
|
|
22
|
+
mk() {
|
|
23
|
+
slug=$1; title=$2; body=$3
|
|
24
|
+
d="$tmp/entries/$slug"; mkdir -p "$d"
|
|
25
|
+
cp tests/fixtures/entry-ok/strip.png "$d/strip.png"
|
|
26
|
+
cat > "$d/entry.md" <<EOF
|
|
27
|
+
---
|
|
28
|
+
slug: $slug
|
|
29
|
+
title: $title
|
|
30
|
+
tier: inspiration
|
|
31
|
+
source:
|
|
32
|
+
kind: open
|
|
33
|
+
url: https://example.com
|
|
34
|
+
license: MIT
|
|
35
|
+
captured:
|
|
36
|
+
method: frames
|
|
37
|
+
at: 2026-09-14
|
|
38
|
+
tool: wp-design-library@0.1.0
|
|
39
|
+
media:
|
|
40
|
+
frames: 3
|
|
41
|
+
duration_ms: 120
|
|
42
|
+
roles: [footer]
|
|
43
|
+
feel: [cold]
|
|
44
|
+
palette:
|
|
45
|
+
canvas: "#0b0f14"
|
|
46
|
+
ink: "#f4f6f8"
|
|
47
|
+
accent: "#6ea8ff"
|
|
48
|
+
type:
|
|
49
|
+
display: geometric sans, light
|
|
50
|
+
body: same family
|
|
51
|
+
motion:
|
|
52
|
+
devices: [pin]
|
|
53
|
+
notes: static footer element
|
|
54
|
+
ported_from: null
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
$body
|
|
58
|
+
EOF
|
|
59
|
+
}
|
|
60
|
+
mk y "parallax bed hero" "the parallax bed rises here and the parallax bed falls there again"
|
|
61
|
+
mk z "quiet mountains view" "parallax bed"
|
|
62
|
+
mk w "parallax bed among many other unrelated words padding this title out considerably more" "clouds valleys rivers forests deserts glaciers coastlines"
|
|
63
|
+
mk x "quiet coastal footer element" "clouds valleys rivers forests deserts glaciers coastlines mountains"
|
|
64
|
+
|
|
65
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs index >"$tmp/out" 2>&1
|
|
66
|
+
grep -q "arms: fts,vec" "$tmp/out" || { echo "FAIL: corpus did not index with the vec arm"; cat "$tmp/out"; exit 1; }
|
|
67
|
+
|
|
68
|
+
LIBRARY_DATA="$tmp" node --input-type=module -e '
|
|
69
|
+
import { openIndex, search } from "./src/index/query.mjs";
|
|
70
|
+
import { getEmbedder } from "./src/index/embed.mjs";
|
|
71
|
+
import { paths } from "./src/paths.mjs";
|
|
72
|
+
const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
|
|
73
|
+
|
|
74
|
+
const e = await getEmbedder({ models: "/x", mode: "stub" });
|
|
75
|
+
const q = await e.text("parallax bed");
|
|
76
|
+
|
|
77
|
+
function renorm(arr) {
|
|
78
|
+
let s = 0; for (const v of arr) s += v * v;
|
|
79
|
+
const n = Math.sqrt(s) || 1;
|
|
80
|
+
for (let i = 0; i < arr.length; i++) arr[i] /= n;
|
|
81
|
+
return arr;
|
|
82
|
+
}
|
|
83
|
+
const vecX = Float32Array.from(q); // rank 1: exact match
|
|
84
|
+
const vecZ = renorm(Float32Array.from(q).map((v, i) => (i === 0 ? v + 0.01 : v))); // rank 2: small nudge
|
|
85
|
+
const vecW = renorm(Float32Array.from(q).map((v, i) => (i === 0 ? v + 0.05 : v))); // rank 3: bigger nudge
|
|
86
|
+
const vecY = Float32Array.from(q).map((v) => -v); // rank 4: opposite direction
|
|
87
|
+
|
|
88
|
+
const dbw = openIndex(paths().index, { readonly: false });
|
|
89
|
+
const upd = dbw.prepare("UPDATE vec_text SET embedding = ? WHERE slug = ?");
|
|
90
|
+
upd.run(Buffer.from(vecX.buffer), "x");
|
|
91
|
+
upd.run(Buffer.from(vecZ.buffer), "z");
|
|
92
|
+
upd.run(Buffer.from(vecW.buffer), "w");
|
|
93
|
+
upd.run(Buffer.from(vecY.buffer), "y");
|
|
94
|
+
dbw.close();
|
|
95
|
+
|
|
96
|
+
const db2 = openIndex(paths().index);
|
|
97
|
+
const r = await search(db2, { query: "parallax bed", embedder: e });
|
|
98
|
+
must(r.arms.length === 2 && r.arms.includes("fts") && r.arms.includes("vec"), "arms is fts+vec");
|
|
99
|
+
must(r.hits[0].slug === "z", "z ranks first (mid on both beats top on one), got " + JSON.stringify(r.hits.map(h => h.slug)));
|
|
100
|
+
const top2 = r.hits.map(h => h.slug).slice(0, 2);
|
|
101
|
+
must(top2.includes("y"), "y appears in the top 2, got " + JSON.stringify(top2));
|
|
102
|
+
const xHit = r.hits.find(h => h.slug === "x");
|
|
103
|
+
must(!!xHit, "x is present in the results");
|
|
104
|
+
must(r.hits[0].slug !== "x", "x is not first");
|
|
105
|
+
must(xHit.ranks.fts === null, "x has no fts rank, got " + JSON.stringify(xHit.ranks));
|
|
106
|
+
must(xHit.ranks.vec === 1, "x is vec rank 1, got " + JSON.stringify(xHit.ranks));
|
|
107
|
+
|
|
108
|
+
const embOff = await getEmbedder({ models: "/x", mode: "off" });
|
|
109
|
+
must(embOff === null, "LIBRARY_EMBED=off resolves to no embedder");
|
|
110
|
+
const r2 = await search(db2, { query: "parallax bed", embedder: embOff });
|
|
111
|
+
must(r2.arms.length === 1 && r2.arms[0] === "fts", "off falls back to fts only");
|
|
112
|
+
must(Array.isArray(r2.skipped) && r2.skipped.length === 1 && r2.skipped[0].arm === "vec" && !!r2.skipped[0].reason, "skipped names the vec arm and a reason");
|
|
113
|
+
|
|
114
|
+
const badEmbedder = { arm: "vec", dims: e.dims, text: async () => { throw new Error("boom"); }, image: e.image };
|
|
115
|
+
const r3 = await search(db2, { query: "parallax bed", embedder: badEmbedder });
|
|
116
|
+
must(r3.arms.length === 1 && r3.arms[0] === "fts", "a failing embedder degrades to fts only, got " + JSON.stringify(r3.arms));
|
|
117
|
+
must(Array.isArray(r3.skipped) && r3.skipped.length === 1 && r3.skipped[0].arm === "vec" && r3.skipped[0].reason.includes("boom"), "skipped reason mentions boom, got " + JSON.stringify(r3.skipped));
|
|
118
|
+
must(Array.isArray(r3.hits) && r3.hits.length > 0, "hits are still returned despite the embedder failure");
|
|
119
|
+
'
|
|
120
|
+
echo PASS
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# On every start, entries present in the seed (the image's entries/) but missing from
|
|
3
|
+
# LIBRARY_DATA are copied in; entries already in the volume are never overwritten.
|
|
4
|
+
set -eu
|
|
5
|
+
cd "$(dirname "$0")/../.."
|
|
6
|
+
tmp=$(mktemp -d); pid=""; trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
|
|
7
|
+
mkdir -p "$tmp/seed" "$tmp/data/entries"
|
|
8
|
+
cp -r tests/fixtures/entry-ok "$tmp/seed/entry-ok"
|
|
9
|
+
cp -r tests/fixtures/entry-ok "$tmp/seed/entry-two"; sed -i 's/slug: entry-ok/slug: entry-two/' "$tmp/seed/entry-two/entry.md"
|
|
10
|
+
# volume already holds entry-ok with a local edit that must survive
|
|
11
|
+
cp -r tests/fixtures/entry-ok "$tmp/data/entries/entry-ok"; sed -i 's/Fixture hero with parallax bed/Volume edit kept/' "$tmp/data/entries/entry-ok/entry.md"
|
|
12
|
+
LIBRARY_DATA="$tmp/data" LIBRARY_SEED="$tmp/seed" PORT=4196 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$tmp/log" 2>&1 & pid=$!
|
|
13
|
+
for i in $(seq 1 30); do curl -sf localhost:4196/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
14
|
+
curl -sf localhost:4196/healthz | grep -q '"entries":2' || { echo "FAIL: seed entry not synced into a non-empty volume"; cat "$tmp/log"; exit 1; }
|
|
15
|
+
grep -q "Volume edit kept" "$tmp/data/entries/entry-ok/entry.md" || { echo "FAIL: volume entry was overwritten"; exit 1; }
|
|
16
|
+
grep -q "seed: copied 1 new" "$tmp/log" || { echo "FAIL: sync not reported"; cat "$tmp/log"; exit 1; }
|
|
17
|
+
kill $pid; wait $pid 2>/dev/null || true; pid=""
|
|
18
|
+
# second start copies nothing
|
|
19
|
+
LIBRARY_DATA="$tmp/data" LIBRARY_SEED="$tmp/seed" PORT=4196 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$tmp/log2" 2>&1 & pid=$!
|
|
20
|
+
for i in $(seq 1 30); do curl -sf localhost:4196/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
21
|
+
grep -q "seed: copied 0 new" "$tmp/log2" || { echo "FAIL: second start should copy nothing"; cat "$tmp/log2"; exit 1; }
|
|
22
|
+
echo PASS
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; export LIBRARY_EMBED=stub; pid=""
|
|
5
|
+
trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
|
|
6
|
+
|
|
7
|
+
# a and b: byte-identical strip.png (both are plain copies of the fixture).
|
|
8
|
+
# c: a strip of a different size, so its stub vector differs.
|
|
9
|
+
mkdir -p "$tmp/entries"
|
|
10
|
+
cp -r tests/fixtures/entry-ok "$tmp/entries/a"; sed -i 's/slug: entry-ok/slug: a/' "$tmp/entries/a/entry.md"
|
|
11
|
+
cp -r tests/fixtures/entry-ok "$tmp/entries/b"; sed -i 's/slug: entry-ok/slug: b/' "$tmp/entries/b/entry.md"
|
|
12
|
+
cp -r tests/fixtures/entry-ok "$tmp/entries/c"; sed -i 's/slug: entry-ok/slug: c/' "$tmp/entries/c/entry.md"
|
|
13
|
+
printf x >> "$tmp/entries/c/strip.png"
|
|
14
|
+
|
|
15
|
+
# stub, over stdio: slug lookup and image lookup
|
|
16
|
+
{
|
|
17
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
18
|
+
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
19
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"similar","arguments":{"slug":"a"}}}'
|
|
20
|
+
printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"similar\",\"arguments\":{\"image\":\"$tmp/entries/a/strip.png\"}}}"
|
|
21
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"similar","arguments":{}}}'
|
|
22
|
+
sleep 1
|
|
23
|
+
} | LIBRARY_DATA="$tmp" timeout 10 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
|
|
24
|
+
|
|
25
|
+
node --input-type=module -e '
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
const lines = fs.readFileSync(process.argv[2], "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
28
|
+
const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
|
|
29
|
+
const byId = (id) => lines.find((l) => l.id === id)?.result?.structuredContent;
|
|
30
|
+
|
|
31
|
+
const bySlug = byId(2);
|
|
32
|
+
must(bySlug.arms.length === 1 && bySlug.arms[0] === "vec", "similar-by-slug arms is vec");
|
|
33
|
+
must(bySlug.hits[0].slug === "b" && bySlug.hits[0].distance === 0, "similar-by-slug returns b first at distance 0; got " + JSON.stringify(bySlug.hits));
|
|
34
|
+
|
|
35
|
+
const byImage = byId(3);
|
|
36
|
+
const slugs = byImage.hits.map((h) => h.slug);
|
|
37
|
+
must(slugs.includes("a") && slugs.includes("b"), "similar-by-image returns a and b; got " + JSON.stringify(slugs));
|
|
38
|
+
|
|
39
|
+
const noArg = byId(4);
|
|
40
|
+
must(!!noArg.refused, "similar with neither slug nor image is refused");
|
|
41
|
+
' _ "$tmp/out"
|
|
42
|
+
|
|
43
|
+
# LIBRARY_EMBED=off: similar refuses, naming the arm
|
|
44
|
+
{
|
|
45
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
46
|
+
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
47
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"similar","arguments":{"slug":"a"}}}'
|
|
48
|
+
sleep 1
|
|
49
|
+
} | LIBRARY_DATA="$tmp" LIBRARY_EMBED=off timeout 10 node bin/library.mjs serve >"$tmp/out-off" 2>"$tmp/err-off" || true
|
|
50
|
+
grep '"id":2' "$tmp/out-off" | grep -q 'refused.*arm' || { echo "FAIL: LIBRARY_EMBED=off did not refuse similar, naming the arm"; cat "$tmp/out-off"; exit 1; }
|
|
51
|
+
|
|
52
|
+
# over http: image is confined to <data>/inbox
|
|
53
|
+
LIBRARY_DATA="$tmp" PORT=4191 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$tmp/log" 2>&1 & pid=$!
|
|
54
|
+
for i in $(seq 1 30); do curl -sf localhost:4191/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
55
|
+
curl -sf localhost:4191/healthz | grep -q '"arms":\["fts","vec"\]' || { echo "FAIL: healthz arms with vec"; cat "$tmp/log"; exit 1; }
|
|
56
|
+
init='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}'
|
|
57
|
+
curl -s -X POST localhost:4191/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$init" >/dev/null
|
|
58
|
+
call='{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"similar","arguments":{"image":"/etc/hostname"}}}'
|
|
59
|
+
curl -s -X POST localhost:4191/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call" \
|
|
60
|
+
| grep -q 'refused.*inbox' || { echo "FAIL: http similar did not refuse an image outside the inbox"; exit 1; }
|
|
61
|
+
kill $pid 2>/dev/null || true; pid=""
|
|
62
|
+
echo PASS
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; pid=""; trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
|
|
5
|
+
mkdir -p "$tmp/entries"
|
|
6
|
+
cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
7
|
+
cp -r tests/fixtures/entry-ok "$tmp/entries/entry-draft"
|
|
8
|
+
# draft frontmatter slug carries a path-traversal + attribute-breakout payload; the
|
|
9
|
+
# directory name (entry-draft) stays safe. title is blanked so the card/entry-page title
|
|
10
|
+
# falls back to fm.slug (text, shown only through esc()) and exercises the quote-escaping
|
|
11
|
+
# fix. captured.at is newer than entry-ok's so the sort fix (not insertion order) puts it first.
|
|
12
|
+
sed -i 's/title: Fixture hero with parallax bed/title: ""/; s/roles: \[hero, closing\]/roles: []/; s#slug: entry-ok#slug: ../escape" onmouseover="x#; s/ at: 2026-09-14/ at: 2026-09-20/' "$tmp/entries/entry-draft/entry.md"
|
|
13
|
+
|
|
14
|
+
# `library ui` builds a static gallery straight from entries/, no index required first
|
|
15
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs ui >"$tmp/out" 2>&1
|
|
16
|
+
ui="$tmp/ui"
|
|
17
|
+
[ -f "$ui/index.html" ] || { echo "FAIL: index.html missing"; cat "$tmp/out"; exit 1; }
|
|
18
|
+
[ -f "$ui/manifest.json" ] || { echo "FAIL: manifest.json missing"; cat "$tmp/out"; exit 1; }
|
|
19
|
+
[ "$(grep -c '"slug":' "$ui/manifest.json")" = "2" ] || { echo "FAIL: manifest does not have two rows"; cat "$ui/manifest.json"; exit 1; }
|
|
20
|
+
[ "$(grep -c '"draft": true' "$ui/manifest.json")" = "1" ] || { echo "FAIL: manifest does not mark exactly one draft"; cat "$ui/manifest.json"; exit 1; }
|
|
21
|
+
grep -q 'Fixture hero with parallax bed' "$ui/entry/entry-ok.html" || { echo "FAIL: entry page missing title"; exit 1; }
|
|
22
|
+
grep -q '<h2>What it does</h2>' "$ui/entry/entry-ok.html" || { echo "FAIL: markdown heading not converted"; exit 1; }
|
|
23
|
+
[ -f "$ui/strips/entry-ok.png" ] || { echo "FAIL: strip not copied"; exit 1; }
|
|
24
|
+
for t in hero proof feature process offer testimonial faq closing capability explainer page-head footer; do
|
|
25
|
+
grep -q "value=\"$t\"" "$ui/index.html" || { echo "FAIL: role term $t missing from index.html filters"; exit 1; }
|
|
26
|
+
done
|
|
27
|
+
|
|
28
|
+
# fm.slug must never reach a path: nothing escaped outDir or the entry/ dir via ../
|
|
29
|
+
# (the payload is `../escape" onmouseover="x`, so any file carrying "escape" anywhere under $tmp is the leak)
|
|
30
|
+
[ -z "$(find "$tmp" -name '*escape*' -print -quit)" ] || { echo "FAIL: fm.slug reached a filesystem path"; find "$tmp" -name '*escape*'; exit 1; }
|
|
31
|
+
# fm.slug must never reach an unescaped attribute: a raw " right before onmouseover=
|
|
32
|
+
# means the quote broke out; a safely-escaped " is fine and expected here
|
|
33
|
+
grep -Fq '" onmouseover="' "$ui/index.html" && { echo "FAIL: fm.slug quote broke out of an attribute"; exit 1; }
|
|
34
|
+
grep -q '"slug": "entry-draft"' "$ui/manifest.json" || { echo "FAIL: manifest does not list the draft under its directory name"; cat "$ui/manifest.json"; exit 1; }
|
|
35
|
+
|
|
36
|
+
# captured.at round-trips js-yaml as a Date; sort must land on the ISO string, newest first
|
|
37
|
+
node -e '
|
|
38
|
+
const rows = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
39
|
+
if (rows[0].slug !== "entry-draft") { console.error(JSON.stringify(rows.map((r) => [r.slug, r.captured]))); process.exit(1); }
|
|
40
|
+
' "$ui/manifest.json" || { echo "FAIL: newer captured.at entry is not first in manifest.json"; exit 1; }
|
|
41
|
+
|
|
42
|
+
# a real file one level above the ui root (LIBRARY_DATA itself), so a successful
|
|
43
|
+
# traversal would actually read something back instead of 404ing on its own
|
|
44
|
+
echo '{"leak":true}' > "$tmp/package.json"
|
|
45
|
+
|
|
46
|
+
# `serve --http` builds the same gallery on start and serves it at /, safely
|
|
47
|
+
LIBRARY_DATA="$tmp" PORT=4193 LIBRARY_TOKEN=t0k timeout 10 node bin/library.mjs serve --http >"$tmp/log" 2>&1 & pid=$!
|
|
48
|
+
for i in $(seq 1 30); do curl -sf localhost:4193/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
49
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' localhost:4193/)
|
|
50
|
+
[ "$code" = "200" ] || { echo "FAIL: GET / -> $code"; cat "$tmp/log"; exit 1; }
|
|
51
|
+
curl -s localhost:4193/ | grep -q 'id="grid"' || { echo "FAIL: GET / missing the grid"; exit 1; }
|
|
52
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' localhost:4193/entry/entry-ok.html)
|
|
53
|
+
[ "$code" = "200" ] || { echo "FAIL: GET /entry/entry-ok.html -> $code"; exit 1; }
|
|
54
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' --path-as-is localhost:4193/../package.json)
|
|
55
|
+
[ "$code" = "404" ] || { echo "FAIL: GET /../package.json -> $code (traversal not blocked)"; exit 1; }
|
|
56
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' --path-as-is localhost:4193/%2e%2e/package.json)
|
|
57
|
+
[ "$code" = "404" ] || { echo "FAIL: GET /%2e%2e/package.json -> $code (traversal not blocked)"; exit 1; }
|
|
58
|
+
curl -sf localhost:4193/healthz | grep -q '"ok":true' || { echo "FAIL: healthz not json"; exit 1; }
|
|
59
|
+
echo PASS
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmpdir=$(mktemp -d); trap 'rm -rf "$tmpdir"' EXIT
|
|
5
|
+
tmp="$tmpdir/bad-vocab-aliases.yaml"
|
|
6
|
+
VOCAB_TMP="$tmp" node --input-type=module -e '
|
|
7
|
+
import { loadVocab, resolveTerm } from "./src/vocab.mjs";
|
|
8
|
+
import { writeFileSync } from "fs";
|
|
9
|
+
const v = loadVocab();
|
|
10
|
+
const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
|
|
11
|
+
must(v.facets.role.has("hero"), "role/hero present");
|
|
12
|
+
must(resolveTerm(v, "role", "plans") === "offer", "alias plans -> offer");
|
|
13
|
+
must(resolveTerm(v, "role", "offer") === "offer", "canonical passes through");
|
|
14
|
+
must(resolveTerm(v, "feel", "neon") === null, "unknown term is null");
|
|
15
|
+
must(resolveTerm(v, "role", "dark") === null, "term from another facet is null");
|
|
16
|
+
must(Object.keys(v.facets).length === 5, "exactly five facets");
|
|
17
|
+
const tmpFile = process.env.VOCAB_TMP;
|
|
18
|
+
writeFileSync(tmpFile, "role: [hero]\nfeel: [dark]\nmotion: [reveal]\nsource: [own]\ntier: [inspiration]\naliases: pricing\n");
|
|
19
|
+
try {
|
|
20
|
+
loadVocab(tmpFile);
|
|
21
|
+
console.error("FAIL: bad aliases did not throw");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (!err.message.includes("aliases")) {
|
|
25
|
+
console.error("FAIL: error message missing aliases: " + err.message);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
'
|
|
30
|
+
echo PASS
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
slug: entry-ok
|
|
3
|
+
title: Fixture hero with parallax bed
|
|
4
|
+
tier: inspiration
|
|
5
|
+
source:
|
|
6
|
+
kind: open
|
|
7
|
+
url: https://example.com
|
|
8
|
+
license: MIT
|
|
9
|
+
captured:
|
|
10
|
+
method: frames
|
|
11
|
+
at: 2026-09-14
|
|
12
|
+
tool: wp-design-library@0.1.0
|
|
13
|
+
media:
|
|
14
|
+
frames: 3
|
|
15
|
+
duration_ms: 120
|
|
16
|
+
roles: [hero, closing]
|
|
17
|
+
feel: [dark, depth]
|
|
18
|
+
palette:
|
|
19
|
+
canvas: "#0b0f14"
|
|
20
|
+
ink: "#f4f6f8"
|
|
21
|
+
accent: "#6ea8ff"
|
|
22
|
+
type:
|
|
23
|
+
display: geometric sans, light
|
|
24
|
+
body: same family
|
|
25
|
+
motion:
|
|
26
|
+
devices: [parallax, reveal]
|
|
27
|
+
notes: bed moves slower than the frame
|
|
28
|
+
ported_from: null
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## What it does
|
|
32
|
+
|
|
33
|
+
A dark hero whose background bed scrolls slower than the foreground frame.
|
|
34
|
+
|
|
35
|
+
## Section roster
|
|
36
|
+
|
|
37
|
+
hero, cta
|
|
38
|
+
|
|
39
|
+
## Why it works
|
|
40
|
+
|
|
41
|
+
Depth from one cheap device.
|
|
Binary file
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>measure fixture</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { box-sizing: border-box; }
|
|
8
|
+
html, body { margin: 0; }
|
|
9
|
+
body {
|
|
10
|
+
background: #0b1218;
|
|
11
|
+
color: #f2f5f8;
|
|
12
|
+
font-family: Arial;
|
|
13
|
+
}
|
|
14
|
+
h1 { font-family: Georgia; margin: 0 0 16px; }
|
|
15
|
+
p { font-family: Arial; margin: 0 0 16px; }
|
|
16
|
+
a, button { color: #7fb6ff; }
|
|
17
|
+
button { background: transparent; border: 1px solid currentColor; font: inherit; padding: 8px 16px; }
|
|
18
|
+
section { height: 1000px; padding: 48px; }
|
|
19
|
+
|
|
20
|
+
@keyframes reveal {
|
|
21
|
+
from { opacity: 0; transform: translateY(24px); }
|
|
22
|
+
to { opacity: 1; transform: translateY(0); }
|
|
23
|
+
}
|
|
24
|
+
.reveal-target {
|
|
25
|
+
animation: reveal linear both;
|
|
26
|
+
animation-timeline: view();
|
|
27
|
+
animation-range: entry 0% entry 40%;
|
|
28
|
+
}
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<section class="hero">
|
|
33
|
+
<h1>Hero heading</h1>
|
|
34
|
+
<p>Static hero copy that sets the scene.</p>
|
|
35
|
+
<a href="#learn">Learn more</a>
|
|
36
|
+
</section>
|
|
37
|
+
<section class="feature">
|
|
38
|
+
<h1>Feature heading</h1>
|
|
39
|
+
<p class="reveal-target">This paragraph reveals as it enters the viewport.</p>
|
|
40
|
+
</section>
|
|
41
|
+
<section class="closing">
|
|
42
|
+
<h1>Closing heading</h1>
|
|
43
|
+
<p>Closing copy.</p>
|
|
44
|
+
<button type="button">Get started</button>
|
|
45
|
+
</section>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
Binary file
|
package/tests/run.sh
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -u
|
|
3
|
+
cd "$(dirname "$0")/.."
|
|
4
|
+
pass=0; fail=0
|
|
5
|
+
for f in tests/checks/*.sh; do
|
|
6
|
+
if out=$(bash "$f" 2>&1); then pass=$((pass+1)); echo "PASS $f"
|
|
7
|
+
else fail=$((fail+1)); echo "FAIL $f"; echo "$out" | tail -20; fi
|
|
8
|
+
done
|
|
9
|
+
echo "PASS=$pass FAIL=$fail"
|
|
10
|
+
[ "$fail" -eq 0 ]
|
package/vocab.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Five facets, one level deep. A term is added by PR, never by an ingest.
|
|
2
|
+
role: [hero, proof, feature, process, offer, testimonial, faq, closing, capability, explainer, page-head, footer]
|
|
3
|
+
feel: [dark, light, premium, playful, editorial, brutalist, saas, depth, minimal, warm, cold]
|
|
4
|
+
# The eleven data-motion device names from claude-wp-builder's motion.js,
|
|
5
|
+
# then observed-only terms: things seen in reference not a device.
|
|
6
|
+
motion: [reveal, pin, pan, wipe, kinetic, parallax, count, drift, tilt, magnet, spotlight, stagger, marquee, stack, tabs]
|
|
7
|
+
source: [own, open, paid, public-site]
|
|
8
|
+
tier: [inspiration, ported]
|
|
9
|
+
aliases:
|
|
10
|
+
offer: [pricing, plans, tiers, offer-table]
|
|
11
|
+
proof: [logos, social-proof]
|
|
12
|
+
testimonial: [testimonials]
|
|
13
|
+
closing: [cta, call-to-action]
|
|
14
|
+
feature: [features]
|
|
15
|
+
dark: [dark-mode]
|
|
16
|
+
parallax: [depth-scroll]
|