Skip to content

Commit 3e1e2c5

Browse files
authored
Merge pull request #62 from nucliweb/feat/webmcp
Add WebMCP support
2 parents 637ebfe + b69480e commit 3e1e2c5

10 files changed

Lines changed: 1219 additions & 13 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* ESLint rule: prefer-dynamic-import-with-feature-guard
3+
*
4+
* Warns when a file has both static non-framework imports and a browser feature
5+
* detection guard (e.g. `if (!navigator.xxx) return`), which suggests the
6+
* import should be dynamic to avoid loading the module when the feature is unavailable.
7+
*/
8+
9+
const FRAMEWORK_PACKAGES = /^(react|react-dom|next|next-cloudinary|nextra)($|\/)/
10+
11+
module.exports = {
12+
meta: {
13+
type: 'suggestion',
14+
docs: {
15+
description:
16+
'Prefer dynamic imports when the module is only used inside a browser feature guard.',
17+
},
18+
messages: {
19+
preferDynamic:
20+
'Static import "{{ source }}" may be loaded unnecessarily. ' +
21+
'Consider a dynamic import inside the feature guard (e.g. `if (!navigator.xxx) return`).',
22+
},
23+
},
24+
25+
create(context) {
26+
const projectImports = []
27+
let hasNavigatorGuard = false
28+
29+
function isNavigatorGuard(node) {
30+
// Matches: !navigator.foo or !navigator?.foo
31+
if (node.type !== 'UnaryExpression' || node.operator !== '!') return false
32+
const arg = node.argument
33+
if (arg.type === 'MemberExpression') {
34+
return arg.object?.name === 'navigator'
35+
}
36+
if (arg.type === 'ChainExpression') {
37+
return arg.expression?.object?.name === 'navigator'
38+
}
39+
return false
40+
}
41+
42+
return {
43+
ImportDeclaration(node) {
44+
if (!FRAMEWORK_PACKAGES.test(node.source.value)) {
45+
projectImports.push(node)
46+
}
47+
},
48+
49+
IfStatement(node) {
50+
if (isNavigatorGuard(node.test)) {
51+
hasNavigatorGuard = true
52+
}
53+
},
54+
55+
'Program:exit'() {
56+
if (hasNavigatorGuard && projectImports.length > 0) {
57+
projectImports.forEach((imp) => {
58+
context.report({
59+
node: imp,
60+
messageId: 'preferDynamic',
61+
data: { source: imp.source.value },
62+
})
63+
})
64+
}
65+
},
66+
}
67+
},
68+
}

.claude/eslint.config.cjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const preferDynamicImport = require('./eslint-rules/prefer-dynamic-import-with-feature-guard.js')
2+
3+
module.exports = [
4+
{
5+
plugins: {
6+
local: {
7+
rules: {
8+
'prefer-dynamic-import-with-feature-guard': preferDynamicImport,
9+
},
10+
},
11+
},
12+
rules: {
13+
'local/prefer-dynamic-import-with-feature-guard': 'warn',
14+
},
15+
languageOptions: {
16+
ecmaVersion: 2020,
17+
sourceType: 'module',
18+
parserOptions: {
19+
ecmaFeatures: { jsx: true },
20+
},
21+
},
22+
},
23+
]

.claude/hooks/eslint-check.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/bin/bash
2+
# PostToolUse hook: runs ESLint with the prefer-dynamic-import-with-feature-guard
3+
# rule on any JS/JSX file that Claude just wrote or edited.
4+
5+
INPUT=$(cat)
6+
7+
FILE_PATH=$(echo "$INPUT" | node -e "
8+
process.stdin.setEncoding('utf8')
9+
let data = ''
10+
process.stdin.on('data', chunk => data += chunk)
11+
process.stdin.on('end', () => {
12+
try {
13+
const parsed = JSON.parse(data)
14+
process.stdout.write(parsed.tool_input?.file_path || '')
15+
} catch {}
16+
})
17+
")
18+
19+
# Only lint JS/JSX files
20+
if [[ -z "$FILE_PATH" ]] || [[ ! "$FILE_PATH" =~ \.(js|jsx)$ ]]; then
21+
exit 0
22+
fi
23+
24+
if [[ ! -f "$FILE_PATH" ]]; then
25+
exit 0
26+
fi
27+
28+
CONFIG="$(cd "$(dirname "$0")/.." && pwd)/eslint.config.cjs"
29+
30+
result=$(npx eslint --config "$CONFIG" "$FILE_PATH" 2>&1)
31+
32+
if [[ -n "$result" ]]; then
33+
echo "$result"
34+
exit 1
35+
fi
36+
37+
exit 0

components/LazyVideoPlayer.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useState } from 'react'
2+
import { CldVideoPlayer } from 'next-cloudinary'
3+
4+
export function LazyVideoPlayer({ src, width, height, poster }) {
5+
const [playing, setPlaying] = useState(false)
6+
7+
if (playing) {
8+
return <CldVideoPlayer width={width} height={height} src={src} autoPlay="always" />
9+
}
10+
11+
return (
12+
<div
13+
onClick={() => setPlaying(true)}
14+
style={{
15+
position: 'relative',
16+
cursor: 'pointer',
17+
background: '#000',
18+
aspectRatio: `${width} / ${height}`,
19+
overflow: 'hidden',
20+
}}
21+
>
22+
{poster && (
23+
<img
24+
src={poster}
25+
alt="Video tutorial preview"
26+
width={width}
27+
height={height}
28+
loading="lazy"
29+
style={{ width: '100%', height: 'auto', display: 'block' }}
30+
/>
31+
)}
32+
<div
33+
style={{
34+
position: 'absolute',
35+
inset: 0,
36+
display: 'flex',
37+
alignItems: 'center',
38+
justifyContent: 'center',
39+
}}
40+
>
41+
<div
42+
style={{
43+
width: '64px',
44+
height: '64px',
45+
background: 'rgba(0, 0, 0, 0.65)',
46+
borderRadius: '50%',
47+
display: 'flex',
48+
alignItems: 'center',
49+
justifyContent: 'center',
50+
fontSize: '24px',
51+
color: '#fff',
52+
userSelect: 'none',
53+
}}
54+
>
55+
56+
</div>
57+
</div>
58+
</div>
59+
)
60+
}

components/WebMCP.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useEffect } from 'react'
2+
3+
export function WebMCP() {
4+
useEffect(() => {
5+
if (!navigator.modelContext) return
6+
7+
import(/* webpackChunkName: "snippets-registry" */ '../lib/snippets-registry').then(({ snippets }) => {
8+
navigator.modelContext.registerTool({
9+
name: 'list_snippets',
10+
description:
11+
'List all web performance measurement snippets available on this site. Returns metadata without code.',
12+
inputSchema: { type: 'object', properties: {} },
13+
execute: () =>
14+
snippets.map(({ id, category, title, description, url }) => ({
15+
id,
16+
category,
17+
title,
18+
description,
19+
url,
20+
})),
21+
})
22+
23+
navigator.modelContext.registerTool({
24+
name: 'get_snippet',
25+
description:
26+
'Get the full JavaScript code for a specific web performance snippet by its ID.',
27+
inputSchema: {
28+
type: 'object',
29+
properties: {
30+
id: {
31+
type: 'string',
32+
description:
33+
'Snippet ID (e.g. "LCP", "CLS", "TTFB", "INP"). Use list_snippets to discover available IDs.',
34+
},
35+
},
36+
required: ['id'],
37+
},
38+
execute: ({ id }) => {
39+
const snippet = snippets.find((s) => s.id === id)
40+
if (!snippet) {
41+
return { error: `Snippet "${id}" not found. Use list_snippets to see available IDs.` }
42+
}
43+
return snippet
44+
},
45+
})
46+
47+
navigator.modelContext.registerTool({
48+
name: 'search_snippets',
49+
description:
50+
'Search web performance snippets by category and/or keyword. Returns metadata without code.',
51+
inputSchema: {
52+
type: 'object',
53+
properties: {
54+
category: {
55+
type: 'string',
56+
description:
57+
'Filter by category. One of: CoreWebVitals, Loading, Interaction, Media, Resources.',
58+
},
59+
query: {
60+
type: 'string',
61+
description: 'Keyword to filter by title or description.',
62+
},
63+
},
64+
},
65+
execute: ({ category, query } = {}) => {
66+
let results = snippets
67+
68+
if (category) {
69+
results = results.filter((s) => s.category === category)
70+
}
71+
72+
if (query) {
73+
const q = query.toLowerCase()
74+
results = results.filter(
75+
(s) =>
76+
s.title.toLowerCase().includes(q) ||
77+
s.description.toLowerCase().includes(q) ||
78+
s.id.toLowerCase().includes(q)
79+
)
80+
}
81+
82+
return results.map(({ id, category, title, description, url }) => ({
83+
id,
84+
category,
85+
title,
86+
description,
87+
url,
88+
}))
89+
},
90+
})
91+
})
92+
}, [])
93+
94+
return null
95+
}

0 commit comments

Comments
 (0)