@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,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,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
|
+
}
|
package/src/mcp/http.mjs
ADDED
|
@@ -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
|
+
}
|