Plone 5 主题开发提速:Grunt 前端工程化实战

发布时间:2026/7/6 10:41:26
Plone 5 主题开发提速:Grunt 前端工程化实战 1. 项目概述为什么 Plone 5 主题开发需要 Grunt 加速Plone 5 的主题开发本质上是一场“前端工程化”与“CMS 约束环境”的持续博弈。你刚在 Plone 5.2 环境里搭好一个新站点准备给客户做一个响应式主题——结果发现每次改一行 CSS就得手动执行bin/buildout、重启bin/instance fg、清浏览器缓存、再刷新页面……整个流程耗时 90 秒起步。更糟的是你写的 SCSS 文件一旦嵌套层级深了点plone.app.theming自带的编译器就报错连错误行号都不给你JS 模块用 RequireJS 写着写着依赖链一乱控制台满屏undefined is not a function而当你终于把图标字体生成好了又发现resourcemytheme/fonts/路径下文件没更新——因为 Plone 的资源注册机制根本没感知到你本地磁盘上的变更。这不是效率问题这是开发节奏被反复打断后的认知损耗。Speed Up Your Plone 5 Theming with Grunt这个标题说的不是“加个工具图新鲜”而是直指 Plone 5 主题开发者每天真实遭遇的三大硬伤编译不可控、资源不同步、反馈不即时。Grunt 在这里不是万能胶而是你和 Plone 后端之间的一条专用数据通道——它把 SCSS 编译、JS 合并压缩、SVG 转图标字体、静态资源自动同步、甚至浏览器自动刷新这些动作全部从 Plone 的 Python 进程里剥离出来交给 Node.js 前端工具链独立运行。这意味着你改完src/scss/main.scss3 秒内浏览器就刷新出效果你新增一个src/js/modules/carousel.jsGrunt 自动把它塞进main.js并注入 RequireJS 配置你拖进一张src/icons/arrow-right.svgGrunt 就生成新的字体文件、CSS 声明、HTML 示例页全在build/目录下备好。它不改变 Plone 的任何架构但彻底重构了你的工作流节奏。适合谁不是只写 HTML 的美工也不是纯后端的 Zope 开发者而是那些既要懂portal_css注册逻辑、又要手写 Flexbox 布局、还得调试require([jquery], function($) {...})的 Plone 全栈主题工程师。如果你还在用 Sublime Text 手动刷新 心算路径拼写来推进项目这篇就是为你写的实战手册。2. 整体设计思路与方案选型逻辑2.1 为什么是 Grunt而不是 Gulp、Webpack 或直接用 Plone 自带工具这个问题我踩过三次坑才理清楚。第一次用 Gulp写了一堆gulp.watch()监听任务结果发现 Gulp 4 的异步流在 Windows 上和 Plone 的collective.plonetruegallery插件冲突gulp.src(src/**/*)会漏掉某些.less文件第二次试 Webpack 5配置了webpack-dev-server和html-webpack-plugin本想热替换模板但 Plone 的theme.html是通过plone.app.theming的 XSLT 引擎解析的Webpack 根本无法介入其 DOM 构建流程最后只能退回到静态 HTML 预览模式完全脱离 Plone 环境第三次干脆放弃前端构建工具直接用 Plone 自带的plone.app.theming的lessc编译器结果在import variables;时它死活找不到src/less/variables.less查源码才发现它只认portal_resources下的路径根本不支持相对路径导入。Grunt 成为最终选择核心在于它的确定性和可拆解性。Grunt 的任务是原子化的grunt-contrib-sass只管编译grunt-contrib-requirejs只管打包grunt-sync只管复制文件每个插件职责单一、文档清晰、错误提示明确。更重要的是Grunt 的watch任务可以精确到文件类型和路径深度——比如watch: { scss: { files: [src/scss/**/*.scss], tasks: [sass:dev] } }它不会误触发 JS 任务也不会因为node_modules目录太大而卡死。而 Plone 5.2 的 Python 环境通常基于 Python 2.7 或 3.6对 Node.js 版本并不敏感Grunt CLI 1.5.3 完全兼容 Node.js 14.x这比 Webpack 5 要求 Node.js 12 更宽松。最关键的是Grunt 的grunt-plone插件生态虽小但足够精准grunt-plone-themeresource能直接读取registry.xml里的资源声明自动映射resourcemytheme/css/main.css到build/css/main.css省去了手动维护路径的麻烦。这不是技术先进性的选择而是在 Plone 5 的现实约束下最不容易翻车的务实方案。2.2 整体架构三层隔离 单向同步整个加速体系不是把所有东西塞进一个 Gruntfile而是按职责划分为三个逻辑层源码层src/存放所有人类可读、可编辑的原始文件。包括src/scss/SCSS 源码、src/js/ES5 模块化 JS、src/icons/SVG 图标、src/images/原始 PNG/JPG、src/templates/用于预览的静态 HTML。这一层严禁出现任何编译产物Git 忽略build/和node_modules/。构建层build/Grunt 的输出目录完全由自动化任务生成。包含build/css/main.css已压缩的 CSS、build/js/main.min.js合并压缩的 JS、build/fonts/图标字体文件、build/preview/静态预览页。这个目录的内容必须能被 Plone 直接识别——即build/css/main.css对应resourcemytheme/css/main.css路径结构严格一致。部署层profiles/default/registry.xmlPlone 的资源注册中心。这里不放文件只放声明。例如record nameplone.resources/mytheme-cssvaluedictelement keyjsresourcemytheme/js/main.min.js/elementelement keycssresourcemytheme/css/main.css/element/dict/value/record。Grunt 不修改此文件但grunt-plone-themeresource会读取它确保build/下的文件路径与注册路径完全匹配。三者关系是单向的src/→Grunt 编译→build/→Plone 启动时加载→ 浏览器渲染。没有反向操作不搞“实时热重载注入 Plone 进程”因为那会破坏 Plone 的 ZODB 事务一致性。这种设计牺牲了“毫秒级刷新”的幻觉换来了100% 可复现、可回滚、可审计的构建结果——上线前你只要对比build/和profiles/default/的哈希值就能确认生产环境和本地构建完全一致。2.3 关键决策为什么放弃 LiveReload坚持 BrowserSyncPlone 社区早期流行用grunt-contrib-livereload原理是在页面插入一段 JS 脚本监听 Grunt 的 WebSocket 信号。但我在实际项目中发现两个致命问题第一Plone 的portal_message提示框、#content-core的 AJAX 加载、以及plone.app.contenttypes的富文本编辑器都会动态修改 DOM导致 LiveReload 的 DOM 替换逻辑失效经常出现“样式刷了但内容没变”或“JS 重载后事件绑定丢失”第二当 Plone 启用plone.app.caching时livereload.js本身会被 Varnish 或 Nginx 缓存导致新版本脚本加载失败。BrowserSync 则完全不同它启动一个独立的代理服务器默认http://localhost:3000把所有请求转发给 Plone如http://localhost:8080同时注入自己的脚本。关键在于它不替换 DOM而是强制整个页面 reload且 reload 前会清空浏览器缓存通过添加时间戳参数?t1678886400123。实测下来BrowserSync 在 Plone 5.2 Firefox 102 环境下从保存文件到页面完全刷新完成稳定在 2.3~2.7 秒之间误差小于 0.2 秒。而且它自带 UI 控制台http://localhost:3001能看到所有设备连接状态、文件变更日志、网络请求瀑布图——当客户说“你们的按钮在 iPad 上点不了”我直接打开控制台切到 iPad 模拟视图点一下按钮立刻看到touchstart事件是否触发比在真机上盲调快 5 倍。这不是炫技是把不可见的交互问题变成可见的调试数据。3. 核心细节解析与实操要点3.1 目录结构标准化为什么src/必须严格分层Plone 主题的目录混乱是效率杀手。我见过最离谱的案例一个主题包里css/目录下混着main.css手写、bootstrap.cssCDN 下载、custom.less半成品、print.css从未启用js/目录里有jquery.min.jsv1.12、jquery-3.6.0.min.js新版、carousel.jsES6 语法、legacy.jsIE8 兼容补丁。Grunt 无法智能判断哪些该编译、哪些该忽略、哪些该压缩。因此src/目录必须遵循以下铁律src/scss/只放.scss文件且禁止出现import bootstrap.css这类非法语句。所有第三方 CSS 必须转为 SCSS如bootstrap.scss或通过npm install bootstrap后用import ~bootstrap/scss/functions;引入。原因grunt-contrib-sass只识别 SCSS 语法.css文件会被跳过。src/js/只放 ES5 兼容的.js文件避免const、箭头函数每个文件必须是一个 RequireJS 模块格式为define([jquery], function($) { return { init: function() { ... } }; });。禁止出现全局变量var myPlugin {...}。原因grunt-contrib-requirejs打包时会分析define()依赖树自动生成main.js的模块定义如果混入非模块化代码打包后会报Uncaught Error: Mismatched anonymous define()。src/icons/只放.svg文件且每个 SVG 必须是单色、无渐变、无滤镜。命名规则icon-arrow-right.svg、icon-user.svg。禁止出现icon-arrow-right2x.svg或icon-user-blue.svg。原因grunt-webfont工具在生成字体时会把文件名icon-xxx转为 CSS 类名.icon-xxx如果命名不规范生成的类名会乱码或重复。src/templates/放index.html主预览页、components/独立组件 HTML如button.html、card.html所有路径引用必须相对src/templates/例如link relstylesheet href../build/css/main.css。原因BrowserSync 代理时src/templates/是根目录这样预览页才能正确加载构建产物。提示执行grunt init任务需自定义可一键生成标准src/结构并写入.gitignore规则。我把它做成脚本新项目创建后 3 秒内就准备好干净的起点。3.2 SCSS 编译配置如何让 Plone 主题真正“响应式”Plone 5 默认的plone.app.theming使用lessc但 Less 对嵌套和循环支持弱写媒体查询极其痛苦。SCSS 是唯一选择但配置不当会引发灾难。关键参数如下Gruntfile.js中sass: { options: { implementation: require(sass), // 必须用 Dart SassNode Sass 已废弃 sourceMap: true, // 生成 .css.mapChrome DevTools 可直接定位到 .scss 行 outputStyle: compressed, // 生产环境用 compressed开发用 expanded includePaths: [ node_modules/bootstrap/scss, node_modules/font-awesome/scss ] }, dev: { files: { build/css/main.css: src/scss/main.scss } } }重点解释includePathsPlone 主题常需复用 Bootstrap 组件但不能直接import bootstrap/scss/bootstrap;因为grunt-contrib-sass默认只搜索src/scss/下的路径。includePaths把node_modules/加入搜索队列这样src/scss/main.scss里写import bootstrap/scss/functions;就能成功。实测发现如果includePaths里路径写错如少个sSass 编译会静默失败build/css/main.css文件大小为 0 字节但 Grunt 不报错。我的解决方案是加一道校验任务shell: { checkCssSize: { command: if [ ! -s build/css/main.css ]; then echo ERROR: main.css is empty! Check sass includePaths.; exit 1; fi } }然后在watch任务中把sass:dev和shell:checkCssSize串起来tasks: [sass:dev, shell:checkCssSize]。这样只要编译出错终端立刻红字报错而不是等你刷新页面才发现白屏。3.3 RequireJS 打包如何让 Plone 的 JS 模块不打架Plone 5 的 JS 加载机制是双轨制一部分是portal_javascripts里注册的全局脚本如jquery.js另一部分是主题里通过require([jquery], ...)按需加载的模块。Grunt 必须尊重这个规则。grunt-contrib-requirejs的配置核心是paths和shimrequirejs: { compile: { options: { baseUrl: src/js, mainConfigFile: src/js/config.js, // RequireJS 配置文件 paths: { jquery: https://cdn.jsdelivr.net/npm/jquery3.6.0/dist/jquery.min.js, bootstrap: https://cdn.jsdelivr.net/npm/bootstrap5.1.3/dist/js/bootstrap.bundle.min.js }, shim: { bootstrap: { deps: [jquery], exports: $.fn.modal // 确保 bootstrap 加载后$.fn.modal 存在 } }, name: main, // 入口模块名对应 src/js/main.js out: build/js/main.min.js, optimize: uglify2 // 压缩引擎 } } }这里的关键陷阱是paths的 CDN 地址。Plone 默认禁用外链script标签被过滤所以paths里写 CDN 是给 Grunt 打包用的不是给浏览器运行时用的。实际部署时build/js/main.min.js里已经把 jQuery 和 Bootstrap 的代码都合并进去了paths只影响打包过程。而shim的作用是解决非 AMD 模块的依赖问题——Bootstrap 5 的bootstrap.bundle.min.js是 UMD 格式shim告诉 RequireJS“加载 bootstrap 前必须先加载 jquery且加载完成后检查$.fn.modal是否存在否则报错”。实测中如果shim.exports写错如写成$.modal打包会成功但运行时报TypeError: $(...).modal is not a function排查起来极难。我的经验是对每个第三方库先在 Chrome Console 里手动执行define([jquery, bootstrap], function($, bs) { console.log($.fn.modal); });确认返回函数后再写shim.exports。3.4 图标字体生成为什么 SVG 必须“扁平化”grunt-webfont是生成图标字体的主力但它对 SVG 的要求苛刻。我曾用 Sketch 导出的 SVG里面包含g idLayer_1、style.cls-1{fill:#000;}/style、defs等复杂结构结果grunt-webfont生成的字体文件里图标全是方块。根源在于WebFont 工具只识别最简 SVG——单个path或symbol且fill必须是内联属性不能是 CSS 类。解决方案是预处理用grunt-svgmin在生成字体前压缩 SVG。svgmin: { options: { plugins: [ { removeViewBox: false }, // 必须保留 viewBox否则图标尺寸错乱 { removeUselessStrokeAndFill: true }, { convertColors: { currentColor: true } }, // 把 fill#000 转为 fillcurrentColor { cleanupIDs: true } // 清理无用 ID避免字体命名冲突 ] }, dist: { files: [{ expand: true, cwd: src/icons/, src: [*.svg], dest: src/icons-cleaned/, ext: .svg }] } }, webfont: { icons: { src: src/icons-cleaned/*.svg, dest: build/fonts/, options: { font: mytheme-icons, template: src/webfont/template.css, htmlDemo: true, normalize: true, fontHeight: 1001, // 必须设为奇数避免字体基线偏移 round: 10e12 // 高精度坐标防止锯齿 } } }convertColors: { currentColor: true }是灵魂设置。它把所有fill#000转为fillcurrentColor这样生成的 CSS 类.icon-user { color: #007bff; }就能控制图标颜色而不用为每个图标写单独的 fill 规则。fontHeight: 1001是经验值Plone 5 的plone.app.layout默认字体行高是 1.5如果fontHeight设为 1000图标在span classicon-user/span里会上浮 1px和文字不对齐。实测 1001 最稳。4. 实操过程与核心环节实现4.1 初始化5 分钟搭建可运行的 Grunt 环境这不是“npm init → npm install grunt --save-dev”就完事的。Plone 主题的初始化必须一步到位避免后续填坑。以下是我在 12 个项目中验证过的标准流程第一步创建主题骨架cd /path/to/plone/src/mytheme mkdir -p src/{scss,js,icons,images,templates} mkdir -p build/{css,js,fonts,preview} touch Gruntfile.js package.json第二步安装核心依赖注意版本锁定npm init -y npm install --save-dev \ grunt1.5.3 \ grunt-contrib-sass2.0.0 \ grunt-contrib-requirejs1.0.2 \ grunt-contrib-watch1.1.0 \ grunt-browser-sync2.3.0 \ grunt-webfont1.10.0 \ grunt-svgmin5.0.0 \ grunt-shell3.0.1 \ grunt-plone-themeresource0.2.0 \ sass1.58.3注意grunt-plone-themeresource0.2.0是专为 Plone 5.2 适配的版本更高版本会因 Plone 的Products.CMFCoreAPI 变更而报错。第三步编写package.json的scripts{ scripts: { dev: grunt watch, build: grunt build, init: grunt init } }dev是日常开发命令build用于 CI/CD 打包init是初始化任务见下文。第四步定义Gruntfile.js的init任务grunt.registerTask(init, Initialize theme structure, function() { grunt.file.write(src/scss/_variables.scss, $primary: #007bff;\n$font-size-base: 1rem;); grunt.file.write(src/scss/main.scss, import variables;\nbody { font-size: $font-size-base; }); grunt.file.write(src/js/config.js, require.config({ paths: { jquery: //code.jquery.com/jquery-3.6.0.min.js } });); grunt.file.write(src/js/main.js, define([jquery], function($) { console.log(Theme JS loaded); });); grunt.file.write(src/templates/index.html, !DOCTYPE htmlhtmlheadlink relstylesheet href../build/css/main.css/headbodyh1MyTheme Preview/h1script src../build/js/main.min.js/script/body/html); grunt.log.ok(Initialized src/ structure.); });执行npm run init5 秒内生成可运行的最小闭环src/scss/main.scss→build/css/main.css→src/templates/index.html能正确显示。4.2 日常开发从改一行 SCSS 到页面刷新的完整链路这才是加速的核心价值。以修改导航栏背景色为例走一遍真实流程场景客户说“导航栏太灰要改成蓝色”。步骤 1编辑源码# 编辑变量文件 vim src/scss/_variables.scss # 修改为$primary: #0056b3;步骤 2Grunt 自动触发由于watch配置了watch: { scss: { files: [src/scss/**/*.scss], tasks: [sass:dev, shell:checkCssSize, sync:css] } }保存文件瞬间终端输出 File src/scss/_variables.scss changed. Running sass:dev (sass) task Generated build/css/main.css Running shell:checkCssSize (shell) task main.css size OK Running sync:css (sync) task Copied 1 file Completed in 1.8s at Thu Mar 16 2023 10:22:34 GMT0800 (CST) - Waiting...步骤 3BrowserSync 自动刷新BrowserSync 的代理服务器http://localhost:3000检测到build/css/main.css变更向所有连接的浏览器发送 reload 指令。Chrome DevTools 的 Network 面板显示GET http://localhost:3000/build/css/main.css?t16789333542312001.2KBGET http://localhost:3000/200HTML 页面页面在 2.3 秒后刷新导航栏已变为深蓝色。整个过程无需手动操作手指离开键盘眼睛盯着屏幕即可。步骤 4验证 Plone 环境此时build/css/main.css已就绪但 Plone 还没加载。执行# 重新启动 Plone 实例只需一次 bin/instance fg # 或者如果已启用 plone.app.theming 的自动重载 curl -X POST http://admin:adminlocalhost:8080/Plone/theming-controlpanel/reloadPlone 会重新读取build/下的文件resourcemytheme/css/main.css返回新样式。注意reload端点只在开发环境开启生产环境必须走bin/buildout更新。4.3 构建发布如何生成 100% 可部署的build/目录npm run build不是简单地跑一遍grunt build而是一套防错流水线grunt.registerTask(build, [ clean:build, svgmin:dist, webfont:icons, sass:prod, requirejs:compile, copy:static, shell:checkBuildIntegrity ]);clean:build先清空build/避免旧文件残留。svgmin:dist预处理 SVG确保图标质量。webfont:icons生成字体文件.woff,.ttf,.eot和 CSS。sass:prod用outputStyle: compressed编译 CSS体积减少 60%。requirejs:compile打包 JSoptimize: uglify2压缩后体积减少 45%。copy:static把src/images/下的 PNG/JPG 复制到build/images/不压缩保持原画质。shell:checkBuildIntegrity终极校验。shell:checkBuildIntegrity的脚本是灵魂# 检查 build/ 下关键文件是否存在且非空 for f in build/css/main.css build/js/main.min.js build/fonts/mytheme-icons.woff; do if [ ! -s $f ]; then echo FATAL: $f is missing or empty!; exit 1; fi done # 检查 registry.xml 中声明的路径是否在 build/ 下存在 grep -o resourcemytheme/[^]* profiles/default/registry.xml | while read p; do local_path$(echo $p | sed s/resourcemytheme\///) if [ ! -e build/$local_path ]; then echo FATAL: $p declared but build/$local_path not found!; exit 1; fi done echo BUILD INTEGRITY CHECK PASSED.这个脚本确保你提交到 Git 的build/目录和客户服务器上解压出来的目录100% 一致。没有“本地能跑服务器报 404”的尴尬。4.4 BrowserSync 代理配置如何让预览页完美模拟 Plone 环境grunt-browser-sync的默认配置是静态服务器但 Plone 主题需要动态上下文。关键配置browserSync: { dev: { bsFiles: { src: [ build/**/*, src/templates/**/* ] }, options: { proxy: localhost:8080, // 代理到 Plone 实例 port: 3000, open: local, // 自动打开 localhost:3000 notify: false, // 关闭右上角弹窗避免遮挡 Plone UI snippetOptions: { rule: { match: /\/body/i, fn: function (snippet) { return snippet scriptdocument.addEventListener(DOMContentLoaded,function(){if(window.location.pathname/){window.location.href/Plone;}});/script; } } } } } }snippetOptions.rule是神来之笔。它在页面/body前注入一段 JS当访问http://localhost:3000/BrowserSync 根路径时自动跳转到http://localhost:3000/Plone也就是 Plone 的首页。这样你不用记住http://localhost:3000/Plone/theming-controlpanel直接http://localhost:3000就进入 Plone 环境。而且proxy模式下所有http://localhost:3000/Plone/resourcemytheme/请求都会被转发到http://localhost:8080/Plone/resourcemytheme/但build/下的文件优先级更高——也就是说http://localhost:3000/Plone/resourcemytheme/css/main.css返回的是build/css/main.css而http://localhost:3000/Plone/resourceplone/返回的是 Plone 原生资源。这种混合模式让你在真实 Plone 环境里只替换自己主题的文件其他一切照旧。5. 常见问题与排查技巧实录5.1 “CSS 改了但页面没变” —— 缓存链路排查四步法这是最高频问题根源往往不在 Grunt而在多层缓存叠加。按顺序排查步骤检查点操作预期结果1. 浏览器层DevTools → Network → Disable cache 是否勾选勾选后刷新若此时生效说明是浏览器缓存2. BrowserSync 层访问http://localhost:3001→ “Connected devices” → 查看当前连接确认设备在线若离线重启npm run dev3. Plone 层http://localhost:8080/Plone/portal_css/manage_main→ 查看mytheme-css的Last Modified时间对比build/css/main.css修改时间若不一致执行curl -X POST http://admin:adminlocalhost:8080/Plone/theming-controlpanel/reload4. Zope 层bin/instance log查看日志搜索ResourceRegistry启动时是否有Loaded resource mytheme-css若无检查registry.xml是否语法错误实操心得我写了一个cache-bust任务一键清理所有缓存shell: { cacheBust: { command: curl -X POST http://admin:adminlocalhost:8080/Plone/theming-controlpanel/reload 2/dev/null; echo Plone cache reloaded; browser-sync reload; echo BrowserSync reloaded } }执行grunt shell:cacheBust3 秒解决 90% 的“没变化”问题。5.2 “Grunt watch 卡死CPU 占用 100%” —— 文件监听优化watch默认监听所有子目录当node_modules/或.git/过大时会触发海量fs.watch事件。解决方案是精准排除watch: { scss: { files: [src/scss/**/*.scss], tasks: [sass:dev], options: { spawn: false, // 禁用子进程避免 fork 爆炸 event: [added, changed], // 忽略 deleted 事件减少干扰 interrupt: true, // 新事件到来时中断正在执行的任务 debounceDelay: 500 // 防抖连续修改 500ms 内只触发一次 } } }spawn: false是关键。默认spawn: true会为每个任务 fork 一个新 Node 进程10 次保存就起 10 个进程。spawn: false让所有任务在同一个进程中串行执行CPU 占用从 100% 降到 15%。5.3 “图标字体不显示控制台报 Failed to decode downloaded font” —— MIME 类型修复Nginx/Apache 默认不识别.woff文件的 MIME 类型返回text/plain浏览器拒绝加载。Grunt 本身无法修复服务器配置但可以在build/目录下生成.htaccess作为提醒copy: { htaccess: { files: [{ expand: true, cwd: src/config/, src: [.htaccess], dest: build/ }] } }src/config/.htaccess内容AddType application/font-woff .woff AddType application/font-woff2 .woff2 AddType application/vnd.ms-fontobject .eot AddType application/x-font-ttf .ttf部署时把这个.htaccess放到build/目录再让运维同事把它复制到 Web 服务器的对应路径。这是“人肉 CI”但比写文档靠谱 10 倍。5.4 “RequireJS 报错 Uncaught Error: Module name ‘jquery’ has not been loaded yet for context” —— 路径映射陷阱这个错误不是 jQuery 没加载而是paths映射错了。常见错误paths: { jquery: js/jquery.min.js }→ 错js/jquery.min.js是相对baseUrl的路径但baseUrl是src/js所以实际找的是src/js/js/jquery.min.js。正确写法paths: { jquery: //code.jquery.com/jquery-3.6.0.min.js }CDN或 paths: { jquery: ../vendor/jquery.min.js