@yojahny/wp-design-library 0.1.0 → 0.2.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/Dockerfile CHANGED
@@ -14,6 +14,10 @@ RUN if [ "$WITH_CHROME" = "1" ] && [ "$(dpkg --print-architecture)" = "amd64" ];
14
14
  WORKDIR /app
15
15
  COPY package*.json ./
16
16
  RUN npm ci --omit=dev
17
+ # The vector arm and URL measurement are optional peers (npm ci skips an optional
18
+ # peer by design); the hosted image wants both, so install them explicitly at the
19
+ # pins package.json declares.
20
+ RUN npm install --no-save --no-audit --no-fund @huggingface/transformers@4.2.0 playwright-core@1.63.0
17
21
  COPY . .
18
22
  # Vector-search models are not baked into the image: they land on the /data volume
19
23
  # under models/ and are fetched at container start when LIBRARY_FETCH_MODELS=1.
package/README.md CHANGED
@@ -24,9 +24,28 @@ only for `own` or `open` sources; the licence gate refuses anything else.
24
24
  ```bash
25
25
  git clone git@github.com:yojahny55/wp-design-library.git
26
26
  cd wp-design-library && npm install
27
- npm test # 24 checks, all should PASS (measure.sh SKIPs without a system Chrome)
27
+ npm test # 25 checks, all should PASS (measure.sh SKIPs without a system Chrome)
28
28
  ```
29
29
 
30
+ ## Install: core vs full
31
+
32
+ A plain `npm install` (or the plugin's `npx @yojahny/wp-design-library serve`) gives you
33
+ keyword search, ingest from recordings, the gallery, and every MCP tool — everything except
34
+ the vector arm of `search`/`similar` and live-URL measurement (`add https://…`, `refresh`).
35
+ Those two features sit behind `@huggingface/transformers` and `playwright-core`, listed as
36
+ optional `peerDependencies` rather than regular dependencies precisely so the `npx` path (what
37
+ the plugin actually runs) stays a core-only install. Measured on a fresh npm cache, a cold
38
+ install is 57 MB and takes 6 s, against 736 MB and 48 s when both are regular dependencies;
39
+ 513 MB of the difference is `onnxruntime-node`, which transformers pulls in.
40
+ Add them yourself when you want both arms:
41
+
42
+ ```bash
43
+ npm install @huggingface/transformers@4.2.0 playwright-core@1.63.0
44
+ ```
45
+
46
+ Without them, `search` degrades to the `fts` arm alone (`skipped` names the missing package),
47
+ and `add`/`refresh` refuse a URL input with a named reason instead of crashing.
48
+
30
49
  ## Adding an entry
31
50
 
32
51
  Adding is two steps by design: the tool does the mechanical part, a Claude
@@ -348,6 +367,36 @@ The container runs as `node` (uid 1000), not root: `docker/entrypoint.sh` starts
348
367
  root only to `chown` an existing (possibly root-owned) `/data` volume, then drops to
349
368
  `node` via `setpriv` before exec'ing the server.
350
369
 
370
+ ## Releasing
371
+
372
+ 1. Bump `version` in `package.json`.
373
+ 2. Tag the release: `git tag v0.2.0` (the workflow triggers on any `v*` tag).
374
+ 3. Push the tag: `git push origin v0.2.0`.
375
+ 4. `.github/workflows/publish.yml` runs on that push: checks out, installs with
376
+ `npm ci`, runs `npm test`, then `npm publish`. Trusted publishing means no npm
377
+ token lives in this repo — the registry trusts the GitHub Actions run itself via
378
+ OIDC (`id-token: write`).
379
+
380
+ One-time setup on npmjs.com, before the first tagged release:
381
+
382
+ 1. Open the package's settings page on npmjs.com.
383
+ 2. Under **Trusted Publisher**, add a publisher.
384
+ 3. Choose **GitHub Actions** as the provider.
385
+ 4. Organisation: `yojahny55`.
386
+ 5. Repository: `wp-design-library`.
387
+ 6. Workflow filename: `publish.yml` (the filename only, not the path under
388
+ `.github/workflows/`).
389
+ 7. Environment: leave empty (the workflow does not define one).
390
+
391
+ Trusted publisher configurations created now default to **staged** publishing rather
392
+ than a direct `npm publish`. Either flip the allowed-actions toggle on the trusted
393
+ publisher to permit direct publishing (matches the workflow above as written), or
394
+ change the workflow's last step to `npm stage publish` and approve each release by
395
+ hand in the npm UI. Provenance is not generated for this release, because this repo
396
+ is private — provenance requires a public repository. After the first successful
397
+ publish, publishing access can be tightened to require 2FA and disallow tokens
398
+ without breaking this workflow, since it never uses a token.
399
+
351
400
  ## Known ceilings
352
401
 
353
402
  - **Chrome runs without its own sandbox inside the container.** Docker's default
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@yojahny/wp-design-library",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Design reference corpus and MCP server for claude-wp-builder",
5
+ "repository": { "type": "git", "url": "git+https://github.com/yojahny55/wp-design-library.git" },
6
+ "homepage": "https://github.com/yojahny55/wp-design-library#readme",
7
+ "bugs": { "url": "https://github.com/yojahny55/wp-design-library/issues" },
5
8
  "type": "module",
6
9
  "bin": { "library": "bin/library.mjs" },
7
10
  "engines": { "node": ">=22" },
@@ -12,15 +15,35 @@
12
15
  "serve": "node bin/library.mjs serve"
13
16
  },
14
17
  "dependencies": {
15
- "@huggingface/transformers": "4.2.0",
16
18
  "@modelcontextprotocol/sdk": "^1.20.0",
17
19
  "better-sqlite3": "^12.2.0",
18
20
  "js-yaml": "^4.1.0",
19
- "playwright-core": "1.63.0",
20
21
  "sharp": "^0.34.3",
21
22
  "sqlite-vec": "0.1.9",
22
23
  "zod": "^3.25.0"
23
24
  },
25
+ "peerDependencies": {
26
+ "@huggingface/transformers": "4.2.0",
27
+ "playwright-core": "1.63.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "@huggingface/transformers": { "optional": true },
31
+ "playwright-core": { "optional": true }
32
+ },
24
33
  "license": "MIT",
25
- "private": false
34
+ "private": false,
35
+ "files": [
36
+ ".dockerignore",
37
+ ".env.example",
38
+ "Dockerfile",
39
+ "LICENSE",
40
+ "README.md",
41
+ "bin/",
42
+ "docker-compose.yml",
43
+ "docker/",
44
+ "entries/",
45
+ "src/",
46
+ "tests/",
47
+ "vocab.yaml"
48
+ ]
26
49
  }
package/src/cli/check.mjs CHANGED
@@ -7,7 +7,8 @@ import { fetchModels, modelDirs } from '../index/embed.mjs';
7
7
  export async function run(args = []) {
8
8
  const p = paths();
9
9
  if (args.includes('--fetch-models')) {
10
- await fetchModels(p.models);
10
+ const r = await fetchModels(p.models);
11
+ if (r?.refused) { process.stdout.write(JSON.stringify(r) + '\n'); return 1; }
11
12
  const d = modelDirs(p.models);
12
13
  process.stdout.write(`model: ${d.text}\nmodel: ${d.image}\n`);
13
14
  return 0;
@@ -19,6 +19,12 @@ export function modelDirs(models) {
19
19
  return { text: path.join(models, TEXT_MODEL), image: path.join(models, IMAGE_MODEL) };
20
20
  }
21
21
 
22
+ // Resolvable without importing/executing the module, so it works as a cheap, sync
23
+ // "is it installed" probe for the optional peer.
24
+ function packageAvailable(specifier) {
25
+ try { import.meta.resolve(specifier); return true; } catch { return false; }
26
+ }
27
+
22
28
  export async function getEmbedder({ models, mode = process.env.LIBRARY_EMBED } = {}) {
23
29
  if (mode === 'off') return null;
24
30
  if (mode === 'stub') {
@@ -34,7 +40,9 @@ export async function getEmbedder({ models, mode = process.env.LIBRARY_EMBED } =
34
40
  }
35
41
  const d = modelDirs(models);
36
42
  if (!fs.existsSync(d.text) || !fs.existsSync(d.image)) return null; // caller reads absentReason()
37
- const tf = await import('@huggingface/transformers');
43
+ let tf;
44
+ try { tf = await import('@huggingface/transformers'); }
45
+ catch { return null; } // package not installed; caller reads absentReason()
38
46
  tf.env.cacheDir = models; tf.env.allowRemoteModels = process.env.LIBRARY_FETCH_MODELS === '1';
39
47
  const textPipe = await tf.pipeline('feature-extraction', TEXT_MODEL);
40
48
  const proc = await tf.AutoProcessor.from_pretrained(IMAGE_MODEL);
@@ -55,13 +63,19 @@ export function absentReason(models, mode = process.env.LIBRARY_EMBED) {
55
63
  // An explicit opt-out is the reason on its own; do not let a stale/present
56
64
  // model dir on disk relabel it, and do not touch the filesystem to find out.
57
65
  if (mode === 'off') return 'LIBRARY_EMBED=off';
66
+ // The package is a smaller, more fundamental blocker than an unfetched model
67
+ // directory, and it's checked first so a host missing the peer gets told that,
68
+ // not a misleading "models absent" (a fresh host has neither).
69
+ if (!packageAvailable('@huggingface/transformers')) return '@huggingface/transformers is not installed';
58
70
  const d = modelDirs(models);
59
71
  const missing = Object.entries(d).filter(([, p]) => !fs.existsSync(p)).map(([k]) => k);
60
72
  return missing.length ? `models absent: ${missing.join(', ')} under ${models}` : null;
61
73
  }
62
74
 
63
75
  export async function fetchModels(models) {
64
- const tf = await import('@huggingface/transformers');
76
+ let tf;
77
+ try { tf = await import('@huggingface/transformers'); }
78
+ catch { return { refused: '@huggingface/transformers is not installed' }; }
65
79
  tf.env.cacheDir = models; tf.env.allowRemoteModels = true;
66
80
  await tf.pipeline('feature-extraction', TEXT_MODEL);
67
81
  await tf.AutoProcessor.from_pretrained(IMAGE_MODEL);
@@ -20,7 +20,7 @@ function filterSql(filters = {}, col = 'slug') {
20
20
 
21
21
  const RRF_K = 60;
22
22
 
23
- export async function search(db, { query = '', filters = {}, limit = 10, embedder = null }) {
23
+ export async function search(db, { query = '', filters = {}, limit = 10, embedder = null, embedderReason = null }) {
24
24
  const { where, params } = filterSql(filters, 'e.slug');
25
25
  if (!query.trim()) {
26
26
  const { where, params } = filterSql(filters);
@@ -39,7 +39,7 @@ export async function search(db, { query = '', filters = {}, limit = 10, embedde
39
39
  return { arms: ['fts'], skipped: [{ arm: 'vec', reason }], hits: rows };
40
40
  };
41
41
  if (!(db.vec && embedder)) {
42
- return ftsOnly(db.vec ? 'no embedder' : 'sqlite-vec not loaded');
42
+ return ftsOnly(db.vec ? (embedderReason ?? 'no embedder') : 'sqlite-vec not loaded');
43
43
  }
44
44
  let vec;
45
45
  try {
@@ -65,9 +65,9 @@ export async function search(db, { query = '', filters = {}, limit = 10, embedde
65
65
  };
66
66
  }
67
67
 
68
- export async function similar(db, { slug, image, limit = 10, embedder = null }) {
68
+ export async function similar(db, { slug, image, limit = 10, embedder = null, embedderReason = null }) {
69
69
  if (!(db.vec && embedder)) {
70
- return { refused: `similar needs the image arm: ${db.vec ? 'no embedder' : 'sqlite-vec not loaded'}` };
70
+ return { refused: `similar needs the image arm: ${db.vec ? (embedderReason ?? 'no embedder') : 'sqlite-vec not loaded'}` };
71
71
  }
72
72
  let vec;
73
73
  if (slug) {
@@ -1,6 +1,5 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { chromium } from 'playwright-core';
4
3
 
5
4
  const VIEWPORT_WIDE = { width: 1440, height: 900 };
6
5
  const VIEWPORT_NARROW = { width: 390, height: 844 };
@@ -87,6 +86,12 @@ function collect() {
87
86
  // comes back as { ok: false, error }, with whatever partial output (frames,
88
87
  // screenshots) had already been captured still attached.
89
88
  export async function measure(url, outDir, { chrome = process.env.CHROME_PATH, record = false, stops = 12 } = {}) {
89
+ let chromium;
90
+ try {
91
+ ({ chromium } = await import('playwright-core'));
92
+ } catch {
93
+ return { ok: false, error: 'playwright-core is not installed; install it for URL measurement' };
94
+ }
90
95
  if (!chrome || !fs.existsSync(chrome)) {
91
96
  return { ok: false, error: 'chrome not available: CHROME_PATH not set or not found' };
92
97
  }
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { paths } from '../paths.mjs';
4
4
  import { loadVocab } from '../vocab.mjs';
5
- import { getEmbedder } from '../index/embed.mjs';
5
+ import { getEmbedder, absentReason } from '../index/embed.mjs';
6
6
  import { registerPrompts } from './prompts.mjs';
7
7
  import { registerResources } from './resources.mjs';
8
8
  import { makeTools, registerTools } from './tools.mjs';
@@ -13,6 +13,8 @@ export async function buildServer(opts = {}) {
13
13
  const ctx = { paths: p, vocab: loadVocab(p.vocab), transport, models: p.models };
14
14
  // http passes its own cached embedder in (one per process, not one per request); stdio resolves its own.
15
15
  ctx.embedder = 'embedder' in opts ? opts.embedder : await getEmbedder({ models: ctx.models });
16
+ // Names why, when it's null: off / package not installed / models absent are distinct.
17
+ ctx.embedderReason = ctx.embedder ? null : absentReason(ctx.models);
16
18
  const server = new McpServer({ name: 'wp-design-library', version: p.version });
17
19
  registerTools(server, makeTools(ctx));
18
20
  registerPrompts(server, ctx);
package/src/mcp/tools.mjs CHANGED
@@ -70,7 +70,7 @@ export function makeTools(ctx) {
70
70
  }),
71
71
  handler: withDb(async (db, a) => {
72
72
  const { filters, unknown } = resolveFilters(ctx.vocab, a.filters ?? {});
73
- const result = await search(db, { ...a, filters, embedder: ctx.embedder });
73
+ const result = await search(db, { ...a, filters, embedder: ctx.embedder, embedderReason: ctx.embedderReason });
74
74
  return unknown.length ? { ...result, unknown } : result;
75
75
  }),
76
76
  },
@@ -97,7 +97,7 @@ export function makeTools(ctx) {
97
97
  const refusal = confineToInbox(ctx, a.image);
98
98
  if (refusal) return { refused: refusal };
99
99
  }
100
- return similar(db, { ...a, embedder: ctx.embedder });
100
+ return similar(db, { ...a, embedder: ctx.embedder, embedderReason: ctx.embedderReason });
101
101
  }),
102
102
  },
103
103
  {
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  set -eu
3
3
  cd "$(dirname "$0")/../.."
4
+ command -v ffmpeg >/dev/null || { echo "SKIP: ffmpeg not installed"; exit 0; }
4
5
  tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
5
6
  mkdir -p "$tmp/f" "$tmp/data/entries"
6
7
  node --input-type=module -e 'import sharp from "sharp"; for (let i = 0; i < 60; i++) await sharp({create:{width:40,height:20,channels:3,background:{r:i*4,g:0,b:0}}}).png().toFile(process.argv[1] + "/" + i + ".png")' "$tmp/f"
@@ -6,7 +6,7 @@ mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
6
6
  # models absent → fts only, skip reported, exit 0
7
7
  out=$(LIBRARY_DATA="$tmp" LIBRARY_EMBED= node bin/library.mjs index 2>&1)
8
8
  echo "$out" | grep -qE "arms: fts([^,]|$)" || { echo "FAIL: arms without models; got: $out"; exit 1; }
9
- echo "$out" | grep -q "embeddings: skipped (models absent" || { echo "FAIL: skip line; got: $out"; exit 1; }
9
+ echo "$out" | grep -qE "embeddings: skipped \((models absent|@huggingface/transformers is not installed)" || { echo "FAIL: skip line; got: $out"; exit 1; }
10
10
  # stub → both arms, vectors stored with the right dims
11
11
  out=$(LIBRARY_DATA="$tmp" LIBRARY_EMBED=stub node bin/library.mjs index 2>&1)
12
12
  echo "$out" | grep -q "arms: fts,vec" || { echo "FAIL: arms with stub; got: $out"; exit 1; }
@@ -3,6 +3,8 @@ set -eu
3
3
  cd "$(dirname "$0")/../.."
4
4
  [ -n "${CHROME_PATH:-}" ] || CHROME_PATH=$(command -v google-chrome || command -v chromium || true)
5
5
  [ -n "$CHROME_PATH" ] || { echo "SKIP: no chrome/chromium found"; exit 0; }
6
+ node --input-type=module -e 'await import("playwright-core")' 2>/dev/null \
7
+ || { echo "SKIP: playwright-core not installed"; exit 0; }
6
8
  export CHROME_PATH
7
9
  tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
8
10
  mkdir -p "$tmp/entries"
@@ -51,7 +53,9 @@ if (strip.height < single.height * 2) { console.error('FAIL: strip height ' + st
51
53
  # refresh re-measures and rewrites captured.at
52
54
  out=$(LIBRARY_DATA="$tmp" node bin/library.mjs refresh fx)
53
55
  echo "$out" | grep -q '"ok":true' || { echo "FAIL: refresh did not succeed"; echo "$out"; exit 1; }
54
- today=$(date +%Y-%m-%d)
56
+ # UTC, because measure.mjs writes `new Date().toISOString()`. Using local `date`
57
+ # here fails every evening west of Greenwich, on a correct entry.
58
+ today=$(date -u +%Y-%m-%d)
55
59
  grep -Eq "^ at: '?$today'?\$" "$tmp/entries/fx/entry.md" || { echo "FAIL: captured.at not rewritten to today"; cat "$tmp/entries/fx/entry.md"; exit 1; }
56
60
 
57
61
  # add without --record: strip is just the 1440 screenshot, method is measured (not both)
@@ -0,0 +1,55 @@
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
+
7
+ # A sandbox that genuinely lacks the two optional peers: copy the code, then symlink
8
+ # every real node_modules entry except @huggingface/transformers and playwright-core.
9
+ # Scoped packages are symlinked individually under @scope/, not the scope directory
10
+ # itself — symlinking the scope dir would drag the excluded package back in.
11
+ sandbox="$tmp/sandbox"
12
+ mkdir -p "$sandbox/node_modules"
13
+ cp -r bin src package.json vocab.yaml "$sandbox/"
14
+ for entry in node_modules/*; do
15
+ name=$(basename "$entry")
16
+ case "$name" in
17
+ @*|playwright-core) continue ;;
18
+ esac
19
+ ln -s "$PWD/$entry" "$sandbox/node_modules/$name"
20
+ done
21
+ for scope in node_modules/@*; do
22
+ sname=$(basename "$scope")
23
+ mkdir -p "$sandbox/node_modules/$sname"
24
+ for pkg in "$scope"/*; do
25
+ pname=$(basename "$pkg")
26
+ [ "$sname/$pname" = "@huggingface/transformers" ] && continue
27
+ ln -s "$PWD/$pkg" "$sandbox/node_modules/$sname/$pname"
28
+ done
29
+ done
30
+ [ ! -e "$sandbox/node_modules/playwright-core" ] || { echo "FAIL: sandbox still has playwright-core"; exit 1; }
31
+ [ ! -e "$sandbox/node_modules/@huggingface/transformers" ] || { echo "FAIL: sandbox still has @huggingface/transformers"; exit 1; }
32
+
33
+ # search over stdio: fts only, skip names the transformers package
34
+ {
35
+ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
36
+ printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
37
+ printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"parallax"}}}'
38
+ sleep 1
39
+ } | LIBRARY_DATA="$tmp" LIBRARY_SEED="$tmp/noseed" timeout 10 node "$sandbox/bin/library.mjs" serve >"$tmp/out" 2>"$tmp/err" || true
40
+ grep -q '"arms":\["fts"\]' "$tmp/out" || { echo "FAIL: arms not fts-only; got:"; cat "$tmp/out"; exit 1; }
41
+ grep -q '"skipped":\[{"arm":"vec","reason":"@huggingface/transformers is not installed"}\]' "$tmp/out" \
42
+ || { echo "FAIL: skipped reason did not name the transformers package; got:"; cat "$tmp/out"; exit 1; }
43
+
44
+ # add <https url>: refused, naming playwright-core
45
+ out=$(LIBRARY_DATA="$tmp" node "$sandbox/bin/library.mjs" add https://example.com --slug px-url --title "Px Url" --source own --license x) || true
46
+ echo "$out" | grep -q '"refused"' || { echo "FAIL: https add did not refuse; got: $out"; exit 1; }
47
+ echo "$out" | grep -q "playwright-core is not installed" || { echo "FAIL: refusal did not name playwright-core; got: $out"; exit 1; }
48
+
49
+ # add <local recording>: still works — the regression a static playwright-core import
50
+ # in measure.mjs would cause, since add.mjs imports measure.mjs unconditionally
51
+ out=$(LIBRARY_DATA="$tmp" node "$sandbox/bin/library.mjs" add "$PWD/tests/fixtures/three-frame.webp" --slug px-local --title "Px Local" --source own --license x)
52
+ echo "$out" | grep -q '"blanks"' || { echo "FAIL: local-file add did not draft; got: $out"; exit 1; }
53
+ [ -f "$tmp/entries/px-local/strip.png" ] || { echo "FAIL: strip.png missing for local-file add"; exit 1; }
54
+
55
+ echo PASS
package/tests/run.sh CHANGED
@@ -1,10 +1,16 @@
1
1
  #!/usr/bin/env bash
2
2
  set -u
3
3
  cd "$(dirname "$0")/.."
4
- pass=0; fail=0
4
+ pass=0; fail=0; skip=0
5
+ # A check that cannot run its subject exits 0 after printing SKIP. Counting those as
6
+ # PASS would let a runner missing every tool report a clean suite, which is how three
7
+ # absent-dependency failures reached the publish workflow unnoticed.
5
8
  for f in tests/checks/*.sh; do
6
- if out=$(bash "$f" 2>&1); then pass=$((pass+1)); echo "PASS $f"
9
+ if out=$(bash "$f" 2>&1); then
10
+ if printf '%s' "$out" | grep -q '^SKIP'; then
11
+ skip=$((skip+1)); echo "SKIP $f ($(printf '%s' "$out" | grep -m1 '^SKIP' | cut -c7-))"
12
+ else pass=$((pass+1)); echo "PASS $f"; fi
7
13
  else fail=$((fail+1)); echo "FAIL $f"; echo "$out" | tail -20; fi
8
14
  done
9
- echo "PASS=$pass FAIL=$fail"
15
+ echo "PASS=$pass SKIP=$skip FAIL=$fail"
10
16
  [ "$fail" -eq 0 ]