@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,147 @@
1
+ import { z } from 'zod';
2
+
3
+ // The add-entry prompt is the authoring command. Claude Code surfaces it as a slash
4
+ // command (/mcp__wp-design-library__add-entry) in any session with this server
5
+ // registered, so the plugin that consumes the library never has to ship it.
6
+ // The vocabulary is rendered from ctx.vocab at call time: no copy can drift.
7
+
8
+ function list(set) { return [...set].join(', '); }
9
+
10
+ export function addEntryPrompt(ctx, a) {
11
+ const v = ctx.vocab.facets;
12
+ const aliases = [...ctx.vocab.aliases.entries()].map(([alias, canon]) => `\`${alias}\` → \`${canon}\``).join(', ');
13
+ const given = Object.entries(a).filter(([, val]) => val).map(([k, val]) => `- ${k}: ${val}`).join('\n');
14
+ return `# Add a design reference to wp-design-library
15
+
16
+ Arguments given:
17
+ ${given || '- (none)'}
18
+
19
+ The server does the mechanical half: it samples frames into \`strip.png\` and writes a
20
+ draft with the judgement fields blank. **The session is the tagger.** Nothing in the
21
+ server guesses what a design feels like, and a draft is never saved with a guess the
22
+ session did not make.
23
+
24
+ ## Order of operations
25
+
26
+ 1. **Arguments.** \`path\` must exist; pass it as an absolute path. Animated webp and gif
27
+ always work; video (mp4/mov/webm/mkv) needs ffmpeg on the server's PATH. If \`add\`
28
+ returns \`refused\` starting with \`ffmpeg\`, relay the message and stop. \`source\` is
29
+ one of ${list(v.source)}. Derive \`slug\` from the file name when absent (lowercase,
30
+ dashes, no extension) and confirm it with the user; derive \`title\` from the slug and
31
+ confirm. \`url\` must be a full URL with scheme. \`license\` defaults by kind: own →
32
+ "own work"; open → ask for the licence name; paid and public-site → "description
33
+ only, no redistribution of the source".
34
+ 2. **Ingest.** Call the \`add\` tool with those values. If it returns \`refused\` because
35
+ the slug exists, ask before calling again with \`force: true\`; a forced re-add resets
36
+ the draft. Over a hosted (HTTP) server \`add\` can only read files already in the server's
37
+ inbox; if the refusal mentions \`inbox\`, say so and stop.
38
+ 3. **Read.** Open the returned \`strip\`. Read \`media.duration_ms\` from the draft at
39
+ \`<dir>/entry.md\`; if it is above 10000, call \`add\` again with \`force: true\` and
40
+ \`frames: 36\` before tagging. The server caps at 36 frames; beyond about 18 seconds a
41
+ section can still be missed, so build a denser sheet yourself from the source file
42
+ before writing the motion note.
43
+ 4. **Tag.** Fill every field in the returned \`blanks\` list in \`<dir>/entry.md\`, per the
44
+ rubric below. Write the motion note last, after the roster.
45
+ 5. **Approve.** Show the draft to the user and **show the draft and wait for a yes** before
46
+ anything else. Apply their edits.
47
+ 6. **Save.** Call \`save_entry\`. On \`refused\`, show the field names, fix, and call again;
48
+ **never remove a field to make it pass**.
49
+ 7. **Offer the git step.** The library checkout is the directory that contains the
50
+ \`bin/library.mjs\` this server runs; if that is not a git checkout (the \`npx\` case), ask
51
+ the user for the path to their clone. If the \`dir\` returned by \`add\` is not inside the
52
+ checkout, first \`cp -r <dir> <checkout>/entries/\`. Then, only with \`git -C <checkout>\`
53
+ and never in the current project: \`checkout -B content/<slug> main\`, \`add
54
+ entries/<slug>\`, commit \`content: <title>\`, \`push -u origin content/<slug>\`, and
55
+ \`env -u GH_TOKEN gh pr create --repo yojahny55/wp-design-library --head content/<slug>
56
+ --title "content: <title>" --body "<one line: source kind and licence>"\`. No attribution
57
+ lines in the commit. The hosted instance picks the entry up on the redeploy after merge.
58
+
59
+ ## Tagging rubric
60
+
61
+ Read the whole page before tagging any section: roster first, then feel, then motion.
62
+
63
+ | Field | Write |
64
+ |---|---|
65
+ | \`roles\` | every page section, in order, from: ${list(v.role)} |
66
+ | \`feel\` | three to six of: ${list(v.feel)} |
67
+ | \`palette.canvas/ink/accent\` | three hex values eyeballed from the strip |
68
+ | \`type.display\`, \`type.body\` | family feel, weight, tracking, in words; never a font name you did not see |
69
+ | \`motion.devices\` | only what was seen, from: ${list(v.motion)} |
70
+ | \`motion.notes\` | one paragraph, per section in page order: what moves, relative to what, how it hands off. Begin with "Eyeballed from N sampled frames, not measured." Name what you could not tell. |
71
+ | body | three headed sections: \`## What it does\`, \`## Section roster\`, \`## Why it works\`; the last ends with the cost to weigh |
72
+
73
+ Aliases resolve on save: ${aliases}. Any other term is refused with the facet and term
74
+ named. Do not invent a term; if the pattern is real and unnamed, describe it in
75
+ \`motion.notes\` and propose the term to the operator.
76
+ A term is **added by a PR to \`vocab.yaml\`, never by an ingest**.
77
+
78
+ Licence kinds: \`own\` and \`open\` may later gain a \`ported/\` folder of real code; \`paid\`
79
+ and \`public-site\` are inspiration only and \`save_entry\` refuses \`tier: ported\` on them.
80
+
81
+ ## Never
82
+
83
+ - Never fill a field with a value you did not read off the strip.
84
+ - Never call \`save_entry\` before the user has seen the draft.
85
+ - Never run git in the current project.`;
86
+ }
87
+
88
+ // brief-from-library turns a client brief into a search plan: one `search` call per
89
+ // role the page needs, feel filters from the brief's own vibe words, then a
90
+ // `## References` block the session fills in with the slugs it actually used.
91
+
92
+ export function briefFromLibraryPrompt(ctx, { brief }) {
93
+ const v = ctx.vocab.facets;
94
+ const roles = list(v.role);
95
+ const feels = list(v.feel);
96
+ return `# Build a brief from the design library
97
+
98
+ Brief given:
99
+
100
+ ${brief}
101
+
102
+ ## What to do
103
+
104
+ For each of the ${v.role.size} roles below, call the \`search\` tool once with \`filters.role\`
105
+ set to that role, \`limit: 3\`, and \`filters.feel\` set to whichever feel terms from
106
+ the vocabulary (${feels}) match the brief's own vibe words. Roles: ${roles}.
107
+
108
+ Read the results, pick the entries that best fit the brief for each role, and use
109
+ \`get_entry\` on any slug whose body you need to see in full before deciding.
110
+
111
+ A hit whose \`ranks.fts\` is null is a weak, vector-only match: it matched by semantic
112
+ distance alone, not by any keyword in the query. If a role's results are all weak,
113
+ report that role as having no reference rather than filling it with a weak match.
114
+
115
+ ## Output
116
+
117
+ End your reply with a \`## References\` block: one line per slug you actually used,
118
+ each line starting with the slug (e.g. \`ais-community-dark-depth: hero, dark bed
119
+ with slower parallax\`).`;
120
+ }
121
+
122
+ export function registerPrompts(server, ctx) {
123
+ server.registerPrompt(
124
+ 'brief-from-library',
125
+ {
126
+ description: "Turn a client brief into a search plan against this library: one search per role, feel filters from the brief's vibe words, and a References block of the slugs used.",
127
+ argsSchema: { brief: z.string().describe('the client brief or page description to search the library against') },
128
+ },
129
+ async (a) => ({ messages: [{ role: 'user', content: { type: 'text', text: briefFromLibraryPrompt(ctx, a) } }] }),
130
+ );
131
+
132
+ server.registerPrompt(
133
+ 'add-entry',
134
+ {
135
+ description: 'Add a design reference to the library: ingest a recording, tag it in this session per the rubric, approve the draft, save it, offer the PR.',
136
+ argsSchema: {
137
+ path: z.string().describe('absolute path to an animated webp/gif or a video file'),
138
+ source: z.string().describe('own | open | paid | public-site'),
139
+ slug: z.string().optional().describe('lowercase, digits and dashes'),
140
+ title: z.string().optional(),
141
+ url: z.string().optional().describe('where it came from, full URL'),
142
+ license: z.string().optional(),
143
+ },
144
+ },
145
+ async (a) => ({ messages: [{ role: 'user', content: { type: 'text', text: addEntryPrompt(ctx, a) } }] }),
146
+ );
147
+ }
@@ -0,0 +1,24 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+
5
+ export function registerResources(server, ctx) {
6
+ server.registerResource(
7
+ 'vocab',
8
+ 'library://vocab',
9
+ { title: 'Vocabulary', description: 'The facet vocabulary (roles, feel, motion, source, tier) as declared in vocab.yaml.', mimeType: 'text/yaml' },
10
+ async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'text/yaml', text: fs.readFileSync(ctx.paths.vocab, 'utf8') }] }),
11
+ );
12
+
13
+ server.registerResource(
14
+ 'entry',
15
+ new ResourceTemplate('library://entry/{slug}', { list: undefined }),
16
+ { title: 'Entry', description: "One entry's entry.md, frontmatter and prose body.", mimeType: 'text/markdown' },
17
+ async (uri, { slug }) => {
18
+ if (!/^[a-z0-9-]+$/.test(slug)) throw new Error('no entry ' + slug);
19
+ const file = path.join(ctx.paths.entries, slug, 'entry.md');
20
+ if (!fs.existsSync(file)) throw new Error('no entry ' + slug);
21
+ return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: fs.readFileSync(file, 'utf8') }] };
22
+ },
23
+ );
24
+ }
@@ -0,0 +1,28 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { paths } from '../paths.mjs';
4
+ import { loadVocab } from '../vocab.mjs';
5
+ import { getEmbedder } from '../index/embed.mjs';
6
+ import { registerPrompts } from './prompts.mjs';
7
+ import { registerResources } from './resources.mjs';
8
+ import { makeTools, registerTools } from './tools.mjs';
9
+
10
+ export async function buildServer(opts = {}) {
11
+ const transport = opts.transport ?? 'stdio';
12
+ const p = paths();
13
+ const ctx = { paths: p, vocab: loadVocab(p.vocab), transport, models: p.models };
14
+ // http passes its own cached embedder in (one per process, not one per request); stdio resolves its own.
15
+ ctx.embedder = 'embedder' in opts ? opts.embedder : await getEmbedder({ models: ctx.models });
16
+ const server = new McpServer({ name: 'wp-design-library', version: p.version });
17
+ registerTools(server, makeTools(ctx));
18
+ registerPrompts(server, ctx);
19
+ registerResources(server, ctx);
20
+ return server;
21
+ }
22
+
23
+ export async function serveStdio() {
24
+ const server = await buildServer();
25
+ const log = (...a) => process.stderr.write(a.join(' ') + '\n');
26
+ await server.connect(new StdioServerTransport());
27
+ log('wp-design-library: stdio ready');
28
+ }
@@ -0,0 +1,152 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { openIndex, search, getEntry, similar } from '../index/query.mjs';
5
+ import { resolveTerm } from '../vocab.mjs';
6
+ import { refuseUnsafeUrl } from '../ingest/url-guard.mjs';
7
+
8
+ const facetList = z.array(z.string()).optional();
9
+ const FILTER_FACETS = ['role', 'feel', 'motion', 'source'];
10
+
11
+ // Aliases resolve on query (spec §5): map each filter term to its canonical
12
+ // vocab term. A term that resolves to nothing is dropped from the filter and
13
+ // reported back so the caller knows why a filter had no effect.
14
+ function resolveFilters(vocab, filters = {}) {
15
+ const resolved = {};
16
+ const unknown = [];
17
+ for (const facet of FILTER_FACETS) {
18
+ const terms = filters[facet];
19
+ if (!terms) continue;
20
+ const ok = [];
21
+ for (const term of terms) {
22
+ const canon = resolveTerm(vocab, facet, term);
23
+ if (canon === null) unknown.push({ facet, term });
24
+ else ok.push(canon);
25
+ }
26
+ if (ok.length) resolved[facet] = ok;
27
+ }
28
+ if (filters.tier !== undefined) {
29
+ const canon = resolveTerm(vocab, 'tier', filters.tier);
30
+ if (canon === null) unknown.push({ facet: 'tier', term: filters.tier });
31
+ else resolved.tier = canon;
32
+ }
33
+ return { filters: resolved, unknown };
34
+ }
35
+
36
+ // Over http, a path argument names a file the server reads on the caller's
37
+ // behalf; an untrusted remote caller could otherwise name any file readable
38
+ // by the process. Confine it to the drop folder. Stdio has no such caller
39
+ // (the operator running the CLI already has that file access), so it stays
40
+ // unrestricted there. Shared by `add` (input) and `similar` (image).
41
+ function confineToInbox(ctx, inputPath) {
42
+ if (ctx.transport !== 'http') return null;
43
+ // Realpath the inbox root itself (when it exists) so a symlinked <data>/inbox still
44
+ // confines correctly: inputPath's own realpath resolves through that symlink too, and
45
+ // comparing against the unresolved join would then never match.
46
+ let inboxRoot = path.join(ctx.paths.data, 'inbox');
47
+ if (fs.existsSync(inboxRoot)) inboxRoot = fs.realpathSync(inboxRoot);
48
+ const inbox = inboxRoot + path.sep;
49
+ // existsSync is false for a dangling symlink too, not just a missing path: refuse
50
+ // both explicitly instead of falling through to path.resolve, which would let a
51
+ // dangling symlink under inbox pass confinement on its unresolved path alone.
52
+ if (!fs.existsSync(inputPath)) return 'input does not exist';
53
+ const real = fs.realpathSync(inputPath);
54
+ return real.startsWith(inbox) ? null : 'over http, the path must be a file under <data>/inbox';
55
+ }
56
+
57
+ export function makeTools(ctx) {
58
+ const withDb = (fn) => async (args) => {
59
+ const db = openIndex(ctx.paths.index);
60
+ try { return await fn(db, args); } finally { db.close(); }
61
+ };
62
+ return [
63
+ {
64
+ name: 'search',
65
+ description: 'Search design references by keyword and facet filters, fused by reciprocal rank fusion when the vector arm is available. Returns slugs ranked by score; arms lists which retrieval arms ran. A hit whose ranks.fts is null is a weak, vector-only match: it matched by semantic distance alone, not by any keyword in the query.',
66
+ schema: z.object({
67
+ query: z.string().default(''),
68
+ filters: z.object({ role: facetList, feel: facetList, motion: facetList, source: facetList, tier: z.string().optional() }).optional(),
69
+ limit: z.number().int().min(1).max(50).default(10),
70
+ }),
71
+ handler: withDb(async (db, a) => {
72
+ const { filters, unknown } = resolveFilters(ctx.vocab, a.filters ?? {});
73
+ const result = await search(db, { ...a, filters, embedder: ctx.embedder });
74
+ return unknown.length ? { ...result, unknown } : result;
75
+ }),
76
+ },
77
+ {
78
+ name: 'get_entry',
79
+ description: 'Return one entry: frontmatter, prose body, and the path to its strip.png.',
80
+ schema: z.object({ slug: z.string() }),
81
+ handler: withDb((db, { slug }) => {
82
+ const e = getEntry(db, slug);
83
+ return e ? { ...e, strip: `${e.dir}/strip.png` } : { refused: `no entry with slug ${slug}` };
84
+ }),
85
+ },
86
+ {
87
+ name: 'similar',
88
+ description: "Find entries visually similar to a slug's strip.png, or to an image file, by vector distance on vec_image. Exactly one of slug/image is required.",
89
+ schema: z.object({
90
+ slug: z.string().optional(),
91
+ image: z.string().optional(),
92
+ limit: z.number().int().min(1).max(50).default(10),
93
+ }),
94
+ handler: withDb(async (db, a) => {
95
+ if (!!a.slug === !!a.image) return { refused: 'similar needs exactly one of slug or image' };
96
+ if (a.image) {
97
+ const refusal = confineToInbox(ctx, a.image);
98
+ if (refusal) return { refused: refusal };
99
+ }
100
+ return similar(db, { ...a, embedder: ctx.embedder });
101
+ }),
102
+ },
103
+ {
104
+ name: 'add',
105
+ description: 'Ingest a video/animated image, or measure a live https:// URL with the system Chrome: sample frames or measure into strip.png/measure.json and write a draft entry. Returns the blank fields the caller must fill before save_entry.',
106
+ schema: z.object({
107
+ input: z.string(), slug: z.string().regex(/^[a-z0-9-]+$/), title: z.string().min(1),
108
+ source: z.object({ kind: z.enum(['own', 'open', 'paid', 'public-site']), url: z.string().url().optional(), license: z.string().min(1) }),
109
+ force: z.boolean().default(false),
110
+ frames: z.number().int().min(6).max(36).optional(),
111
+ record: z.boolean().default(false),
112
+ }),
113
+ handler: async (a) => {
114
+ // A URL is fetched over the network by Chrome, not read off disk on the
115
+ // caller's behalf, so the inbox confinement (local file paths only) doesn't
116
+ // apply to it; a bare local path still does.
117
+ if (/^https?:\/\//.test(a.input)) {
118
+ const refusal = await refuseUnsafeUrl(a.input, { transport: ctx.transport });
119
+ if (refusal) return { refused: refusal };
120
+ } else {
121
+ const refusal = confineToInbox(ctx, a.input);
122
+ if (refusal) return { refused: refusal };
123
+ }
124
+ return (await import('../cli/add.mjs')).addEntry(a);
125
+ },
126
+ },
127
+ {
128
+ name: 'refresh',
129
+ description: 'Re-measure a drafted URL entry (source.url set, captured.method measured|both) with the system Chrome: rewrites measure.json, bumps captured.at, and reindexes. Refuses an entry with no URL or one drafted from frames only.',
130
+ schema: z.object({ slug: z.string().regex(/^[a-z0-9-]+$/) }),
131
+ handler: async ({ slug }) => (await import('../cli/refresh.mjs')).refreshEntry({ slug, transport: ctx.transport }),
132
+ },
133
+ {
134
+ name: 'save_entry',
135
+ description: 'Validate a filled draft against the vocabulary and licence gate, then reindex. Refuses with the field names on any blank or unknown term.',
136
+ schema: z.object({ slug: z.string().regex(/^[a-z0-9-]+$/) }),
137
+ handler: async ({ slug }) => {
138
+ const { saveEntry } = await import('../ingest/save.mjs');
139
+ return saveEntry({ dir: path.join(ctx.paths.entries, slug), vocab: ctx.vocab, indexPath: ctx.paths.index, entriesDir: ctx.paths.entries, models: ctx.paths.models });
140
+ },
141
+ },
142
+ ];
143
+ }
144
+
145
+ export function registerTools(server, tools) {
146
+ for (const t of tools) {
147
+ server.registerTool(t.name, { description: t.description, inputSchema: t.schema.shape }, async (args) => {
148
+ const out = await t.handler(args);
149
+ return { content: [{ type: 'text', text: JSON.stringify(out) }], structuredContent: out };
150
+ });
151
+ }
152
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,21 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
+ const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
7
+
8
+ export function paths(env = process.env) {
9
+ const data = env.LIBRARY_DATA ? path.resolve(env.LIBRARY_DATA) : ROOT;
10
+ return {
11
+ root: ROOT,
12
+ data,
13
+ entries: path.join(data, 'entries'),
14
+ // Entries shipped with the code (the image). Synced into `entries` on every start.
15
+ seed: env.LIBRARY_SEED ? path.resolve(env.LIBRARY_SEED) : path.join(ROOT, 'entries'),
16
+ index: path.join(data, '.library', 'index.sqlite'),
17
+ vocab: path.join(ROOT, 'vocab.yaml'),
18
+ models: env.LIBRARY_MODELS ? path.resolve(env.LIBRARY_MODELS) : path.join(data, 'models'),
19
+ version: VERSION,
20
+ };
21
+ }
package/src/ui/app.js ADDED
@@ -0,0 +1,34 @@
1
+ // Vanilla, no build step: filters the manifest already rendered into `.card`
2
+ // data attributes. Within one facet, checked boxes OR together; across facets, AND.
3
+ (function () {
4
+ var grid = document.getElementById('grid');
5
+ var cards = grid ? Array.prototype.slice.call(grid.querySelectorAll('.card')) : [];
6
+ var facets = ['role', 'feel', 'motion', 'source', 'tier'];
7
+ var draftsToggle = document.getElementById('drafts-toggle');
8
+
9
+ function selected(facet) {
10
+ var boxes = document.querySelectorAll('input[name="' + facet + '"]:checked');
11
+ return Array.prototype.map.call(boxes, function (b) { return b.value; });
12
+ }
13
+
14
+ function apply() {
15
+ var active = {};
16
+ for (var i = 0; i < facets.length; i++) active[facets[i]] = selected(facets[i]);
17
+ var showDrafts = !!(draftsToggle && draftsToggle.checked);
18
+ cards.forEach(function (card) {
19
+ var isDraft = card.dataset.draft === 'true';
20
+ var ok = showDrafts || !isDraft;
21
+ for (var i = 0; ok && i < facets.length; i++) {
22
+ var want = active[facets[i]];
23
+ if (!want.length) continue;
24
+ var have = (card.dataset[facets[i]] || '').split(' ');
25
+ ok = want.some(function (t) { return have.indexOf(t) !== -1; });
26
+ }
27
+ card.hidden = !ok;
28
+ });
29
+ }
30
+
31
+ var inputs = document.querySelectorAll('#filters input[type="checkbox"]');
32
+ Array.prototype.forEach.call(inputs, function (input) { input.addEventListener('change', apply); });
33
+ apply();
34
+ })();
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { listEntries, parseEntry, validateEntry } from '../entry.mjs';
5
+ import { loadVocab, FACETS } from '../vocab.mjs';
6
+
7
+ const DIR = path.dirname(fileURLToPath(import.meta.url));
8
+ const TPL = path.join(DIR, 'templates');
9
+
10
+ function esc(s) {
11
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
12
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
13
+ }
14
+
15
+ function render(tpl, vars) {
16
+ return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
17
+ }
18
+
19
+ // The 20-line converter: headings, paragraphs, fenced code. Text is always escaped.
20
+ function mdToHtml(md) {
21
+ const out = [];
22
+ let para = [], code = null;
23
+ const flush = () => { if (para.length) { out.push(`<p>${esc(para.join(' '))}</p>`); para = []; } };
24
+ for (const line of md.split('\n')) {
25
+ if (line.trim().startsWith('```')) {
26
+ if (code) { out.push(`<pre><code>${code.join('\n')}</code></pre>`); code = null; } else { flush(); code = []; }
27
+ continue;
28
+ }
29
+ if (code) { code.push(esc(line)); continue; }
30
+ const h = /^(#{1,6})\s+(.*)$/.exec(line);
31
+ if (h) { flush(); out.push(`<h${h[1].length}>${esc(h[2])}</h${h[1].length}>`); continue; }
32
+ if (!line.trim()) { flush(); continue; }
33
+ para.push(line.trim());
34
+ }
35
+ flush();
36
+ return out.join('\n');
37
+ }
38
+
39
+ function filterFieldset(facet, terms) {
40
+ const legend = facet.charAt(0).toUpperCase() + facet.slice(1);
41
+ const boxes = [...terms].sort().map((t) => `<label><input type="checkbox" name="${facet}" value="${esc(t)}"> ${esc(t)}</label>`).join(' ');
42
+ return `<fieldset><legend>${legend}</legend>${boxes}</fieldset>`;
43
+ }
44
+
45
+ function card(row) {
46
+ const img = row.strip ? `<img src="${row.strip}" alt="">` : `<div class="ph"></div>`;
47
+ const badge = row.draft ? ' <span class="badge">draft</span>' : '';
48
+ const tags = esc([...row.roles, ...row.feel, ...row.motion].join(', '));
49
+ const data = `data-role="${esc(row.roles.join(' '))}" data-feel="${esc(row.feel.join(' '))}" data-motion="${esc(row.motion.join(' '))}"`;
50
+ return `<a class="card" href="entry/${esc(row.slug)}.html" ${data} data-source="${esc(row.source)}" data-tier="${esc(row.tier)}" data-draft="${row.draft}">` +
51
+ `${img}<div class="meta"><strong>${esc(row.title)}</strong>${badge}<br><small>${esc(row.tier)} &middot; ${tags}</small></div></a>`;
52
+ }
53
+
54
+ // measure.json is only written for a live-URL capture (`add <url>`/`refresh`); frame-sampled entries have none.
55
+ function measurementsHtml(dir) {
56
+ const file = path.join(dir, 'measure.json');
57
+ if (!fs.existsSync(file)) return '';
58
+ const m = JSON.parse(fs.readFileSync(file, 'utf8'));
59
+ const t = m.tokens ?? {};
60
+ return `<h2>Measured tokens</h2><dl><dt>Canvas</dt><dd>${esc(t.canvas)}</dd><dt>Ink</dt><dd>${esc(t.ink)}</dd><dt>Accent</dt><dd>${esc(t.accent)}</dd></dl>` +
61
+ `<p>${(m.sections ?? []).length} sections measured.</p>`;
62
+ }
63
+
64
+ function writeEntryPage(outDir, entry, row) {
65
+ const tpl = fs.readFileSync(path.join(TPL, 'entry.html'), 'utf8');
66
+ const html = render(tpl, {
67
+ TITLE: esc(row.title), TIER: esc(row.tier), ROLES: esc(row.roles.join(', ')), FEEL: esc(row.feel.join(', ')),
68
+ MOTION: esc(row.motion.join(', ')), SOURCE: esc(row.source), LICENSE: esc(entry.fm.source?.license),
69
+ CAPTURED: esc(row.captured), STRIP_IMG: row.strip ? `<img src="../${row.strip}" alt="">` : '',
70
+ DRAFT_BADGE: row.draft ? '<span class="badge">draft</span>' : '', BODY: mdToHtml(entry.body),
71
+ MEASUREMENTS: measurementsHtml(entry.dir),
72
+ });
73
+ fs.writeFileSync(path.join(outDir, 'entry', `${row.slug}.html`), html);
74
+ }
75
+
76
+ // `indexPath` is accepted for interface symmetry with buildIndex but unused: a draft is an
77
+ // entry that fails validateEntry, and buildIndex never puts a failing entry in the sqlite
78
+ // index — so entries/ is the one place both valid and draft entries can be read from.
79
+ export async function buildUi({ indexPath, entriesDir, outDir }) {
80
+ void indexPath;
81
+ const vocab = loadVocab();
82
+ fs.rmSync(outDir, { recursive: true, force: true });
83
+ fs.mkdirSync(path.join(outDir, 'entry'), { recursive: true });
84
+ fs.mkdirSync(path.join(outDir, 'strips'), { recursive: true });
85
+
86
+ const rows = [];
87
+ for (const dir of listEntries(entriesDir)) {
88
+ let entry;
89
+ try { entry = parseEntry(dir); } catch (err) { process.stderr.write(`ui: skipping ${path.basename(dir)}: ${err.message}\n`); continue; }
90
+ const { ok } = validateEntry(entry, vocab);
91
+ // Filesystem paths and URLs are always derived from the directory name, never from
92
+ // frontmatter fm.slug: a draft (failing validateEntry) is not forced to have
93
+ // slug === dir name, so fm.slug can carry path-traversal or markup and must never
94
+ // reach a path or an unescaped attribute.
95
+ const slug = path.basename(dir);
96
+ const stripSrc = path.join(dir, 'strip.png');
97
+ const hasStrip = fs.existsSync(stripSrc);
98
+ if (hasStrip) fs.copyFileSync(stripSrc, path.join(outDir, 'strips', `${slug}.png`));
99
+ const fm = entry.fm;
100
+ const capturedAt = fm.captured?.at;
101
+ const captured = capturedAt instanceof Date ? capturedAt.toISOString().slice(0, 10) : String(capturedAt ?? '');
102
+ // fm.slug is never a path, so an untitled draft may still fall back to it as display
103
+ // text — esc() (card()/writeEntryPage()) is what makes that safe, not the dir-based slug.
104
+ const row = {
105
+ slug, title: fm.title || fm.slug || slug, tier: fm.tier || '', roles: fm.roles ?? [], feel: fm.feel ?? [],
106
+ motion: fm.motion?.devices ?? [], source: fm.source?.kind || '', captured,
107
+ strip: hasStrip ? `strips/${slug}.png` : null, draft: !ok,
108
+ };
109
+ rows.push(row);
110
+ writeEntryPage(outDir, entry, row);
111
+ }
112
+ rows.sort((a, b) => b.captured.localeCompare(a.captured));
113
+
114
+ fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(rows, null, 2));
115
+ const tpl = fs.readFileSync(path.join(TPL, 'index.html'), 'utf8');
116
+ const filters = FACETS.map((f) => filterFieldset(f, vocab.facets[f])).join('\n');
117
+ fs.writeFileSync(path.join(outDir, 'index.html'), render(tpl, { FILTERS: filters, GRID: rows.map(card).join('\n') }));
118
+ fs.copyFileSync(path.join(DIR, 'app.js'), path.join(outDir, 'app.js'));
119
+
120
+ const drafts = rows.filter((r) => r.draft).length;
121
+ return { entries: rows.length - drafts, drafts };
122
+ }
@@ -0,0 +1,38 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const MIME = {
6
+ '.html': 'text/html; charset=utf-8',
7
+ '.js': 'text/javascript; charset=utf-8',
8
+ '.json': 'application/json; charset=utf-8',
9
+ '.png': 'image/png',
10
+ };
11
+
12
+ // Serves one file under `root`. Takes the raw request-target (`req.url`, or the
13
+ // pathname component of it) rather than a WHATWG `URL#pathname` — that parser folds
14
+ // `%2e%2e` and `..` to the same normalized path *before* any code here runs, which
15
+ // would make a prefix check look load-bearing while actually never seeing a path
16
+ // that leaves `root`. Decoding and joining ourselves is what actually blocks it.
17
+ export function serveFile(root, rawPath, res) {
18
+ const pathname = rawPath.split('?')[0];
19
+ let rel;
20
+ try { rel = decodeURIComponent(pathname); } catch { res.writeHead(404); return res.end(); }
21
+ if (rel === '/') rel = '/index.html';
22
+ const file = path.join(root, rel);
23
+ if (file !== root && !file.startsWith(root + path.sep)) { res.writeHead(404); return res.end(); }
24
+ if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end(); }
25
+ res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' });
26
+ fs.createReadStream(file).pipe(res);
27
+ }
28
+
29
+ // A bare static server for `library ui --serve` — same content http.mjs serves at `/`,
30
+ // without `/mcp` or `/healthz`.
31
+ export async function serveStatic(root, port) {
32
+ const srv = http.createServer((req, res) => {
33
+ serveFile(root, req.url, res);
34
+ });
35
+ await new Promise((resolve, reject) => { srv.once('error', reject); srv.listen(port, resolve); });
36
+ process.stderr.write(`wp-design-library: ui on :${port}\n`);
37
+ return new Promise(() => {}); // stay alive until killed
38
+ }
@@ -0,0 +1,31 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>{{TITLE}}</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <style>
8
+ body { font: 14px/1.5 system-ui, sans-serif; max-width: 60rem; margin: 0 auto; padding: 1rem; }
9
+ img { max-width: 100%; border-radius: 8px; }
10
+ dl { display: grid; grid-template-columns: 8rem 1fr; gap: .25rem 1rem; }
11
+ dt { font-weight: 600; }
12
+ .badge { background: #d33; color: #fff; font-size: .7em; padding: .1em .4em; border-radius: 4px; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <p><a href="../index.html">&larr; back to the gallery</a></p>
17
+ <h1>{{TITLE}} {{DRAFT_BADGE}}</h1>
18
+ {{STRIP_IMG}}
19
+ <dl>
20
+ <dt>Tier</dt><dd>{{TIER}}</dd>
21
+ <dt>Roles</dt><dd>{{ROLES}}</dd>
22
+ <dt>Feel</dt><dd>{{FEEL}}</dd>
23
+ <dt>Motion</dt><dd>{{MOTION}}</dd>
24
+ <dt>Source</dt><dd>{{SOURCE}}</dd>
25
+ <dt>Licence</dt><dd>{{LICENSE}}</dd>
26
+ <dt>Captured</dt><dd>{{CAPTURED}}</dd>
27
+ </dl>
28
+ {{MEASUREMENTS}}
29
+ {{BODY}}
30
+ </body>
31
+ </html>
@@ -0,0 +1,32 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>wp-design-library</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <style>
8
+ :root { color-scheme: dark light; }
9
+ body { font: 14px/1.4 system-ui, sans-serif; margin: 0; padding: 1rem; }
10
+ fieldset { display: inline-block; margin: 0 .5rem .5rem 0; border-radius: 6px; }
11
+ #filters { margin-bottom: 1rem; }
12
+ #grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
13
+ .card { border: 1px solid #8883; border-radius: 8px; overflow: hidden; color: inherit; text-decoration: none; display: block; }
14
+ .card img { width: 100%; display: block; aspect-ratio: 4/3; object-fit: cover; background: #8882; }
15
+ .card .ph { width: 100%; aspect-ratio: 4/3; background: #8882; }
16
+ .card .meta { padding: .5rem; }
17
+ .badge { background: #d33; color: #fff; font-size: .7em; padding: .1em .4em; border-radius: 4px; }
18
+ [hidden] { display: none !important; }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <h1>wp-design-library</h1>
23
+ <form id="filters">
24
+ {{FILTERS}}
25
+ <label><input type="checkbox" id="drafts-toggle"> Show drafts</label>
26
+ </form>
27
+ <div id="grid">
28
+ {{GRID}}
29
+ </div>
30
+ <script src="app.js"></script>
31
+ </body>
32
+ </html>