@moonarc/mcp 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/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/moonarc.js +80 -0
- package/catalog.json +11809 -0
- package/package.json +48 -0
- package/src/audit.js +365 -0
- package/src/catalog.js +186 -0
- package/src/compose.js +171 -0
- package/src/install.js +29 -0
- package/src/license.js +47 -0
- package/src/measure.js +269 -0
- package/src/server.js +196 -0
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moonarc/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server and CI CLI for Moonarc: search the catalogue by intent and byte budget, read a component's craft notes and source, get the install command; with a Pro key, audit motion in your codebase, measure per-route JS of your build, and compose sections.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "azriPtr",
|
|
8
|
+
"homepage": "https://moonarc.dev/mcp/",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"astro",
|
|
11
|
+
"mcp",
|
|
12
|
+
"mcp-server",
|
|
13
|
+
"model-context-protocol",
|
|
14
|
+
"animation",
|
|
15
|
+
"motion",
|
|
16
|
+
"components",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"cursor"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"moonarc-mcp": "./bin/moonarc.js",
|
|
22
|
+
"moonarc": "./bin/moonarc.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin",
|
|
26
|
+
"src",
|
|
27
|
+
"catalog.json",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./src/server.js",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.20.0",
|
|
36
|
+
"zod": "^4.0.0"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22.12.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build:catalog": "node scripts/build-catalog.mjs",
|
|
46
|
+
"test": "node scripts/build-catalog.mjs && node scripts/smoke.mjs && node scripts/test.mjs"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit_motion: findings against the critique rubric, on the user's own
|
|
3
|
+
* files. Static, deterministic, line-accurate. Runs forever on evolving code,
|
|
4
|
+
* which is what makes it worth a subscription; cannot be copy-pasted.
|
|
5
|
+
*
|
|
6
|
+
* Each rule reads one view of a file, so prose is never a finding: the style
|
|
7
|
+
* sheets (a .css file, <style> blocks, style="" attributes, a script's css``
|
|
8
|
+
* and styled`` templates), the scripts with their comments blanked (a .js/.ts
|
|
9
|
+
* file, <script> blocks), or the markup. An .astro file's frontmatter runs on
|
|
10
|
+
* the server and is in none of them. Every view keeps each character where it
|
|
11
|
+
* was, so a finding's line is the source line.
|
|
12
|
+
*/
|
|
13
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
14
|
+
import { extname, join, relative } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const EXT = new Set(['.astro', '.css', '.scss', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.svelte', '.vue', '.html']);
|
|
17
|
+
const SKIP = new Set(['node_modules', 'dist', '.astro', '.git', '.output', 'build', '.vercel', '.netlify']);
|
|
18
|
+
const SCRIPT_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs']);
|
|
19
|
+
|
|
20
|
+
/** @typedef {{ rule: string, severity: 'error'|'warn'|'info', file: string, line: number, message: string, fix: string, excerpt: string }} Finding */
|
|
21
|
+
|
|
22
|
+
const LAYOUT_PROPS = /\b(width|height|top|left|right|bottom|margin(?:-[a-z]+)?|padding(?:-[a-z]+)?|inset(?:-[a-z]+)?|font-size|line-height|border-width)\b/;
|
|
23
|
+
/** A property name that is not the end of a longer one: `transition` but not `--my-transition`. */
|
|
24
|
+
const prop = (name) => `(?<![\\w-])${name}`;
|
|
25
|
+
|
|
26
|
+
// ---------- views ----------
|
|
27
|
+
|
|
28
|
+
const blankRange = (chars, from, to) => { for (let k = from; k < to; k++) if (chars[k] !== '\n') chars[k] = ' '; };
|
|
29
|
+
const blankOf = (text) => text.replace(/[^\n]/g, ' ');
|
|
30
|
+
|
|
31
|
+
/** A style sheet with its comments blanked (block comments, and SCSS line comments). */
|
|
32
|
+
function cssText(src, scss = false) {
|
|
33
|
+
let out = src.replace(/\/\*[\s\S]*?(?:\*\/|$)/g, blankOf);
|
|
34
|
+
if (scss) out = out.replace(/(^|[\s;{}])(\/\/[^\n]*)/g, (_, pre, c) => pre + blankOf(c));
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const REGEX_AFTER = /(?:^|[^\w$])(?:return|typeof|case|do|else|in|of|void|yield|await|delete|instanceof|new|throw)$/;
|
|
39
|
+
/** The tag of a CSS-in-JS template literal (styled-components, Emotion, Lit): an untagged template is text, often prose. */
|
|
40
|
+
const CSS_TAG = /(?:^|[^\w$.])(?:css|keyframes|createGlobalStyle|injectGlobal|styled(?:\.[\w$]+|\([^()]*\))(?:\.attrs\([^()]*\))?)\s*$/;
|
|
41
|
+
/**
|
|
42
|
+
* A script with its comments blanked (`code`), and a copy with everything blanked but the text of its CSS template
|
|
43
|
+
* literals (`tpl`). A regular-expression literal is told from a division by what precedes it, well enough for a line
|
|
44
|
+
* audit.
|
|
45
|
+
*/
|
|
46
|
+
function scriptText(src) {
|
|
47
|
+
const code = src.split('');
|
|
48
|
+
const tpl = blankOf(src).split('');
|
|
49
|
+
const braces = [];
|
|
50
|
+
let depth = 0;
|
|
51
|
+
let i = 0;
|
|
52
|
+
let css = false;
|
|
53
|
+
const regexAllowed = () => {
|
|
54
|
+
let k = i - 1;
|
|
55
|
+
while (k >= 0 && /\s/.test(code[k])) k--;
|
|
56
|
+
if (k < 0) return true;
|
|
57
|
+
if ('(,=:[!&|?{};+-*%<>~^'.includes(code[k])) return true;
|
|
58
|
+
return /[\w$]/.test(code[k]) && REGEX_AFTER.test(code.slice(Math.max(0, k - 11), k + 1).join(''));
|
|
59
|
+
};
|
|
60
|
+
// i is just past a backtick, or past the } that closes a ${…}: read the literal up to its end or its next ${, and copy
|
|
61
|
+
// its text into tpl when it is CSS
|
|
62
|
+
const template = () => {
|
|
63
|
+
while (i < src.length) {
|
|
64
|
+
const c = src[i];
|
|
65
|
+
if (c === '\\') { if (css) { tpl[i] = c; if (i + 1 < src.length && src[i + 1] !== '\n') tpl[i + 1] = src[i + 1]; } i += 2; continue; }
|
|
66
|
+
if (c === '`') { i++; return; }
|
|
67
|
+
if (c === '$' && src[i + 1] === '{') { braces.push({ depth, css }); depth++; i += 2; return; }
|
|
68
|
+
if (css && c !== '\n') tpl[i] = c;
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
while (i < src.length) {
|
|
73
|
+
const c = src[i];
|
|
74
|
+
if (c === '/' && src[i + 1] === '/') { const e = src.indexOf('\n', i); const end = e < 0 ? src.length : e; blankRange(code, i, end); i = end; continue; }
|
|
75
|
+
if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); const end = e < 0 ? src.length : e + 2; blankRange(code, i, end); i = end; continue; }
|
|
76
|
+
if (c === '"' || c === "'") { i++; while (i < src.length && src[i] !== c && src[i] !== '\n') i += src[i] === '\\' ? 2 : 1; i++; continue; }
|
|
77
|
+
if (c === '`') { css = CSS_TAG.test(src.slice(Math.max(0, i - 60), i)); i++; template(); continue; }
|
|
78
|
+
if (c === '/' && regexAllowed()) {
|
|
79
|
+
i++;
|
|
80
|
+
let cls = false;
|
|
81
|
+
while (i < src.length && src[i] !== '\n') {
|
|
82
|
+
const d = src[i];
|
|
83
|
+
if (d === '\\') { i += 2; continue; }
|
|
84
|
+
if (d === '[') cls = true;
|
|
85
|
+
else if (d === ']') cls = false;
|
|
86
|
+
else if (d === '/' && !cls) break;
|
|
87
|
+
i++;
|
|
88
|
+
}
|
|
89
|
+
i++;
|
|
90
|
+
while (i < src.length && /[a-z]/i.test(src[i])) i++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (c === '{') depth++;
|
|
94
|
+
else if (c === '}') {
|
|
95
|
+
depth--;
|
|
96
|
+
if (braces.length && braces.at(-1).depth === depth) { ({ css } = braces.pop()); i++; template(); continue; }
|
|
97
|
+
}
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
return { code: code.join(''), tpl: tpl.join('') };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const RUNS_AS_SCRIPT = /^(?:module|(?:text|application)\/(?:x-)?(?:java|ecma)script|text\/babel)$/i;
|
|
104
|
+
|
|
105
|
+
/** The views of one file: `css`, `code` and `markup`, each as long as the file. */
|
|
106
|
+
function views(file, text) {
|
|
107
|
+
const ext = extname(file);
|
|
108
|
+
if (ext === '.css' || ext === '.scss') return { css: cssText(text, ext === '.scss'), code: blankOf(text), markup: blankOf(text) };
|
|
109
|
+
if (SCRIPT_EXT.has(ext)) { const s = scriptText(text); return { css: cssText(s.tpl), code: s.code, markup: blankOf(text) }; }
|
|
110
|
+
const css = blankOf(text).split('');
|
|
111
|
+
const code = blankOf(text).split('');
|
|
112
|
+
const markup = text.split('');
|
|
113
|
+
const put = (chars, at, s) => { for (let k = 0; k < s.length; k++) if (s[k] !== '\n') chars[at + k] = s[k]; };
|
|
114
|
+
// text that is neither markup nor a block: an .astro frontmatter (server code) and HTML comments
|
|
115
|
+
const dead = [];
|
|
116
|
+
if (ext === '.astro') { const m = text.match(/^\s*---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?=\r?\n|$)/); if (m) dead.push([0, m[0].length]); }
|
|
117
|
+
for (const m of text.matchAll(/<!--[\s\S]*?(?:-->|$)/g)) dead.push([m.index, m.index + m[0].length]);
|
|
118
|
+
const isDead = (at) => dead.some(([a, b]) => at >= a && at < b);
|
|
119
|
+
for (const [a, b] of dead) blankRange(markup, a, b);
|
|
120
|
+
for (const m of text.matchAll(/<(style|script)\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/\1\s*>/gi)) {
|
|
121
|
+
if (isDead(m.index)) continue;
|
|
122
|
+
const start = m.index + m[0].length - m[3].length - `</${m[1]}>`.length - (m[0].match(/<\/\w+(\s*)>$/)?.[1].length ?? 0);
|
|
123
|
+
blankRange(markup, start, start + m[3].length);
|
|
124
|
+
if (m[1].toLowerCase() === 'style') { put(css, start, cssText(m[3], /\blang=["']?scss/.test(m[2]))); continue; }
|
|
125
|
+
const type = m[2].match(/(?:^|\s)type\s*=\s*["']?([^"'\s>]+)/i)?.[1];
|
|
126
|
+
if (type && !RUNS_AS_SCRIPT.test(type)) continue; // JSON, JSON-LD, an import map: data
|
|
127
|
+
const s = scriptText(m[3]);
|
|
128
|
+
put(code, start, s.code);
|
|
129
|
+
put(css, start, cssText(s.tpl));
|
|
130
|
+
}
|
|
131
|
+
// inline styles: the value of each style="" attribute left in the markup
|
|
132
|
+
const live = markup.join('');
|
|
133
|
+
for (const m of live.matchAll(/\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
134
|
+
const value = m[1] ?? m[2];
|
|
135
|
+
put(css, m.index + m[0].length - value.length - 1, value);
|
|
136
|
+
}
|
|
137
|
+
return { css: css.join(''), code: code.join(''), markup: live };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every { } block of a style sheet: where its prelude starts, where it opens and closes, and `own`, the text between
|
|
142
|
+
* its braces with each nested block (prelude and body) blanked and ended with a `;`, so its own declarations stand
|
|
143
|
+
* alone. The last entry is the sheet's top level (style="" values live there).
|
|
144
|
+
*/
|
|
145
|
+
function blocksOf(css) {
|
|
146
|
+
const out = [];
|
|
147
|
+
const stack = [{ open: -1, prelude: 0, seg: 0, nested: [] }];
|
|
148
|
+
let quote = null;
|
|
149
|
+
for (let i = 0; i < css.length; i++) {
|
|
150
|
+
const c = css[i];
|
|
151
|
+
// a CSS string ends at its quote, or at the end of the line (an unclosed one)
|
|
152
|
+
if (quote) { if (c === '\\') i++; else if (c === quote || c === '\n') quote = null; continue; }
|
|
153
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
154
|
+
const top = stack.at(-1);
|
|
155
|
+
if (c === ';') top.seg = i + 1;
|
|
156
|
+
else if (c === '{') stack.push({ open: i, prelude: top.seg, seg: i + 1, nested: [] });
|
|
157
|
+
else if (c === '}' && stack.length > 1) {
|
|
158
|
+
const b = stack.pop();
|
|
159
|
+
const parent = stack.at(-1);
|
|
160
|
+
parent.nested.push([b.prelude, i]);
|
|
161
|
+
parent.seg = i + 1;
|
|
162
|
+
out.push({ ...b, close: i });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
out.push({ ...stack[0], open: -1, close: css.length });
|
|
166
|
+
for (const b of out) {
|
|
167
|
+
const own = css.slice(b.open + 1, b.close).split('');
|
|
168
|
+
for (const [s, e] of b.nested) { blankRange(own, s - b.open - 1, e - b.open); own[e - b.open - 1] = ';'; }
|
|
169
|
+
b.own = own.join('');
|
|
170
|
+
b.selector = b.open < 0 ? '' : css.slice(b.prelude, b.open).trim();
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The innermost block around a position of the sheet. */
|
|
176
|
+
const blockAt = (blocks, at) => blocks.filter((b) => b.open < at && at < b.close).reduce((a, b) => (b.open > a.open ? b : a), blocks.at(-1));
|
|
177
|
+
/** A declaration of the block itself (not of a nested rule): `name:` at its start or after a `;`. */
|
|
178
|
+
const declares = (b, name) => new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*(?!none\\b)[^;\\s]`).test(b.own);
|
|
179
|
+
const KEYFRAME_STEP = /^(?:from|to|\d+(?:\.\d+)?%)(?:\s*,\s*(?:from|to|\d+(?:\.\d+)?%))*$/;
|
|
180
|
+
const STATE = /:(?:hover|active|focus|focus-visible|focus-within|checked)\b|\[(?:open|aria-[\w-]+|data-[\w-]+)[^\]]*\]|\.is-[\w-]+/;
|
|
181
|
+
|
|
182
|
+
const lineAt = (text, at) => text.slice(0, at).split('\n').length;
|
|
183
|
+
/** The first character at or after `at` that is not white space: where a prelude's text starts. */
|
|
184
|
+
const firstChar = (text, at) => { while (at < text.length && /\s/.test(text[at])) at++; return at; };
|
|
185
|
+
/** Top-level commas only: `var(--a, 1s)` keeps its comma. */
|
|
186
|
+
const splitList = (s) => { const out = []; let depth = 0; let cur = ''; for (const c of s) { if (c === '(') depth++; else if (c === ')') depth--; if (c === ',' && depth === 0) { out.push(cur); cur = ''; } else cur += c; } out.push(cur); return out; };
|
|
187
|
+
|
|
188
|
+
// ---------- rules ----------
|
|
189
|
+
|
|
190
|
+
/** A rule tested line by line on one view: `test` returns the message or null. */
|
|
191
|
+
const perLine = (view, test) => (v, file) => v[view].split('\n').flatMap((l, i, lines) => { const msg = test(l, { file, lines, i, v }); return msg ? [{ line: i + 1, message: msg }] : []; });
|
|
192
|
+
|
|
193
|
+
/** @type {{ id: string, severity: Finding['severity'], run: (v: { css: string, code: string, markup: string, raw: string, blocks: any[] }, file: string) => { line: number, message: string }[], fix: string }[]} */
|
|
194
|
+
const RULES = [
|
|
195
|
+
{
|
|
196
|
+
id: 'transition-all',
|
|
197
|
+
severity: 'error',
|
|
198
|
+
fix: 'Name the properties: `transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out)`. `all` animates layout properties and repaints every frame.',
|
|
199
|
+
run: perLine('css', (l) => (new RegExp(`${prop('transition(?:-property)?')}\\s*:\\s*all\\b`).test(l) ? '`transition: all` animates every property, including layout' : null)),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'layout-transition',
|
|
203
|
+
// a warning: an accordion's height or a divider's left can be the right call, and only a person can tell
|
|
204
|
+
severity: 'warn',
|
|
205
|
+
fix: 'Animate `transform`, `opacity`, `clip-path` or `filter` instead; for size changes animate `scale` or use a grid-rows trick, for position use `translate`.',
|
|
206
|
+
run: perLine('css', (l) => {
|
|
207
|
+
const m = l.match(new RegExp(`${prop('transition(?:-property)?')}\\s*:\\s*([^;}{]+)`));
|
|
208
|
+
if (!m) return null;
|
|
209
|
+
const props = splitList(m[1]).map((p) => p.trim().split(/\s+/)[0]).filter((p) => p && !p.startsWith('--') && !p.startsWith('var('));
|
|
210
|
+
const bad = props.filter((p) => LAYOUT_PROPS.test(p));
|
|
211
|
+
return bad.length ? `transitions a layout property (${bad.join(', ')}): the browser lays out and paints every frame` : null;
|
|
212
|
+
}),
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: 'layout-keyframes',
|
|
216
|
+
severity: 'warn',
|
|
217
|
+
fix: 'Move the motion to `transform`/`translate`/`scale`/`opacity`. Keyframes on layout properties run on the main thread.',
|
|
218
|
+
// once per @keyframes rule, at its first step that moves a layout property
|
|
219
|
+
run: (v) => v.blocks.filter((k) => /^@(?:-\w+-)?keyframes\b/.test(k.selector)).flatMap((k) => {
|
|
220
|
+
for (const b of v.blocks.filter((b) => b.open > k.open && b.close < k.close && KEYFRAME_STEP.test(b.selector)).sort((a, b) => a.open - b.open)) {
|
|
221
|
+
const m = b.own.match(/(?:^|;)\s*((?:min-|max-)?(?:width|height)|top|left|right|bottom|margin[\w-]*|padding[\w-]*)\s*:/);
|
|
222
|
+
if (m) return [{ line: lineAt(v.css, firstChar(v.css, b.prelude)), message: `keyframe animates ${m[1]}, a layout property` }];
|
|
223
|
+
}
|
|
224
|
+
return [];
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: 'will-change-static',
|
|
229
|
+
severity: 'warn',
|
|
230
|
+
fix: '`will-change` is a loan: set it when the interaction starts and clear it when it ends (the runtime does this in Reveal). Left in a stylesheet it costs memory on every matching element, always. An element that animates for as long as the rule applies (an infinite or scroll-driven animation in the same rule) is the exception, and is not reported.',
|
|
231
|
+
run: (v) => [...v.css.matchAll(new RegExp(`${prop('will-change')}\\s*:\\s*(?!auto\\b)[a-z-]`, 'g'))].flatMap((m) => {
|
|
232
|
+
const b = blockAt(v.blocks, m.index);
|
|
233
|
+
if (STATE.test(b.selector) || declares(b, 'animation(?:-name)?')) return [];
|
|
234
|
+
return [{ line: lineAt(v.css, m.index), message: 'will-change declared permanently in CSS' }];
|
|
235
|
+
}),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'blur-animated',
|
|
239
|
+
severity: 'warn',
|
|
240
|
+
fix: 'Animate the position or opacity of an already-blurred element instead of the blur radius; keep radii under ~8px when you must.',
|
|
241
|
+
run: (v) => {
|
|
242
|
+
if (!/\bblur\(/.test(v.css)) return [];
|
|
243
|
+
const out = [];
|
|
244
|
+
for (const m of v.css.matchAll(new RegExp(`${prop('transition(?:-property)?')}\\s*:[^;{}]*${prop('(?:backdrop-)?filter')}\\b`, 'g'))) out.push({ line: lineAt(v.css, m.index), message: 'transitioned blur() is re-rendered every frame' });
|
|
245
|
+
for (const b of v.blocks.filter((b) => KEYFRAME_STEP.test(b.selector))) {
|
|
246
|
+
const m = b.own.match(/(?:^|;)\s*(?:backdrop-)?filter\s*:[^;]*\bblur\(/);
|
|
247
|
+
if (m) out.push({ line: lineAt(v.css, b.open + 1 + m.index + m[0].search(/(?:backdrop-)?filter/)), message: 'keyframed blur() is re-rendered every frame' });
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: 'long-ui-duration',
|
|
254
|
+
severity: 'info',
|
|
255
|
+
fix: 'UI transitions (hover, press, open/close) read best at 100–300 ms; reserve 500+ ms for reveals beside content, never for something in front of the user\'s intent.',
|
|
256
|
+
run: perLine('css', (l) => {
|
|
257
|
+
const m = l.match(new RegExp(`${prop('transition(?:-duration)?')}\\s*:[^;]*?(\\d+(?:\\.\\d+)?)(ms|s)\\b`));
|
|
258
|
+
if (!m) return null;
|
|
259
|
+
const ms = m[2] === 's' ? Number(m[1]) * 1000 : Number(m[1]);
|
|
260
|
+
return ms > 600 ? `transition of ${ms} ms, long for a UI state change` : null;
|
|
261
|
+
}),
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'animation-timeline-shorthand',
|
|
265
|
+
severity: 'error',
|
|
266
|
+
fix: 'Put `animation-timeline` in a separate rule with different selector text. lightningcss folds it into the `animation` shorthand, which browsers reject, and the animation silently disappears in production.',
|
|
267
|
+
// the declaration only: `@supports (animation-timeline: view())` is a condition, and a nested rule is another rule
|
|
268
|
+
run: (v) => v.blocks.filter((b) => b.open >= 0 && /(?:^|;)\s*animation\s*:/.test(b.own)).flatMap((b) => [...b.own.matchAll(/(?:^|;)(\s*)animation-timeline\s*:/g)].map((m) => ({ line: lineAt(v.css, b.open + 1 + m.index + (m[0].startsWith(';') ? 1 : 0) + m[1].length), message: '`animation-timeline` in the same rule as the `animation` shorthand' }))),
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 'no-reduced-motion',
|
|
272
|
+
severity: 'warn',
|
|
273
|
+
fix: 'Add a `@media (prefers-reduced-motion: reduce)` branch that keeps the fade and drops the travel, or read `prefersReducedMotion()` from the runtime in scripts.',
|
|
274
|
+
run: (v) => {
|
|
275
|
+
const animates = new RegExp(`@keyframes\\b|${prop('(?:animation|transition)(?:-name|-property)?')}\\s*:\\s*(?!none\\b)[^;\\s]`).test(v.css) || /\.animate\(\s*[[{]/.test(v.code);
|
|
276
|
+
return animates && !/prefers-reduced-motion|reduced-?motion|motion-(?:reduce|safe)/i.test(v.raw) ? [{ line: 1, message: 'file animates but has no reduced-motion branch' }] : [];
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: 'page-load-listener',
|
|
281
|
+
severity: 'error',
|
|
282
|
+
fix: 'Use `onMount`/`onPage` from `@moonarc/core/runtime`: `astro:page-load` does not fire without <ClientRouter />, can be skipped or doubled on back/forward, and needs a matching `astro:before-swap` teardown.',
|
|
283
|
+
run: (v) => (/astro:before-swap/.test(v.code) ? [] : perLine('code', (l) => (/addEventListener\(\s*['"`]astro:page-load['"`]/.test(l) ? '`astro:page-load` listener without an `astro:before-swap` teardown' : null))(v)),
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: 'single-instance-query',
|
|
287
|
+
severity: 'warn',
|
|
288
|
+
fix: 'Astro runs a component <script> once per page, not per instance. Use `querySelectorAll` and loop, or `onMount(selector, setup)` from the runtime.',
|
|
289
|
+
run: (v, file) => (/\.astro$/.test(file) ? perLine('code', (l) => (/^\s*(?:const|let|var)\s+\w+\s*=\s*document\.querySelector\(/.test(l) ? '`document.querySelector` in a component script binds only the first instance' : null))(v) : []),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
id: 'define-vars-script',
|
|
293
|
+
severity: 'warn',
|
|
294
|
+
fix: 'Pass per-instance values as `data-*` attributes and read `el.dataset` in the script. `define:vars` on a <script> implies `is:inline`: no bundling, no imports, duplicated per instance.',
|
|
295
|
+
run: perLine('markup', (l) => (/<script\b[^>]*\bdefine:vars\b/.test(l) ? '`define:vars` on a <script>' : null)),
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
id: 'hover-not-gated',
|
|
299
|
+
severity: 'info',
|
|
300
|
+
fix: 'Wrap hover-only motion in `@media (hover: hover) and (pointer: fine)` so touch devices do not trigger it on tap.',
|
|
301
|
+
run: (v) => (/:hover\s*\{[^}]*(transform|translate|scale|animation)/.test(v.css.replace(/\n/g, ' ')) && !/hover:\s*hover/.test(v.raw) ? [{ line: 1, message: ':hover motion not gated on (hover: hover)' }] : []),
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
id: 'scale-zero',
|
|
305
|
+
severity: 'info',
|
|
306
|
+
fix: 'Use `scale(0.9–0.97)` with `opacity: 0`; scaling from zero reads as a pop, not an entrance.',
|
|
307
|
+
run: perLine('css', (l) => (new RegExp(`(?:${prop('transform')}\\s*:\\s*scale|${prop('scale')}\\s*:)\\s*\\(?\\s*0\\s*\\)?\\s*[;}]`).test(l) ? 'scale(0) entrance' : null)),
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
function* walk(dir) {
|
|
312
|
+
for (const f of readdirSync(dir)) {
|
|
313
|
+
if (SKIP.has(f)) continue;
|
|
314
|
+
const p = join(dir, f);
|
|
315
|
+
const st = statSync(p);
|
|
316
|
+
if (st.isDirectory()) yield* walk(p);
|
|
317
|
+
else if (EXT.has(extname(f))) yield p;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* @param {string[]} paths files or directories
|
|
323
|
+
* @returns {{ files: number, findings: Finding[] }}
|
|
324
|
+
*/
|
|
325
|
+
export function audit(paths) {
|
|
326
|
+
const files = [];
|
|
327
|
+
for (const p of paths) {
|
|
328
|
+
let st;
|
|
329
|
+
try {
|
|
330
|
+
st = statSync(p);
|
|
331
|
+
} catch {
|
|
332
|
+
throw new Error(`No such file or folder: ${p}`);
|
|
333
|
+
}
|
|
334
|
+
if (st.isDirectory()) files.push(...walk(p));
|
|
335
|
+
else files.push(p);
|
|
336
|
+
}
|
|
337
|
+
const root = process.cwd();
|
|
338
|
+
/** @type {Finding[]} */
|
|
339
|
+
const findings = [];
|
|
340
|
+
for (const file of files) {
|
|
341
|
+
const raw = readFileSync(file, 'utf8');
|
|
342
|
+
const v = { ...views(file, raw), raw };
|
|
343
|
+
v.blocks = blocksOf(v.css);
|
|
344
|
+
const lines = raw.split('\n');
|
|
345
|
+
const rel = relative(root, file) || file;
|
|
346
|
+
for (const rule of RULES) {
|
|
347
|
+
for (const f of rule.run(v, file)) findings.push({ rule: rule.id, severity: rule.severity, file: rel, line: f.line, message: f.message, fix: rule.fix, excerpt: (lines[f.line - 1] ?? '').trim().slice(0, 120) });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const order = { error: 0, warn: 1, info: 2 };
|
|
351
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity] || a.file.localeCompare(b.file) || a.line - b.line);
|
|
352
|
+
return { files: files.length, findings };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** @param {{ files: number, findings: Finding[] }} r */
|
|
356
|
+
export function formatAudit(r) {
|
|
357
|
+
if (r.findings.length === 0) return `moonarc audit · ${r.files} files · no findings`;
|
|
358
|
+
const by = (s) => r.findings.filter((f) => f.severity === s).length;
|
|
359
|
+
const head = `moonarc audit · ${r.files} files · ${by('error')} errors, ${by('warn')} warnings, ${by('info')} notes`;
|
|
360
|
+
const body = r.findings.map((f) => ` ${f.severity.padEnd(5)} ${f.file}:${f.line} [${f.rule}] ${f.message}\n ${f.excerpt}\n → ${f.fix}`).join('\n\n');
|
|
361
|
+
return `${head}\n\n${body}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The rule ids, for tests that hold the count /pro states (13) against the code. */
|
|
365
|
+
export const ruleIds = RULES.map((r) => r.id);
|
package/src/catalog.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/** @typedef {{ name: string, slug: string, title: string, description: string, category: string, tier: string, props: {name:string,type:string,default?:string,description:string}[], reducedMotion: string, clientRouter: string, craft: string[], related: string[], intents: string[], replaces: string[], source: string, cost: { js: {raw:number,gzip:number}, jsWithShared: {raw:number,gzip:number}, css: {raw:number,gzip:number}, dependsOn: string[] } | null, url: string }} Entry */
|
|
6
|
+
/** @typedef {{ site: string, runtime: {raw:number,gzip:number}|null, chunks?: Record<string, {raw:number,gzip:number}>, components: Entry[] }} Catalog */
|
|
7
|
+
|
|
8
|
+
let cached = null;
|
|
9
|
+
|
|
10
|
+
/** What every tool reads of a catalogue: a site and components with names, slugs and props. A URL answering anything else is not used. */
|
|
11
|
+
const usable = (c) => typeof c?.site === 'string' && Array.isArray(c.components) && c.components.length > 0 && c.components.every((e) => typeof e?.name === 'string' && typeof e.slug === 'string' && Array.isArray(e.props) && Array.isArray(e.intents));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The bundled catalogue, or the one at MOONARC_CATALOG_URL: the same shape as catalog.json, served by a mirror or a
|
|
15
|
+
* test. A URL that fails, takes over 5 s or answers another shape leaves the bundled copy in charge, with a line on
|
|
16
|
+
* stderr (an MCP client shows the server's stderr in its log; stdout is the protocol).
|
|
17
|
+
* @returns {Promise<Catalog>}
|
|
18
|
+
*/
|
|
19
|
+
export async function loadCatalog() {
|
|
20
|
+
if (cached) return cached;
|
|
21
|
+
const url = process.env.MOONARC_CATALOG_URL;
|
|
22
|
+
if (url) {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
25
|
+
if (!res.ok) throw new Error(`answered ${res.status}`);
|
|
26
|
+
const body = await res.json();
|
|
27
|
+
if (!usable(body)) throw new Error('answered something other than a catalogue');
|
|
28
|
+
return (cached = body);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error(`moonarc: MOONARC_CATALOG_URL ${err?.name === 'TimeoutError' ? 'gave no answer within 5 s' : err.message}; using the bundled catalogue`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const path = join(dirname(fileURLToPath(import.meta.url)), '../catalog.json');
|
|
34
|
+
return (cached = JSON.parse(readFileSync(path, 'utf8')));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
38
|
+
|
|
39
|
+
/** One component by name or slug, in any case and spacing: Reveal, reveal, split-text, Split Text. */
|
|
40
|
+
export function findComponent(cat, name) {
|
|
41
|
+
const key = norm(name);
|
|
42
|
+
return key ? cat.components.find((c) => norm(c.name) === key || norm(c.slug) === key) : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The names Rollup gives Moonarc's chunks in a build (Reveal, runtime, canvas, …), for measure_budget's share: the
|
|
47
|
+
* catalogue's `chunks` when build-catalog wrote them (every component and helper a fixture page loads, Pro included),
|
|
48
|
+
* else the components and what they depend on.
|
|
49
|
+
* @param {Catalog} cat
|
|
50
|
+
*/
|
|
51
|
+
export function libraryChunks(cat) {
|
|
52
|
+
if (cat.chunks) return new Set(Object.keys(cat.chunks));
|
|
53
|
+
return new Set(cat.components.flatMap((c) => [c.name, ...(c.cost?.dependsOn ?? [])]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A byte figure with its unit, as the site prints it: "1.3 kB raw", "699 B gzip"; zero is "0 B" in either. */
|
|
57
|
+
const fmt = (b, unit) => (b === 0 ? '0 B' : `${b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} kB`} ${unit}`);
|
|
58
|
+
|
|
59
|
+
/** @param {Entry} e */
|
|
60
|
+
export function costLine(e) {
|
|
61
|
+
if (!e.cost) return 'not measured';
|
|
62
|
+
const deps = e.cost.dependsOn;
|
|
63
|
+
if (e.cost.js.raw === 0 && deps.length === 0) return '0 B JS';
|
|
64
|
+
if (e.cost.js.raw === 0) return `0 B JS of its own (uses ${deps.join(' + ')}; ${fmt(e.cost.jsWithShared.raw, 'raw')} with them)`;
|
|
65
|
+
return `${fmt(e.cost.js.raw, 'raw')} JS own (${fmt(e.cost.js.gzip, 'gzip')}), ${fmt(e.cost.jsWithShared.raw, 'raw')} with ${deps.join(' + ') || 'nothing'}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const BASELINE = { widely: 'Baseline widely available', newly: 'Baseline newly available', limited: 'limited availability' };
|
|
69
|
+
/** @param {Entry} e */
|
|
70
|
+
const supportText = (e) => `${e.supportLine} (${BASELINE[e.support.baseline] ?? e.support.baseline})${e.support.fallback ? `; elsewhere: ${e.support.fallback}` : ''}`;
|
|
71
|
+
|
|
72
|
+
// Words that say nothing about which component is meant. Each field is matched word by word, so "in" never matches
|
|
73
|
+
// "inline" and "text" never matches "context".
|
|
74
|
+
const STOP = new Set('a an and any are as at be by can for from how i in into is it its like make me my need of on or our so some that the this to use want we with you your'.split(' '));
|
|
75
|
+
/** Words, with a name split where its case changes: BlurText is "blur text", SVGIcon is "svg icon". */
|
|
76
|
+
const words = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
77
|
+
/** One form per word: fade, fading and faded are fad; logos is logo; glasses is glass. */
|
|
78
|
+
const stem = (w) => {
|
|
79
|
+
if (w.length <= 3) return w;
|
|
80
|
+
for (const s of ['ing', 'ed', 'es', 's', 'e']) if (w.endsWith(s) && w.length - s.length >= 3 && !(s === 's' && w.endsWith('ss'))) return w.slice(0, -s.length);
|
|
81
|
+
return w;
|
|
82
|
+
};
|
|
83
|
+
const terms = (s) => words(s).filter((w) => !STOP.has(w)).map(stem);
|
|
84
|
+
const bag = (s) => [...new Set(terms(s))];
|
|
85
|
+
/** Full weight for the word itself, half for a longer form of it (follow and follower, count and counter). */
|
|
86
|
+
const weigh = (field, t, weight) => (field.includes(t) ? weight : field.some((w) => (t.length >= 4 && w.startsWith(t)) || (w.length >= 4 && t.startsWith(w))) ? weight / 2 : 0);
|
|
87
|
+
|
|
88
|
+
const index = new WeakMap();
|
|
89
|
+
/** @param {Entry} e */
|
|
90
|
+
const fieldsOf = (e) => {
|
|
91
|
+
if (!index.has(e)) {
|
|
92
|
+
index.set(e, {
|
|
93
|
+
intents: bag(e.intents.join(' ')),
|
|
94
|
+
title: bag(`${e.title} ${e.name} ${e.slug}`),
|
|
95
|
+
replaces: bag(e.replaces.join(' ')),
|
|
96
|
+
description: bag(e.description),
|
|
97
|
+
category: bag(e.category),
|
|
98
|
+
trigger: bag((e.trigger ?? []).join(' ')),
|
|
99
|
+
phrases: [...e.intents, ...e.replaces].map((p) => words(p).join(' ')),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return index.get(e);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every component that answers the intent, best first. Each word of the intent scores once per field it appears in
|
|
107
|
+
* (intents 4, title 3, what it replaces 3, category 2, description 1, trigger 1); a component that answers more of the
|
|
108
|
+
* words ranks above one that answers one word in many fields; the intent naming one of its intents or what it
|
|
109
|
+
* replaces, or a run of words inside one, adds more, and its exact name most. Ties go to the smaller script.
|
|
110
|
+
* @param {Entry[]} components @param {string} intent @param {{ maxJsBytes?: number, tier?: string }} [filters]
|
|
111
|
+
*/
|
|
112
|
+
export function search(components, intent, { maxJsBytes, tier } = {}) {
|
|
113
|
+
const q = [...new Set(terms(intent))];
|
|
114
|
+
const phrase = words(intent).join(' ');
|
|
115
|
+
const exact = norm(intent);
|
|
116
|
+
return components
|
|
117
|
+
.map((e) => {
|
|
118
|
+
const f = fieldsOf(e);
|
|
119
|
+
let score = 0;
|
|
120
|
+
let answered = 0;
|
|
121
|
+
for (const t of q) {
|
|
122
|
+
const s = weigh(f.intents, t, 4) + weigh(f.title, t, 3) + weigh(f.replaces, t, 3) + weigh(f.category, t, 2) + weigh(f.description, t, 1) + weigh(f.trigger, t, 1);
|
|
123
|
+
if (s) { score += s; answered++; }
|
|
124
|
+
}
|
|
125
|
+
if (q.length > 1) score += 3 * answered;
|
|
126
|
+
if (phrase && f.phrases.includes(phrase)) score += 12;
|
|
127
|
+
else if (phrase.includes(' ') && f.phrases.some((p) => ` ${phrase} `.includes(` ${p} `) || ` ${p} `.includes(` ${phrase} `))) score += 6;
|
|
128
|
+
if (exact && (exact === norm(e.name) || exact === norm(e.slug))) score += 20;
|
|
129
|
+
return { e, score };
|
|
130
|
+
})
|
|
131
|
+
.filter(({ e, score }) => score > 0 && (tier ? e.tier === tier : true) && (maxJsBytes != null ? (e.cost ? e.cost.js.raw <= maxJsBytes : true) : true))
|
|
132
|
+
.sort((a, b) => b.score - a.score || (a.e.cost?.js.raw ?? Infinity) - (b.e.cost?.js.raw ?? Infinity) || a.e.name.localeCompare(b.e.name))
|
|
133
|
+
.map(({ e }) => e);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @param {Entry} e */
|
|
137
|
+
export function summary(e) {
|
|
138
|
+
return { name: e.name, slug: e.slug, title: e.title, description: e.description, category: e.category, tier: e.tier, trigger: e.trigger, readout: e.readout, cost: costLine(e), jsBytes: e.cost?.js.raw ?? null, support: supportText(e), page: e.url, markdown: `${e.url.replace(/\/$/, '')}.md` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A Markdown table cell: a pipe would end the cell, inside code too (GitHub's table rules), and a newline the row. */
|
|
142
|
+
const cell = (s) => String(s).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ');
|
|
143
|
+
|
|
144
|
+
/** @param {Entry} e @param {{raw:number}|null} runtime */
|
|
145
|
+
export function detail(e, runtime) {
|
|
146
|
+
const props = e.props.map((p) => `| \`${cell(p.name)}\` | \`${cell(p.type)}\` | ${p.default ? `\`${cell(p.default)}\`` : 'none'} | ${cell(p.description)} |`).join('\n');
|
|
147
|
+
return `# ${e.title}
|
|
148
|
+
|
|
149
|
+
${e.description}
|
|
150
|
+
|
|
151
|
+
Import: \`import ${e.name} from '@moonarc/core/${e.name}'\`
|
|
152
|
+
Cost: ${costLine(e)}${runtime ? ` · shared runtime ${fmt(runtime.raw, 'raw')} once per site` : ''}
|
|
153
|
+
Support: ${supportText(e)}
|
|
154
|
+
Trigger: ${e.trigger.join(', ')}
|
|
155
|
+
Page: ${e.url}
|
|
156
|
+
|
|
157
|
+
## Usage
|
|
158
|
+
|
|
159
|
+
\`\`\`astro
|
|
160
|
+
${e.usage}
|
|
161
|
+
\`\`\`
|
|
162
|
+
|
|
163
|
+
## Props
|
|
164
|
+
|
|
165
|
+
| Prop | Type | Default | Description |
|
|
166
|
+
|---|---|---|---|
|
|
167
|
+
${props}
|
|
168
|
+
|
|
169
|
+
## Reduced motion
|
|
170
|
+
${e.reducedMotion}
|
|
171
|
+
|
|
172
|
+
## With ClientRouter
|
|
173
|
+
${e.clientRouter}
|
|
174
|
+
|
|
175
|
+
## Craft: repeat these decisions, do not re-derive them
|
|
176
|
+
${e.craft.map((c) => `- ${c}`).join('\n')}
|
|
177
|
+
|
|
178
|
+
## Replaces
|
|
179
|
+
${e.replaces.map((r) => `- ${r}`).join('\n')}
|
|
180
|
+
|
|
181
|
+
## Source (\`${e.name}.astro\`)
|
|
182
|
+
\`\`\`astro
|
|
183
|
+
${e.source}
|
|
184
|
+
\`\`\`
|
|
185
|
+
`;
|
|
186
|
+
}
|