Skip to content

Commit

Permalink
feat(bundler): support direct css import in js file
Browse files Browse the repository at this point in the history
Support `import './foo.css';` directly in JavaScript.
When you do `import './foo.css'` in `foo.js`, it behaves same as `<require from="./foo.css"></require>` in `foo.html`.
  • Loading branch information
3cp committed Jan 13, 2019
1 parent 405cf65 commit 2de02d2
Show file tree
Hide file tree
Showing 9 changed files with 341 additions and 36 deletions.
28 changes: 23 additions & 5 deletions lib/build/bundled-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions lib/build/bundler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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'),
Expand All @@ -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}`);
}
Expand Down
40 changes: 25 additions & 15 deletions lib/build/find-deps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -158,7 +159,7 @@ const auConfigureDepFinder = function(contents) {
});
}

return deps;
return Array.from(deps);
};

const inlineViewExtract = jsDepFinder(
Expand All @@ -170,15 +171,15 @@ 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 [];

// If user accidentally calls inlineView more than once,
// 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
Expand Down Expand Up @@ -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);

Expand All @@ -231,41 +241,41 @@ 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);

let parser = new htmlparser.Parser({
onopentag: function(name, attrs) {
// <require from="dep"></require>
if (name === 'require' && attrs.from) {
add(attrs.from);
add(auDep(attrs.from, loaderType));
// <compose view-model="vm" view="view"></compose>
// <any as-element="compose" view-model="vm" view="view"></any>
} else if (name === 'compose' || attrs['as-element'] === 'compose') {
add([attrs['view-model'], attrs.view]);
add([auDep(attrs['view-model'], loaderType), auDep(attrs.view, loaderType)]);
// <router-view layout-view-model="lvm" layout-view="ly"></router-view>
// <any as-element === 'router-view' layout-view-model="lvm" layout-view="ly"></any>
} 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)]);
}
}
});
Expand All @@ -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 [];
Expand Down
79 changes: 79 additions & 0 deletions lib/build/inject-css.js
Original file line number Diff line number Diff line change
@@ -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 <base> 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;
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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'
};
}
};

49 changes: 49 additions & 0 deletions spec/lib/build/bundled-source.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(\'<template><require from="foo.css"></require></template>\', [\'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(\'<template><require from="foo.css"></require></template>\', [\'bar.css\', \'./a.css\']);});');
});

it('transforms local js file above root level (src/)', () => {
let file = {
path: path.resolve(cwd, '../shared/bar/loo.js'),
Expand Down
Loading

0 comments on commit 2de02d2

Please sign in to comment.