diff --git a/lib/build/bundled-source.js b/lib/build/bundled-source.js index ecdacd303..fe9d89f47 100644 --- a/lib/build/bundled-source.js +++ b/lib/build/bundled-source.js @@ -122,14 +122,14 @@ exports.BundledSource = class { if (path.extname(modulePath).toLowerCase() === '.json') { // support text! prefix - let contents = `define(\'${Utils.moduleIdWithPlugin(moduleId, 'text', loaderType)}\',[],function(){return ${JSON.stringify(this.contents)};});\n`; + let contents = `define('${Utils.moduleIdWithPlugin(moduleId, 'text', loaderType)}',[],function(){return ${JSON.stringify(this.contents)};});\n`; // support Node.js's json module - contents += `define(\'${moduleId}\',[\'${Utils.moduleIdWithPlugin(moduleId, 'text', loaderType)}\'],function(m){return JSON.parse(m);});\n`; + contents += `define('${moduleId}',['${Utils.moduleIdWithPlugin(moduleId, 'text', loaderType)}'],function(m){return JSON.parse(m);});\n`; // be nice to requirejs json plugin users, add json! prefix - contents += `define(\'${Utils.moduleIdWithPlugin(moduleId, 'json', loaderType)}\',[\'${moduleId}\'],function(m){return m;});\n`; + contents += `define('${Utils.moduleIdWithPlugin(moduleId, 'json', loaderType)}',['${moduleId}'],function(m){return m;});\n`; this.contents = contents; } else if (matchingPlugin) { - deps = findDeps(modulePath, this.contents); + deps = findDeps(modulePath, this.contents, loaderType); this.contents = matchingPlugin.transform(moduleId, modulePath, this.contents); } else { deps = []; @@ -234,10 +234,28 @@ exports.BundledSource = class { const writeTransform = allWriteTransforms(opts); contents = writeTransform(context, moduleId, modulePath, contents); - const tracedDeps = findDeps(modulePath, contents); + const tracedDeps = findDeps(modulePath, contents, loaderType); if (tracedDeps && tracedDeps.length) { deps.push.apply(deps, tracedDeps); } + if (deps) { + let needsCssInjection = false; + + (new Set(deps)).forEach(dep => { + // ignore module with plugin prefix/subfix + if (dep.indexOf('!') !== -1) return; + // only check css file + if (path.extname(dep).toLowerCase() !== '.css') return; + + needsCssInjection = true; + dep = absoluteModuleId(moduleId, dep); + // inject css to document head + contents += `\ndefine('${dep}',['__inject_css__','${Utils.moduleIdWithPlugin(dep, 'text', loaderType)}'],function(i,c){i(c,'_au_css:${dep}');});\n`; + }); + + if (needsCssInjection) deps.push('__inject_css__'); + } + this.contents = contents; // write cache diff --git a/lib/build/bundler.js b/lib/build/bundler.js index c55e30678..d4e63af05 100644 --- a/lib/build/bundler.js +++ b/lib/build/bundler.js @@ -8,7 +8,7 @@ const path = require('path'); const fs = require('../file-system'); const Utils = require('./utils'); const logger = require('aurelia-logging').getLogger('Bundler'); -const stubCoreNodejsModule = require('./stub-core-nodejs-module'); +const stubModule = require('./stub-module'); exports.Bundler = class { constructor(project, packageAnalyzer, packageInstaller) { @@ -284,7 +284,7 @@ exports.Bundler = class { return depInclusion.traceMain(); } - let stub = stubCoreNodejsModule(nodeId, this.project.paths.root); + let stub = stubModule(nodeId, this.project.paths.root); if (typeof stub === 'string') { this.addFile({ path: path.resolve(this.project.paths.root, nodeId + '.js'), @@ -300,7 +300,7 @@ exports.Bundler = class { } if (stub) { - logger.info(`Auto stubbing core Node.js module: ${nodeId}`); + logger.info(`Auto stubbing module: ${nodeId}`); } else { logger.info(`Auto tracing ${description.banner}`); } diff --git a/lib/build/find-deps.js b/lib/build/find-deps.js index 3297a84a5..962b99062 100644 --- a/lib/build/find-deps.js +++ b/lib/build/find-deps.js @@ -7,6 +7,7 @@ const astMatcher = am.astMatcher; const htmlparser = require('htmlparser2'); const path = require('path'); const fs = require('../file-system'); +const Utils = require('./utils'); const amdNamedDefine = jsDepFinder( 'define(__dep, __any)', @@ -158,7 +159,7 @@ const auConfigureDepFinder = function(contents) { }); } - return deps; + return Array.from(deps); }; const inlineViewExtract = jsDepFinder( @@ -170,7 +171,7 @@ const inlineViewExtract = jsDepFinder( '__any.inlineView(__dep, __any)' ); -const auInlineViewDepsFinder = function(contents) { +const auInlineViewDepsFinder = function(contents, loaderType) { let match = inlineViewExtract(contents); if (match.length === 0) return []; @@ -178,7 +179,7 @@ const auInlineViewDepsFinder = function(contents) { // aurelia renders first inlineView without any complain. // But this assumes there is only one custom element // class implementation in current js file. - return exports.findHtmlDeps('', match[0]); + return exports.findHtmlDeps('', match[0], loaderType); }; // helper to add deps to a set @@ -215,7 +216,16 @@ function isPackageName(id) { return parts.length === 1 || (parts.length === 2 && parts[0].startsWith('@')); } -exports.findJsDeps = function(filename, contents) { +function auDep(dep, loaderType) { + if (!dep) return dep; + let ext = path.extname(dep).toLowerCase(); + if (ext === '.html' || ext === '.css') { + return Utils.moduleIdWithPlugin(dep, 'text', loaderType); + } + return dep; +} + +exports.findJsDeps = function(filename, contents, loaderType = 'require') { let deps = new Set(); let add = _add.bind(deps); @@ -231,25 +241,25 @@ exports.findJsDeps = function(filename, contents) { amdNamedDefine(parsed).forEach(d => deps.delete(d)); // aurelia dependencies PLATFORM.moduleName and some others - add(auJsDepFinder(parsed)); + add(auJsDepFinder(parsed).map(d => auDep(d, loaderType))); // aurelia deps in configure func without PLATFORM.moduleName - add(auConfigureDepFinder(parsed)); + add(auConfigureDepFinder(parsed).map(d => auDep(d, loaderType))); // aurelia deps in inlineView template - add(auInlineViewDepsFinder(parsed)); + add(auInlineViewDepsFinder(parsed, loaderType)); // aurelia view convention, try foo.html for every foo.js let fileParts = path.parse(filename); let htmlPair = fileParts.name + '.html'; if (fs.existsSync(fileParts.dir + path.sep + htmlPair)) { - add('./' + htmlPair); + add('text!./' + htmlPair); } return Array.from(deps); }; -exports.findHtmlDeps = function(filename, contents) { +exports.findHtmlDeps = function(filename, contents, loaderType = 'require') { let deps = new Set(); let add = _add.bind(deps); @@ -257,15 +267,15 @@ exports.findHtmlDeps = function(filename, contents) { onopentag: function(name, attrs) { // if (name === 'require' && attrs.from) { - add(attrs.from); + add(auDep(attrs.from, loaderType)); // // } else if (name === 'compose' || attrs['as-element'] === 'compose') { - add([attrs['view-model'], attrs.view]); + add([auDep(attrs['view-model'], loaderType), auDep(attrs.view, loaderType)]); // // } else if (name === 'router-view' || attrs['as-element'] === 'router-view') { - add([attrs['layout-view-model'], attrs['layout-view']]); + add([auDep(attrs['layout-view-model'], loaderType), auDep(attrs['layout-view'], loaderType)]); } } }); @@ -275,13 +285,13 @@ exports.findHtmlDeps = function(filename, contents) { return Array.from(deps); }; -exports.findDeps = function(filename, contents) { +exports.findDeps = function(filename, contents, loaderType = 'require') { let ext = path.extname(filename).toLowerCase(); if (ext === '.js') { - return exports.findJsDeps(filename, contents); + return exports.findJsDeps(filename, contents, loaderType); } else if (ext === '.html' || ext === '.htm') { - return exports.findHtmlDeps(filename, contents); + return exports.findHtmlDeps(filename, contents, loaderType); } return []; diff --git a/lib/build/inject-css.js b/lib/build/inject-css.js new file mode 100644 index 000000000..d3aa1462e --- /dev/null +++ b/lib/build/inject-css.js @@ -0,0 +1,79 @@ +'use strict'; +let cssUrlMatcher = /url\s*\(\s*(?!['"]data)([^) ]+)\s*\)/gi; + +// copied from aurelia-templating-resources css-resource +// This behaves differently from webpack's style-loader. +// Here we change './hello.png' to 'foo/hello.png' if base address is 'foo/bar'. +// Note 'foo/hello.png' is technically a relative path in css, +// this is designed to work with aurelia-router setup. +// We inject css into a style tag on html head, it means the 'foo/hello.png' +// is related to current url (not css url on link tag), or tag in html +// head (which is recommended setup of router if not using hash). +function fixupCSSUrls(address, css) { + if (typeof css !== 'string') { + throw new Error(`Failed loading required CSS file: ${address}`); + } + return css.replace(cssUrlMatcher, (match, p1) => { + let quote = p1.charAt(0); + if (quote === '\'' || quote === '"') { + p1 = p1.substr(1, p1.length - 2); + } + const absolutePath = absoluteModuleId(address, p1); + if (absolutePath === p1) { + return match; + } + return 'url(\'' + absolutePath + '\')'; + }); +} + +function absoluteModuleId(baseId, moduleId) { + if (moduleId[0] !== '.') return moduleId; + + let parts = baseId.split('/'); + parts.pop(); + + moduleId.split('/').forEach(p => { + if (p === '.') return; + if (p === '..') { + parts.pop(); + return; + } + parts.push(p); + }); + + return parts.join('/'); +} + +// copied from aurelia-pal-browser DOM.injectStyles +function injectCSS(css, id) { + if (typeof document === 'undefined' || !css) return; + css = fixupCSSUrls(id, css); + + if (id) { + let oldStyle = document.getElementById(id); + if (oldStyle) { + let isStyleTag = oldStyle.tagName.toLowerCase() === 'style'; + + if (isStyleTag) { + oldStyle.innerHTML = css; + return; + } + + throw new Error('The provided id does not indicate a style tag.'); + } + } + + let node = document.createElement('style'); + node.innerHTML = css; + node.type = 'text/css'; + + if (id) { + node.id = id; + } + + document.head.appendChild(node); +} + +injectCSS.fixupCSSUrls = fixupCSSUrls; + +module.exports = injectCSS; diff --git a/lib/build/stub-core-nodejs-module.js b/lib/build/stub-module.js similarity index 92% rename from lib/build/stub-core-nodejs-module.js rename to lib/build/stub-module.js index ff7f544c3..6d46dcdf6 100644 --- a/lib/build/stub-core-nodejs-module.js +++ b/lib/build/stub-module.js @@ -14,7 +14,7 @@ const logger = require('aurelia-logging').getLogger('StubNodejs'); // process // string_decoder // url -// util (note: got small problem on ./support/isBuffer, read util package.json browser field) +// util // fail on following core modules has no stub const UNAVAIABLE_CORE_MODULES = [ @@ -75,5 +75,13 @@ module.exports = function(moduleId, root) { if (moduleId === '__ignore__') { return EMPTY_MODULE; } + + if (moduleId === '__inject_css__') { + return { + name: '__inject_css__', + path: resolvePath('aurelia-cli', root), + main: 'lib/build/inject-css' + }; + } }; diff --git a/spec/lib/build/bundled-source.spec.js b/spec/lib/build/bundled-source.spec.js index d428504bb..611a77177 100644 --- a/spec/lib/build/bundled-source.spec.js +++ b/spec/lib/build/bundled-source.spec.js @@ -126,6 +126,55 @@ describe('the BundledSource module', () => { expect(bundler.configTargetBundle.addAlias).toHaveBeenCalledWith('b8/loo', 'foo/bar/loo'); }); + it('transforms local js file with css injection', () => { + let file = { + path: path.resolve(cwd, 'src/foo.js'), + contents: "define(['./foo.css', 'bar'], function(c,b){});" + }; + + let bs = new BundledSource(bundler, file); + bs._getProjectRoot = () => 'src'; + bs._getLoaderPlugins = () => []; + bs._getLoaderConfig = () => ({ + paths: { + root: 'src', + resources: 'resources' + } + }); + bs._getUseCache = () => undefined; + + let deps = bs.transform(); + expect(deps).toEqual(['bar', '__inject_css__']); // relative dep is ignored + expect(bs.requiresTransform).toBe(false); + expect(bs.contents).toBe(`define('foo',['./foo.css', 'bar'], function(c,b){}); +define('foo.css',['__inject_css__','text!foo.css'],function(i,c){i(c,'_au_css:foo.css');}); +`); + }); + + it('transforms local js file with inlineView with css dep', () => { + let file = { + path: path.resolve(cwd, 'src/foo.js'), + contents: 'define([\'au\'], function(au){au.inlineView(\'\', [\'bar.css\', \'./a.css\']);});' + }; + + let bs = new BundledSource(bundler, file); + bs._getProjectRoot = () => 'src'; + bs._getLoaderPlugins = () => []; + bs._getLoaderConfig = () => ({ + paths: { + root: 'src', + resources: 'resources' + } + }); + bs._getUseCache = () => undefined; + + let deps = bs.transform(); + expect(deps).toEqual(['au', 'bar.css', 'foo.css']); // relative dep is ignored, text! prefix is stripped + expect(bs.requiresTransform).toBe(false); + // doesn't call DOM.injectStyles for inlineView css + expect(bs.contents).toBe('define(\'foo\',[\'au\'], function(au){au.inlineView(\'\', [\'bar.css\', \'./a.css\']);});'); + }); + it('transforms local js file above root level (src/)', () => { let file = { path: path.resolve(cwd, '../shared/bar/loo.js'), diff --git a/spec/lib/build/find-deps.spec.js b/spec/lib/build/find-deps.spec.js index 083380a95..8c1c4e9db 100644 --- a/spec/lib/build/find-deps.spec.js +++ b/spec/lib/build/find-deps.spec.js @@ -43,7 +43,7 @@ let html = ` `; -let htmlDeps = ['./c.html', 'a/b', 'd/e.css', 'lv1', 'lv2', 'lvm2', 'v2', 'vm1', 'vm2']; +let htmlDeps = ['a/b', 'lv1', 'lv2', 'lvm2', 'text!./c.html', 'text!d/e.css', 'v2', 'vm1', 'vm2']; let css = ` @import 'other.css'; @@ -229,7 +229,7 @@ describe('find-deps', () => { _classCallCheck(this, MyComp); }) || _class); `; - expect(findJsDeps('my-comp.js', file).sort()).toEqual(['./b.css', 'a.css']); + expect(findJsDeps('my-comp.js', file).sort()).toEqual(['text!./b.css', 'text!a.css']); }); it('find deps on useView', () => { @@ -252,7 +252,7 @@ describe('find-deps', () => { _classCallCheck(this, MyComp); }) || _class); `; - expect(findJsDeps('my-comp.js', file)).toEqual(['./a.html']); + expect(findJsDeps('my-comp.js', file, 'system')).toEqual(['./a.html!text']); }); it('find deps in inlineView html', () => { @@ -274,7 +274,7 @@ describe('find-deps', () => { _classCallCheck(this, MyComp); }) || _class); `; - expect(findJsDeps('my-comp.js', file)).toEqual(['./a.css']); + expect(findJsDeps('my-comp.js', file)).toEqual(['text!./a.css']); }); it('find deps in inlineView html for TypeScript compiled code', () => { @@ -303,7 +303,7 @@ var MyComp = (function () { })(); exports.MyComp = MyComp; `; - expect(findJsDeps('my-comp.js', file)).toEqual(['./a.css']); + expect(findJsDeps('my-comp.js', file)).toEqual(['text!./a.css']); }); it('find deps in inlineView html, and additional deps', () => { @@ -327,15 +327,15 @@ exports.MyComp = MyComp; _classCallCheck(this, MyComp); }) || _class); `; - expect(findJsDeps('my-comp.js', file).sort()).toEqual(['./a.css', './b.css', './c.css']); + expect(findJsDeps('my-comp.js', file).sort()).toEqual(['text!./a.css', 'text!./b.css', 'text!./c.css']); }); it('find html file by aurelia view convention', () => { const fsConfig = {}; fsConfig['src/foo.html'] = 'contents'; mockfs(fsConfig); - expect(findJsDeps('src/foo.js', 'a();')).toEqual(['./foo.html']); - expect(findDeps('src/foo.js', 'a();')).toEqual(['./foo.html']); + expect(findJsDeps('src/foo.js', 'a();')).toEqual(['text!./foo.html']); + expect(findDeps('src/foo.js', 'a();')).toEqual(['text!./foo.html']); }); it('remove inner defined modules', () => { diff --git a/spec/lib/build/inject-css.spec.js b/spec/lib/build/inject-css.spec.js new file mode 100644 index 000000000..72b6bc2f8 --- /dev/null +++ b/spec/lib/build/inject-css.spec.js @@ -0,0 +1,141 @@ +'use strict'; +const fixupCSSUrls = require('../../../lib/build/inject-css').fixupCSSUrls; + +// tests partly copied from +// https://github.com/webpack-contrib/style-loader/blob/master/test/fixUrls.test.js +describe('fixupCSSUrls', () => { + it('throws on null/undefined', () => { + expect(() => fixupCSSUrls('foo/bar', null)).toThrow(); + expect(() => fixupCSSUrls('foo/bar', undefined)).toThrow(); + }); + + it('Blank css is not modified', () => { + const css = ''; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it('No url is not modified', () => { + const css = 'body { }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Full url isn't changed (no quotes)", () => { + const css = 'body { background-image:url ( http://example.com/bg.jpg ); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Full url isn't changed (no quotes, spaces)", () => { + const css = 'body { background-image:url ( http://example.com/bg.jpg ); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Full url isn't changed (double quotes)", () => { + const css = 'body { background-image:url(\"http://example.com/bg.jpg\"); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Full url isn't changed (double quotes, spaces)", () => { + const css = 'body { background-image:url ( \"http://example.com/bg.jpg\" ); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Full url isn't changed (single quotes)", () => { + const css = 'body { background-image:url(\'http://example.com/bg.jpg\'); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Full url isn't changed (single quotes, spaces)", () => { + const css = 'body { background-image:url ( \'http://example.com/bg.jpg\' ); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it('Multiple full urls are not changed', () => { + const css = "body { background-image:url(http://example.com/bg.jpg); }\ndiv.main { background-image:url ( 'https://www.anothersite.com/another.png' ); }"; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Http url isn't changed", function() { + const css = 'body { background-image:url(http://example.com/bg.jpg); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Https url isn't changed", function() { + const css = 'body { background-image:url(https://example.com/bg.jpg); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("HTTPS url isn't changed", function() { + const css = 'body { background-image:url(HTTPS://example.com/bg.jpg); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("File url isn't changed", function() { + const css = 'body { background-image:url(file:///example.com/bg.jpg); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Double slash url isn't changed", function() { + const css = 'body { background-image:url(//example.com/bg.jpg); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Image data uri url isn't changed", function() { + const css = 'body { background-image:url(data:image/png;base64,qsrwABYuwNkimqm3gAAAABJRU5ErkJggg==); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Font data uri url isn't changed", function() { + const css = 'body { background-image:url(data:application/x-font-woff;charset=utf-8;base64,qsrwABYuwNkimqm3gAAAABJRU5ErkJggg); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it('Relative url with dot slash', function() { + const css = 'body { background-image:url(./c/d/bg.jpg); }'; + const expected = "body { background-image:url('foo/c/d/bg.jpg'); }"; + expect(fixupCSSUrls('foo/bar', css)).toBe(expected); + }); + + it('Multiple relative urls', function() { + const css = 'body { background-image:URL ( "./bg.jpg" ); }\ndiv.main { background-image:url(../c/d/bg.jpg); }'; + const expected = "body { background-image:url('foo/bg.jpg'); }\ndiv.main { background-image:url('c/d/bg.jpg'); }"; + expect(fixupCSSUrls('foo/bar', css)).toBe(expected); + }); + + it("url with hash isn't changed", function() { + const css = 'body { background-image:url(#bg.jpg); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it('Empty url should be skipped', function() { + let css = 'body { background-image:url(); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + css = 'body { background-image:url( ); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + css = 'body { background-image:url(\n); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + css = 'body { background-image:url(\'\'); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + css = 'body { background-image:url(\' \'); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + css = 'body { background-image:url(""); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + css = 'body { background-image:url(" "); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Rooted url isn't changed", function() { + let css = 'body { background-image:url(/bg.jpg); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + css = 'body { background-image:url(/a/b/bg.jpg); }'; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + + it("Doesn't break inline SVG", function() { + const css = "body { background-image:url('data:image/svg+xml;charset=utf-8,'); }"; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); + it("Doesn't break inline SVG with HTML comment", function() { + const css = "body { background-image:url('data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%0A%3C!--%20Comment%20--%3E%0A%3Csvg%3E%3C%2Fsvg%3E%0A'); }"; + expect(fixupCSSUrls('foo/bar', css)).toBe(css); + }); +}); diff --git a/spec/lib/build/stub-core-nodejs-module.spec.js b/spec/lib/build/stub-module.spec.js similarity index 51% rename from spec/lib/build/stub-core-nodejs-module.spec.js rename to spec/lib/build/stub-module.spec.js index 4dbcc13c1..96878df99 100644 --- a/spec/lib/build/stub-core-nodejs-module.spec.js +++ b/spec/lib/build/stub-module.spec.js @@ -1,19 +1,19 @@ 'use strict'; -const stubCoreNodejsModule = require('../../../lib/build/stub-core-nodejs-module'); +const stubModule = require('../../../lib/build/stub-module'); describe('StubCoreNodejsModule', () => { it('stubs some core module with subfix -browserify', () => { - expect(stubCoreNodejsModule('os', 'src')).toEqual({ + expect(stubModule('os', 'src')).toEqual({ name: 'os', path: '../node_modules/os-browserify' }); }); it('ignores sys', () => { - expect(stubCoreNodejsModule('sys', 'src')).toBeUndefined(); + expect(stubModule('sys', 'src')).toBeUndefined(); }); it('stubs empty module for some core module', () => { - expect(stubCoreNodejsModule('fs', 'src')).toBe('define(function(){});'); + expect(stubModule('fs', 'src')).toBe('define(function(){});'); }); });