@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.
Files changed (74) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +7 -0
  3. package/Dockerfile +27 -0
  4. package/LICENSE +21 -0
  5. package/README.md +382 -0
  6. package/bin/library.mjs +19 -0
  7. package/docker/entrypoint.sh +11 -0
  8. package/docker-compose.yml +18 -0
  9. package/entries/.gitkeep +0 -0
  10. package/entries/ais-community-dark-depth/entry.md +76 -0
  11. package/entries/ais-community-dark-depth/strip.png +0 -0
  12. package/package.json +26 -0
  13. package/src/cli/add.mjs +163 -0
  14. package/src/cli/check.mjs +26 -0
  15. package/src/cli/export.mjs +41 -0
  16. package/src/cli/index.mjs +12 -0
  17. package/src/cli/refresh.mjs +63 -0
  18. package/src/cli/save.mjs +16 -0
  19. package/src/cli/serve.mjs +64 -0
  20. package/src/cli/ui.mjs +18 -0
  21. package/src/entry.mjs +69 -0
  22. package/src/index/build.mjs +78 -0
  23. package/src/index/embed.mjs +69 -0
  24. package/src/index/query.mjs +103 -0
  25. package/src/index/schema-vec.sql +4 -0
  26. package/src/index/schema.sql +10 -0
  27. package/src/ingest/draft.mjs +32 -0
  28. package/src/ingest/frames.mjs +93 -0
  29. package/src/ingest/measure.mjs +158 -0
  30. package/src/ingest/save.mjs +11 -0
  31. package/src/ingest/url-guard.mjs +76 -0
  32. package/src/mcp/http.mjs +72 -0
  33. package/src/mcp/prompts.mjs +147 -0
  34. package/src/mcp/resources.mjs +24 -0
  35. package/src/mcp/server.mjs +28 -0
  36. package/src/mcp/tools.mjs +152 -0
  37. package/src/paths.mjs +21 -0
  38. package/src/ui/app.js +34 -0
  39. package/src/ui/build.mjs +122 -0
  40. package/src/ui/serve.mjs +38 -0
  41. package/src/ui/templates/entry.html +31 -0
  42. package/src/ui/templates/index.html +32 -0
  43. package/src/vocab.mjs +34 -0
  44. package/tests/README.md +21 -0
  45. package/tests/checks/cli-check.sh +13 -0
  46. package/tests/checks/docker.sh +10 -0
  47. package/tests/checks/entry.sh +55 -0
  48. package/tests/checks/export.sh +16 -0
  49. package/tests/checks/fetch-on-start.sh +26 -0
  50. package/tests/checks/frames-dense.sh +26 -0
  51. package/tests/checks/http.sh +22 -0
  52. package/tests/checks/hygiene.sh +29 -0
  53. package/tests/checks/inbox.sh +27 -0
  54. package/tests/checks/index-degrade.sh +32 -0
  55. package/tests/checks/index.sh +36 -0
  56. package/tests/checks/ingest-mp4.sh +47 -0
  57. package/tests/checks/ingest.sh +39 -0
  58. package/tests/checks/licence-gate.sh +24 -0
  59. package/tests/checks/mcp-stdout.sh +31 -0
  60. package/tests/checks/measure.sh +75 -0
  61. package/tests/checks/minors.sh +84 -0
  62. package/tests/checks/prompt-add-entry.sh +28 -0
  63. package/tests/checks/resources.sh +44 -0
  64. package/tests/checks/rrf.sh +120 -0
  65. package/tests/checks/seed-sync.sh +22 -0
  66. package/tests/checks/similar.sh +62 -0
  67. package/tests/checks/ui-build.sh +59 -0
  68. package/tests/checks/vocab.sh +30 -0
  69. package/tests/fixtures/entry-ok/entry.md +41 -0
  70. package/tests/fixtures/entry-ok/strip.png +0 -0
  71. package/tests/fixtures/page/index.html +47 -0
  72. package/tests/fixtures/three-frame.webp +0 -0
  73. package/tests/run.sh +10 -0
  74. package/vocab.yaml +16 -0
@@ -0,0 +1,103 @@
1
+ import Database from 'better-sqlite3';
2
+ import * as sqliteVec from 'sqlite-vec';
3
+
4
+ export function openIndex(indexPath, { readonly = true } = {}) {
5
+ const db = new Database(indexPath, { readonly, fileMustExist: readonly });
6
+ try { sqliteVec.load(db); db.vec = true; } catch { db.vec = false; }
7
+ return db;
8
+ }
9
+
10
+ function filterSql(filters = {}, col = 'slug') {
11
+ const clauses = [], params = [];
12
+ for (const [facet, terms] of Object.entries(filters)) {
13
+ const list = Array.isArray(terms) ? terms : [terms];
14
+ if (!list.length) continue;
15
+ clauses.push(`${col} IN (SELECT slug FROM entry_tags WHERE facet=? AND term IN (${list.map(() => '?').join(',')}))`);
16
+ params.push(facet, ...list);
17
+ }
18
+ return { where: clauses.length ? ' AND ' + clauses.join(' AND ') : '', params };
19
+ }
20
+
21
+ const RRF_K = 60;
22
+
23
+ export async function search(db, { query = '', filters = {}, limit = 10, embedder = null }) {
24
+ const { where, params } = filterSql(filters, 'e.slug');
25
+ if (!query.trim()) {
26
+ const { where, params } = filterSql(filters);
27
+ const rows = db.prepare(`SELECT slug, title, tier, 0 AS score FROM entries WHERE 1=1 ${where} ORDER BY captured_at DESC LIMIT ?`)
28
+ .all(...params, limit);
29
+ return { arms: ['fts'], hits: rows };
30
+ }
31
+ const q = fts5Quote(query);
32
+ const ftsOnly = (reason) => {
33
+ const rows = db.prepare(
34
+ `SELECT e.slug, e.title, e.tier, bm25(entries_fts) AS score
35
+ FROM entries_fts JOIN entries e ON e.slug = entries_fts.slug
36
+ WHERE entries_fts MATCH ? ${where}
37
+ ORDER BY score LIMIT ?`
38
+ ).all(q, ...params, limit);
39
+ return { arms: ['fts'], skipped: [{ arm: 'vec', reason }], hits: rows };
40
+ };
41
+ if (!(db.vec && embedder)) {
42
+ return ftsOnly(db.vec ? 'no embedder' : 'sqlite-vec not loaded');
43
+ }
44
+ let vec;
45
+ try {
46
+ vec = await embedder.text(query);
47
+ } catch (err) {
48
+ return ftsOnly(`vec arm failed: ${err.message}`);
49
+ }
50
+ const rows = db.prepare(`
51
+ WITH f AS (SELECT slug, r FROM (
52
+ SELECT slug, row_number() OVER (ORDER BY bm25(entries_fts)) AS r
53
+ FROM entries_fts WHERE entries_fts MATCH ?
54
+ ) ORDER BY r LIMIT 50),
55
+ v AS (SELECT slug, row_number() OVER (ORDER BY distance) AS r
56
+ FROM vec_text WHERE embedding MATCH ? AND k = 50)
57
+ SELECT e.slug, e.title, e.tier, f.r AS fts_rank, v.r AS vec_rank,
58
+ COALESCE(1.0/(${RRF_K}+f.r),0) + COALESCE(1.0/(${RRF_K}+v.r),0) AS score
59
+ FROM entries e LEFT JOIN f ON f.slug = e.slug LEFT JOIN v ON v.slug = e.slug
60
+ WHERE (f.r IS NOT NULL OR v.r IS NOT NULL) ${where}
61
+ ORDER BY score DESC LIMIT ?`).all(q, Buffer.from(vec.buffer), ...params, limit);
62
+ return {
63
+ arms: ['fts', 'vec'],
64
+ hits: rows.map((r) => ({ slug: r.slug, title: r.title, tier: r.tier, score: r.score, ranks: { fts: r.fts_rank, vec: r.vec_rank } })),
65
+ };
66
+ }
67
+
68
+ export async function similar(db, { slug, image, limit = 10, embedder = null }) {
69
+ if (!(db.vec && embedder)) {
70
+ return { refused: `similar needs the image arm: ${db.vec ? 'no embedder' : 'sqlite-vec not loaded'}` };
71
+ }
72
+ let vec;
73
+ if (slug) {
74
+ const row = db.prepare(`SELECT embedding FROM vec_image WHERE slug = ?`).get(slug);
75
+ if (!row) return { refused: `similar needs the image arm: no vector for slug ${slug}` };
76
+ vec = row.embedding;
77
+ } else {
78
+ try {
79
+ vec = Buffer.from((await embedder.image(image)).buffer);
80
+ } catch (err) {
81
+ return { refused: `vec arm failed: ${err.message}` };
82
+ }
83
+ }
84
+ // k = 50: sqlite-vec 0.1.9 requires an explicit k alongside MATCH for a KNN query.
85
+ // slug ?? '' never matches a real slug, so an image query (no slug) excludes nothing.
86
+ const rows = db.prepare(`
87
+ WITH v AS (SELECT slug, distance FROM vec_image WHERE embedding MATCH ? AND k = 50)
88
+ SELECT e.slug, e.title, v.distance
89
+ FROM v JOIN entries e ON e.slug = v.slug
90
+ WHERE v.slug != ?
91
+ ORDER BY v.distance LIMIT ?`).all(vec, slug ?? '', limit);
92
+ return { arms: ['vec'], hits: rows };
93
+ }
94
+
95
+ function fts5Quote(q) {
96
+ return q.split(/\s+/).filter(Boolean).map((t) => `"${t.replace(/"/g, '""')}"`).join(' ');
97
+ }
98
+
99
+ export function getEntry(db, slug) {
100
+ const row = db.prepare(`SELECT * FROM entries WHERE slug = ?`).get(slug);
101
+ if (!row) return null;
102
+ return { slug: row.slug, title: row.title, tier: row.tier, dir: row.dir, body: row.body, fm: JSON.parse(row.frontmatter_json) };
103
+ }
@@ -0,0 +1,4 @@
1
+ DROP TABLE IF EXISTS vec_text;
2
+ DROP TABLE IF EXISTS vec_image;
3
+ CREATE VIRTUAL TABLE vec_text USING vec0(slug TEXT PRIMARY KEY, embedding float[384]);
4
+ CREATE VIRTUAL TABLE vec_image USING vec0(slug TEXT PRIMARY KEY, embedding float[768]);
@@ -0,0 +1,10 @@
1
+ DROP TABLE IF EXISTS entries;
2
+ DROP TABLE IF EXISTS entry_tags;
3
+ DROP TABLE IF EXISTS entries_fts;
4
+ CREATE TABLE entries (
5
+ slug TEXT PRIMARY KEY, title TEXT NOT NULL, tier TEXT NOT NULL, source_kind TEXT NOT NULL,
6
+ captured_at TEXT NOT NULL, dir TEXT NOT NULL, body TEXT NOT NULL, frontmatter_json TEXT NOT NULL
7
+ );
8
+ CREATE TABLE entry_tags (slug TEXT NOT NULL, facet TEXT NOT NULL, term TEXT NOT NULL);
9
+ CREATE INDEX entry_tags_ft ON entry_tags(facet, term);
10
+ CREATE VIRTUAL TABLE entries_fts USING fts5(slug UNINDEXED, title, body, tags, tokenize='trigram');
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { REQUIRED_BLANKS } from '../entry.mjs';
5
+
6
+ function get(obj, dotted) {
7
+ return dotted.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
8
+ }
9
+ function isBlank(v) {
10
+ return v == null || v === '' || (Array.isArray(v) && v.length === 0);
11
+ }
12
+
13
+ export function writeDraft({ entriesDir, slug, title, source, media, captured, palette, type }) {
14
+ const dir = path.join(entriesDir, slug);
15
+ fs.mkdirSync(dir, { recursive: true });
16
+ const fm = {
17
+ slug, title, tier: 'inspiration',
18
+ source: { kind: source.kind, url: source.url ?? null, license: source.license },
19
+ captured, media,
20
+ roles: [], feel: [],
21
+ palette: palette ?? { canvas: '', ink: '', accent: '' },
22
+ type: type ?? { display: '', body: '' },
23
+ motion: { devices: [], notes: '' },
24
+ ported_from: null,
25
+ };
26
+ fs.writeFileSync(path.join(dir, 'entry.md'), `---\n${yaml.dump(fm, { lineWidth: 120 })}---\n`);
27
+ // Report only the blanks a caller actually still needs to fill — a mechanically
28
+ // filled field (measured palette/type from a URL add) is not "blank" just because
29
+ // the frame-sampling path always leaves it that way.
30
+ const blanks = REQUIRED_BLANKS.filter((k) => isBlank(get(fm, k)));
31
+ return { dir, blanks };
32
+ }
@@ -0,0 +1,93 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import sharp from 'sharp';
6
+
7
+ const VIDEO = /\.(mp4|mov|webm|mkv)$/i;
8
+
9
+ function which(bin) {
10
+ try { execFileSync('sh', ['-c', `command -v ${bin}`], { stdio: 'pipe' }); return true; } catch { return false; }
11
+ }
12
+
13
+ function readFailure(input, e) {
14
+ const reason = typeof e === 'string' ? e : (e.stderr?.toString().split('\n').find(Boolean) ?? e.message);
15
+ return new Error(`ffmpeg could not read ${path.basename(input)}: ${reason}`);
16
+ }
17
+
18
+ // Browser MediaRecorder webm has no format=duration (ffprobe prints N/A). Fall back to
19
+ // counting the video stream's frames against its rate: dur = frames / (num/den).
20
+ // Parsed by key, not position: ffprobe's default writer does not emit fields in the
21
+ // order -show_entries lists them (avg_frame_rate prints before nb_read_frames here).
22
+ function streamCountDuration(input) {
23
+ try {
24
+ const out = execFileSync('ffprobe', [
25
+ '-v', 'error', '-select_streams', 'v:0', '-count_frames',
26
+ '-show_entries', 'stream=nb_read_frames,avg_frame_rate', '-of', 'default=nw=1', '-i', input,
27
+ ], { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
28
+ const nFrames = Number(out.match(/^nb_read_frames=(\S+)/m)?.[1]);
29
+ const [num, den] = (out.match(/^avg_frame_rate=(\S+)/m)?.[1] ?? '').split('/').map(Number);
30
+ if (!Number.isFinite(nFrames) || nFrames <= 0 || !Number.isFinite(num) || !Number.isFinite(den) || num <= 0 || den <= 0) return null;
31
+ return nFrames / (num / den);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ async function videoFrames(input, want) {
38
+ if (!which('ffmpeg') || !which('ffprobe')) throw new Error('ffmpeg is required for video input and was not found on PATH');
39
+ let dur;
40
+ try {
41
+ const probed = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', '-i', input], { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
42
+ dur = Number(probed);
43
+ } catch (e) {
44
+ throw readFailure(input, e);
45
+ }
46
+ if (!Number.isFinite(dur) || dur <= 0) dur = streamCountDuration(input) ?? dur;
47
+ if (!Number.isFinite(dur) || dur <= 0) throw new Error(`ffmpeg could not read ${path.basename(input)}: no duration`);
48
+ const durationMs = Math.round(dur * 1000);
49
+ const n = want ?? Math.min(36, Math.max(6, Math.round(durationMs / 500)));
50
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wpdl-'));
51
+ try {
52
+ execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', input, '-vf', `fps=${n / dur}`, '-frames:v', String(n), path.join(dir, '%03d.png')], { stdio: ['ignore', 'pipe', 'pipe'] });
53
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.png')).sort().map((f) => path.join(dir, f));
54
+ if (files.length === 0) throw new Error('no frames decoded');
55
+ return { files, durationMs, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
56
+ } catch (e) {
57
+ fs.rmSync(dir, { recursive: true, force: true });
58
+ throw readFailure(input, e.message === 'no frames decoded' ? e.message : e);
59
+ }
60
+ }
61
+
62
+ export async function compositeStrip(tiles, w, h, outPng) {
63
+ const cols = tiles.length > 12 ? 6 : Math.min(3, tiles.length), rows = Math.ceil(tiles.length / cols);
64
+ await sharp({ create: { width: w * cols, height: h * rows, channels: 3, background: '#000' } })
65
+ .composite(tiles.map((b, k) => ({ input: b, left: (k % cols) * w, top: Math.floor(k / cols) * h })))
66
+ .png().toFile(outPng);
67
+ }
68
+
69
+ export async function sampleFrames(input, outPng, { frames: want } = {}) {
70
+ if (VIDEO.test(input)) {
71
+ const { files, durationMs, cleanup } = await videoFrames(input, want);
72
+ try {
73
+ const tiles = await Promise.all(files.map((f) => sharp(f).png().toBuffer()));
74
+ const first = await sharp(files[0]).metadata();
75
+ await compositeStrip(tiles, first.width, first.height, outPng);
76
+ return { frames: files.length, durationMs, sampled: files.map((_, i) => i) };
77
+ } finally {
78
+ cleanup();
79
+ }
80
+ }
81
+ const meta = await sharp(input, { pages: -1 }).metadata();
82
+ const frames = meta.pages ?? 1;
83
+ const delays = meta.delay ?? [];
84
+ const durationMs = delays.reduce((a, b) => a + b, 0);
85
+ const byDuration = durationMs > 0 ? Math.round(durationMs / 500) : 12;
86
+ let n = Math.max(1, Math.min(frames, want ?? Math.min(36, Math.max(6, byDuration))));
87
+ if (frames >= 2) n = Math.max(2, n);
88
+ const sampled = n >= frames ? [...Array(frames).keys()] : [...Array(n).keys()].map((i) => Math.round((i * (frames - 1)) / (n - 1)));
89
+ const w = meta.width, h = meta.pageHeight ?? meta.height;
90
+ const tiles = await Promise.all(sampled.map((i) => sharp(input, { page: i }).png().toBuffer()));
91
+ await compositeStrip(tiles, w, h, outPng);
92
+ return { frames, durationMs, sampled };
93
+ }
@@ -0,0 +1,158 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { chromium } from 'playwright-core';
4
+
5
+ const VIEWPORT_WIDE = { width: 1440, height: 900 };
6
+ const VIEWPORT_NARROW = { width: 390, height: 844 };
7
+
8
+ // Runs inside the page via page.evaluate — no Node globals, no sharp, nothing
9
+ // from this module's closure crosses the Playwright boundary.
10
+ function collect() {
11
+ function toHex(raw) {
12
+ const m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\s*\)$/.exec(raw || '');
13
+ if (!m) return null;
14
+ const [, r, g, b, a] = m;
15
+ if (a !== undefined && Number(a) === 0) return null; // fully transparent: not a real token
16
+ const hex = (n) => Math.round(Number(n)).toString(16).padStart(2, '0');
17
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
18
+ }
19
+
20
+ const bodyStyle = getComputedStyle(document.body);
21
+ let canvas = toHex(bodyStyle.backgroundColor);
22
+ if (!canvas) canvas = toHex(getComputedStyle(document.documentElement).backgroundColor) || '';
23
+ const ink = toHex(bodyStyle.color) || '';
24
+
25
+ const freq = new Map();
26
+ for (const el of document.querySelectorAll('a, button, [role="button"]')) {
27
+ const cs = getComputedStyle(el);
28
+ for (const raw of [cs.color, cs.backgroundColor]) {
29
+ const hex = toHex(raw);
30
+ if (hex && hex !== canvas && hex !== ink) freq.set(hex, (freq.get(hex) || 0) + 1);
31
+ }
32
+ }
33
+ let accent = '';
34
+ let best = 0;
35
+ for (const [hex, count] of freq) if (count > best) { best = count; accent = hex; }
36
+
37
+ const h1 = document.querySelector('h1');
38
+ const p = document.querySelector('p');
39
+ const fontDisplay = h1 ? getComputedStyle(h1).fontFamily : '';
40
+ const fontBody = p ? getComputedStyle(p).fontFamily : '';
41
+
42
+ const sections = [...document.querySelectorAll('section, header, main > *, footer')].map((el, index) => {
43
+ const rect = el.getBoundingClientRect();
44
+ const animations = el.getAnimations({ subtree: true }).map((anim) => {
45
+ const target = anim.effect?.target;
46
+ const cls = target?.className ? String(target.className).trim() : '';
47
+ const targetStr = target ? (cls ? `${target.tagName.toLowerCase()}.${cls}` : target.tagName.toLowerCase()) : '';
48
+ let timeline = 'none';
49
+ if (anim.timeline) {
50
+ if (typeof ViewTimeline !== 'undefined' && anim.timeline instanceof ViewTimeline) timeline = 'view';
51
+ else if (typeof ScrollTimeline !== 'undefined' && anim.timeline instanceof ScrollTimeline) timeline = 'scroll';
52
+ else timeline = 'document';
53
+ }
54
+ // rangeStart/rangeEnd are CSSKeywordish: the string 'normal' (no animation-range
55
+ // set), or a { rangeName, offset: CSSNumericValue } dictionary whose offset needs
56
+ // its own toString() — naive template-literal concatenation stringifies the
57
+ // object as "[object Object]".
58
+ const fmtBoundary = (v) => {
59
+ if (v == null) return null;
60
+ if (typeof v === 'string') return v;
61
+ return v.offset != null ? `${v.rangeName} ${v.offset}` : v.rangeName;
62
+ };
63
+ const rStart = fmtBoundary(anim.rangeStart), rEnd = fmtBoundary(anim.rangeEnd);
64
+ const range = rStart && rEnd && !(rStart === 'normal' && rEnd === 'normal') ? `${rStart} ${rEnd}` : null;
65
+ const rawDuration = anim.effect?.getTiming?.().duration;
66
+ const duration = typeof rawDuration === 'number' ? rawDuration : null;
67
+ return { target: targetStr, timeline, range, duration };
68
+ });
69
+ return {
70
+ index, tag: el.tagName.toLowerCase(), id: el.id || '',
71
+ class: el.className ? String(el.className).trim() : '',
72
+ top: Math.round(rect.top + window.scrollY), height: Math.round(rect.height),
73
+ animations,
74
+ };
75
+ });
76
+
77
+ return { tokens: { canvas, ink, accent, fontDisplay, fontBody }, sections };
78
+ }
79
+
80
+ // Measures a live page with the system Chrome: computed-style tokens, a per-section
81
+ // animation inventory, and two screenshots (1440/390). With record:true it also
82
+ // scrolls top to bottom in `stops` equal steps at 1440, screenshotting each —
83
+ // add.mjs feeds those frames to the strip compositor. Never throws: chrome
84
+ // missing/unlaunchable is caught up front, and everything from here (viewport,
85
+ // navigation, screenshots, the scroll loop, the evaluate) is one outer try/catch,
86
+ // so any failure — named ('page failed to load', 'measurement failed') or not —
87
+ // comes back as { ok: false, error }, with whatever partial output (frames,
88
+ // screenshots) had already been captured still attached.
89
+ export async function measure(url, outDir, { chrome = process.env.CHROME_PATH, record = false, stops = 12 } = {}) {
90
+ if (!chrome || !fs.existsSync(chrome)) {
91
+ return { ok: false, error: 'chrome not available: CHROME_PATH not set or not found' };
92
+ }
93
+ let browser;
94
+ try {
95
+ // ponytail: chromiumSandbox off by default — Docker's default seccomp profile blocks
96
+ // the user namespaces Chrome's own sandbox needs, so it would fail to launch inside
97
+ // the container; the non-root `node` user is the isolation there instead. Opt in with
98
+ // CHROME_SANDBOX=1 only where user namespaces are actually allowed (e.g. bare metal).
99
+ browser = await chromium.launch({ executablePath: chrome, headless: true, timeout: 60000, chromiumSandbox: process.env.CHROME_SANDBOX === '1' });
100
+ } catch (err) {
101
+ return { ok: false, error: `chrome not available: ${err.message}` };
102
+ }
103
+ const screenshots = {};
104
+ let frames;
105
+ try {
106
+ const page = await browser.newPage();
107
+ await page.setViewportSize(VIEWPORT_WIDE);
108
+ try {
109
+ await page.goto(url, { waitUntil: 'load', timeout: 45000 });
110
+ } catch (err) {
111
+ return { ok: false, error: `page failed to load: ${err.message}` };
112
+ }
113
+
114
+ fs.mkdirSync(outDir, { recursive: true });
115
+ const shot1440 = path.join(outDir, 'screen-1440.png');
116
+ await page.screenshot({ path: shot1440, fullPage: false });
117
+ screenshots[1440] = shot1440;
118
+
119
+ if (record) {
120
+ frames = [];
121
+ const total = await page.evaluate(() => document.documentElement.scrollHeight);
122
+ const vh = VIEWPORT_WIDE.height;
123
+ for (let i = 0; i < stops; i++) {
124
+ const y = stops > 1 ? Math.round((i * Math.max(total - vh, 0)) / (stops - 1)) : 0;
125
+ await page.evaluate((y) => window.scrollTo(0, y), y);
126
+ await page.waitForTimeout(150);
127
+ const fp = path.join(outDir, `frame-${String(i).padStart(2, '0')}.png`);
128
+ await page.screenshot({ path: fp, fullPage: false });
129
+ frames.push(fp);
130
+ }
131
+ await page.evaluate(() => window.scrollTo(0, 0));
132
+ }
133
+
134
+ let data;
135
+ try {
136
+ data = await page.evaluate(collect);
137
+ } catch (err) {
138
+ return { ok: false, error: `measurement failed: ${err.message}`, frames, screenshots };
139
+ }
140
+
141
+ await page.setViewportSize(VIEWPORT_NARROW);
142
+ const shot390 = path.join(outDir, 'screen-390.png');
143
+ await page.screenshot({ path: shot390, fullPage: false });
144
+ screenshots[390] = shot390;
145
+
146
+ const measureJson = {
147
+ url, at: new Date().toISOString().slice(0, 10), viewport: [1440, 390],
148
+ tokens: data.tokens, sections: data.sections,
149
+ screenshots: { 1440: path.basename(shot1440), 390: path.basename(shot390) },
150
+ };
151
+ fs.writeFileSync(path.join(outDir, 'measure.json'), JSON.stringify(measureJson, null, 2));
152
+ return { ok: true, measure: measureJson, screenshots, frames };
153
+ } catch (err) {
154
+ return { ok: false, error: `measurement failed: ${err.message}`, frames, screenshots };
155
+ } finally {
156
+ await browser.close();
157
+ }
158
+ }
@@ -0,0 +1,11 @@
1
+ import { parseEntry, validateEntry } from '../entry.mjs';
2
+ import { buildIndex } from '../index/build.mjs';
3
+
4
+ export async function saveEntry({ dir, vocab, indexPath, entriesDir, models }) {
5
+ const e = parseEntry(dir);
6
+ const r = validateEntry(e, vocab);
7
+ if (!r.ok) return { ok: false, slug: e.slug, errors: r.errors, refused: r.errors.join('; ') };
8
+ // Phase 1: a full rebuild is cheap; single-entry upsert arrives with the vector arms.
9
+ await buildIndex({ entriesDir, indexPath, vocab, models });
10
+ return { ok: true, slug: e.slug, errors: [] };
11
+ }
@@ -0,0 +1,76 @@
1
+ import dns from 'node:dns/promises';
2
+ import net from 'node:net';
3
+
4
+ // SSRF guard for `add <url>` and `refresh` over the hosted HTTP transport: a remote
5
+ // caller names a URL, this process's Chrome fetches it, so the URL must not be able
6
+ // to reach the container's own loopback/private network. Stdio has no such caller
7
+ // (the operator already has that network access), so it only gets the scheme check.
8
+ const V4_PRIVATE = [
9
+ ['127.0.0.0', 8], ['10.0.0.0', 8], ['172.16.0.0', 12], ['192.168.0.0', 16],
10
+ ['169.254.0.0', 16], ['0.0.0.0', 8], ['100.64.0.0', 10],
11
+ ];
12
+
13
+ function ipv4ToInt(ip) {
14
+ return ip.split('.').reduce((acc, o) => (acc << 8) + Number(o), 0) >>> 0;
15
+ }
16
+
17
+ function isPrivateV4(ip) {
18
+ const n = ipv4ToInt(ip);
19
+ return V4_PRIVATE.some(([base, bits]) => {
20
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
21
+ return (n & mask) === (ipv4ToInt(base) & mask);
22
+ });
23
+ }
24
+
25
+ // ::ffff:a.b.c.d (dotted) or ::ffff:xxxx:xxxx (hex) — dns.lookup can hand back either form.
26
+ function ipv4MappedToV4(address) {
27
+ const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
28
+ if (dotted) return dotted[1];
29
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address);
30
+ if (!hex) return null;
31
+ const hi = parseInt(hex[1], 16), lo = parseInt(hex[2], 16);
32
+ return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join('.');
33
+ }
34
+
35
+ function isPrivateAddress(address, family) {
36
+ if (family === 4) return isPrivateV4(address);
37
+ const a = address.toLowerCase();
38
+ if (a === '::1') return true;
39
+ if (a.startsWith('fc') || a.startsWith('fd')) return true; // fc00::/7
40
+ if (/^fe[89ab]/.test(a)) return true; // fe80::/10
41
+ const mapped = ipv4MappedToV4(a);
42
+ return mapped ? isPrivateV4(mapped) : false;
43
+ }
44
+
45
+ // Returns null when the URL is allowed, or a refusal string naming why.
46
+ export async function refuseUnsafeUrl(url, { transport } = {}) {
47
+ let u;
48
+ try { u = new URL(url); } catch { return `invalid URL: ${url}`; }
49
+ const scheme = u.protocol;
50
+ const overHttp = transport === 'http';
51
+
52
+ const schemeOk = scheme === 'https:' || scheme === 'http:' || (scheme === 'file:' && !overHttp);
53
+ if (!schemeOk) return overHttp ? 'only https:// URLs are accepted over http' : `unsupported URL scheme: ${scheme}`;
54
+ if (!overHttp) return null; // stdio/CLI: no caller to guard against, DNS never consulted
55
+
56
+ if (scheme !== 'https:') return 'only https:// URLs are accepted over http';
57
+
58
+ // WHATWG URL keeps the brackets on an IPv6 literal host ("[::1]"); net.isIP and
59
+ // dns.lookup both want the bare address.
60
+ const hostname = u.hostname.replace(/^\[|\]$/g, '');
61
+ const literalFamily = net.isIP(hostname);
62
+ let addresses;
63
+ if (literalFamily) {
64
+ addresses = [{ address: hostname, family: literalFamily }];
65
+ } else {
66
+ try {
67
+ addresses = await dns.lookup(hostname, { all: true });
68
+ } catch {
69
+ return `cannot resolve ${hostname}`;
70
+ }
71
+ }
72
+ if (addresses.some((a) => isPrivateAddress(a.address, a.family))) {
73
+ return `${hostname} resolves to a private address`;
74
+ }
75
+ return null;
76
+ }
@@ -0,0 +1,72 @@
1
+ import http from 'node:http';
2
+ import path from 'node:path';
3
+ import { timingSafeEqual } from 'node:crypto';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { buildServer } from './server.mjs';
6
+ import { paths } from '../paths.mjs';
7
+ import { openIndex } from '../index/query.mjs';
8
+ import { getEmbedder } from '../index/embed.mjs';
9
+ import { serveFile } from '../ui/serve.mjs';
10
+
11
+ function authorized(header, token) {
12
+ const want = Buffer.from(`Bearer ${token}`), got = Buffer.from(header ?? '');
13
+ return want.length === got.length && timingSafeEqual(want, got);
14
+ }
15
+
16
+ async function handle(req, res, p, token, embedder) {
17
+ const url = new URL(req.url, 'http://x');
18
+ if (url.pathname === '/healthz') {
19
+ let entries = 0, arms = ['fts'];
20
+ try {
21
+ const db = openIndex(p.index);
22
+ entries = db.prepare('SELECT count(*) c FROM entries').get().c;
23
+ if (db.vec && db.prepare('SELECT count(*) c FROM vec_text').get().c > 0) arms = ['fts', 'vec'];
24
+ db.close();
25
+ } catch {}
26
+ res.writeHead(200, { 'content-type': 'application/json' });
27
+ return res.end(JSON.stringify({ ok: true, entries, arms, version: p.version }));
28
+ }
29
+ if (url.pathname === '/mcp') {
30
+ if (!authorized(req.headers.authorization, token)) { res.writeHead(401); return res.end('unauthorized'); }
31
+ // ponytail: SDK 1.30's stateless transport (sessionIdGenerator: undefined)
32
+ // latches `_initialized` after the first `initialize` and 400s any later
33
+ // one on that same instance — fine for one client, wrong for a server
34
+ // meant to answer many. A fresh transport + server per request keeps
35
+ // every request statelessly independent; buildServer() stays the one
36
+ // tool table, just called once per request instead of once at startup.
37
+ // The embedder is cached once at serveHttp start and threaded through
38
+ // instead: the real arm loads models from disk, too slow to redo per request.
39
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
40
+ const server = await buildServer({ transport: 'http', embedder });
41
+ await server.connect(transport);
42
+ res.on('close', () => { transport.close(); server.close(); });
43
+ return transport.handleRequest(req, res);
44
+ }
45
+ // req.url, not url.pathname: WHATWG URL parsing already folds `%2e%2e`/`..` before
46
+ // serveFile's own prefix check would ever see them (see src/ui/serve.mjs).
47
+ return serveFile(path.join(p.data, 'ui'), req.url, res);
48
+ }
49
+
50
+ export async function serveHttp() {
51
+ const token = process.env.LIBRARY_TOKEN;
52
+ if (!token) { process.stderr.write('refusing to start HTTP transport: LIBRARY_TOKEN is not set\n'); return 2; }
53
+ const port = Number(process.env.PORT || 4180);
54
+ const p = paths();
55
+ const embedder = await getEmbedder({ models: p.models }); // once per process; handle() reuses it per request
56
+
57
+ // ponytail: a bare `async (req, res) =>` callback here would turn any
58
+ // thrown error (bad body, SDK error) into an unhandled rejection, which
59
+ // crashes the whole process under Node's default --unhandled-rejections.
60
+ // Catching per-request keeps one bad request from taking the server down.
61
+ const srv = http.createServer((req, res) => {
62
+ handle(req, res, p, token, embedder).catch((err) => {
63
+ process.stderr.write(`wp-design-library: request error: ${err.stack || err}\n`);
64
+ if (!res.headersSent) res.writeHead(500);
65
+ res.end();
66
+ });
67
+ });
68
+
69
+ await new Promise((resolve, reject) => { srv.once('error', reject); srv.listen(port, resolve); });
70
+ process.stderr.write(`wp-design-library: http on :${port}\n`);
71
+ return new Promise(() => {});
72
+ }