diff --git a/client/nodejs-stubs.js b/client/nodejs-stubs.js index c499ccf..34548b6 100644 --- a/client/nodejs-stubs.js +++ b/client/nodejs-stubs.js @@ -1,3 +1,5 @@ +import '@aurelia/kernel'; + global.HOST_NAMES = require('./host-names'); global.DUMBER_MODULE_LOADER_DIST = 'dumber-module-loader dist content'; global.fetch = require('node-fetch'); diff --git a/client/src-worker/transpiler.js b/client/src-worker/transpiler.js index ca06da0..6828534 100644 --- a/client/src-worker/transpiler.js +++ b/client/src-worker/transpiler.js @@ -1,4 +1,5 @@ import path from 'path'; +import {inject} from 'aurelia-dependency-injection'; import {SvelteTranspiler} from './transpilers/svelte'; import {Au2Transpiler} from './transpilers/au2'; import {AuTsTranspiler} from './transpilers/au-ts'; @@ -7,17 +8,18 @@ import {SassTranspiler} from './transpilers/sass'; import {LessTranspiler} from './transpilers/less'; import {TextTranspiler} from './transpilers/text'; +@inject( + SvelteTranspiler, + Au2Transpiler, + AuTsTranspiler, + JsTranspiler, + SassTranspiler, + LessTranspiler, + TextTranspiler +) export class Transpiler { - constructor() { - this.transpilers = [ - new SvelteTranspiler(), - new Au2Transpiler(), - new AuTsTranspiler(), - new JsTranspiler(), - new SassTranspiler(), - new LessTranspiler(), - new TextTranspiler() - ]; + constructor(...transpilers) { + this.transpilers = transpilers; } findTranspiler(file, files) { diff --git a/client/src-worker/transpilers/sass.js b/client/src-worker/transpilers/sass.js index 2c4f90c..7e312ff 100644 --- a/client/src-worker/transpilers/sass.js +++ b/client/src-worker/transpilers/sass.js @@ -1,18 +1,79 @@ -import path from 'path'; +import {ext, parse, resolveModuleId} from 'dumber-module-loader/dist/id-utils'; import _ from 'lodash'; +import {inject} from 'aurelia-dependency-injection'; +import {CachePrimitives} from '../cache-primitives'; const EXTS = ['.scss', '.sass']; function cleanSource(s) { - const idx = s.indexOf('/sass/'); - if (idx === -1) return s; - return s.slice(idx + 6); + if (s.startsWith('../sass/')) { + return s.slice(8); + } + if (s.startsWith('../')) { + return s.slice(3); + } + return s; } +export function possiblePaths(filePath) { + const parsed = parse(filePath); + const [packagePath, ...others] = parsed.parts; + if (others.length === 0) return []; + + const base = others.pop(); + const dir = _(others).map(o => o + '/').join(''); + + if (EXTS.indexOf(parsed.ext) !== -1 || parsed.ext === '.css') { + return { + packagePath, + filePaths: [ + dir + base, + dir + base + '/_index.scss', + dir + base + '/_index.sass' + ] + }; + } + + return { + packagePath, + filePaths: [ + dir + base + '.scss', + dir + base + '.sass', + dir + base + '.css', + dir + '_' + base + '.scss', + dir + '_' + base + '.sass', + dir + base + '/_index.scss', + dir + base + '/_index.sass' + ] + }; +} + +@inject(CachePrimitives) export class SassTranspiler { + constructor(primitives) { + this.primitives = primitives; + } + match(file) { - const ext = path.extname(file.filename); - return EXTS.indexOf(ext) !== -1; + const e = ext(file.filename); + return EXTS.indexOf(e) !== -1; + } + + async fetchRemoteFile(path) { + const possible = possiblePaths(path) + + const packageJson = JSON.parse( + (await this.primitives.getJsdelivrFile(possible.packagePath, 'package.json')).contents + ); + + const packagePathWithVersion = possible.packagePath + '@' + packageJson.version; + + for (const filePath of possible.filePaths) { + if (await this.primitives.doesJsdelivrFileExist(packagePathWithVersion, filePath)) { + return this.primitives.getJsdelivrFile(packagePathWithVersion, filePath); + } + } + throw new Error('No remote file found for ' + path); } _lazyLoad() { @@ -21,7 +82,41 @@ export class SassTranspiler { // https://github.com/sass/dart-sass/issues/25 // So I have to use sass.js (emscripted libsass) as it // provided a fake fs layer. - this._promise = import('sass.js/dist/sass.sync'); + this._promise = import('sass.js/dist/sass.sync').then(Sass => { + // Add custom importer to handle import from npm packages. + Sass.importer((request, done) => { + if ( + request.path || + request.current.startsWith('.') || + request.current.match(/^https?:\/\//) + ) { + // Sass.js already found a file, + // or it's definitely not a remote file, + // or it's a full url, + // let Sass.js to do its job. + done(); + } else { + let remotePath = request.current; + if (request.previous.startsWith('/node_modules/')) { + remotePath = resolveModuleId(request.previous.slice(14), './' + request.current); + } + + this.fetchRemoteFile(remotePath).then( + ({path, contents}) => { + done({ + path: '/node_modules/' + path.slice(23), + content: contents + }); + }, + err => { + done({error: err.message}); + } + ); + } + }); + + return Sass; + }); } return this._promise; @@ -30,32 +125,37 @@ export class SassTranspiler { async transpile(file, files) { const {filename} = file; if (!this.match(file)) throw new Error('Cannot use SassTranspiler for file: ' + filename); - if (path.basename(filename).startsWith('_')) { + + const parsed = parse(filename); + if (_.last(parsed.parts).startsWith('_')) { // ignore sass partial return; } const Sass = await this._lazyLoad(); - const ext = path.extname(filename); - const cssFiles = {}; _.each(files, f => { - const ext = path.extname(f.filename); - if (EXTS.indexOf(ext) !== -1 || ext === '.css') { + const e = ext(f.filename); + if (EXTS.indexOf(e) !== -1 || e === '.css') { cssFiles[f.filename] = f.content; } }); - const newFilename = filename.slice(0, -ext.length) + '.css'; + const newFilename = filename.slice(0, -parsed.ext.length) + '.css'; if (file.content.match(/^\s*$/)) { return {filename: newFilename, content: ''}; } return new Promise((resolve, reject) => { Sass.writeFile(cssFiles, () => { - Sass.compileFile( - filename, + Sass.compile( + file.content, + { + indentedSyntax: parsed.ext === '.sass', + sourceMapRoot: '/', + inputPath: '/sass/' + filename + }, result => { Sass.removeFile(Object.keys(cssFiles), () => { if (result.status === 0) { diff --git a/client/test-worker/transpiler.spec.js b/client/test-worker/transpiler.spec.js index 0d2df68..31bfd8e 100644 --- a/client/test-worker/transpiler.spec.js +++ b/client/test-worker/transpiler.spec.js @@ -1,5 +1,18 @@ import test from 'tape'; +import {Container} from 'aurelia-dependency-injection'; import {Transpiler} from '../src-worker/transpiler'; +import {CachePrimitives} from '../src-worker/cache-primitives'; + +const container = new Container(); +container.registerInstance(CachePrimitives, { + async getJsdelivrFile() { + throw new Error('should not get here'); + }, + async doesJsdelivrFileExist() { + return false; + } +}); +const jt = container.get(Transpiler); const p = { filename: 'package.json', @@ -7,7 +20,6 @@ const p = { }; test('Transpiler transpiles au1 ts file', async t => { - const jt = new Transpiler(); const code = `import {autoinject, bindable} from 'aurelia-framework'; @autoinject export class Foo { @@ -31,7 +43,6 @@ export class Foo { }); test('Transpiler transpiles jsx file in inferno way with fragment', async t => { - const jt = new Transpiler(); const code = `const descriptions = items.map(item => ( <>
{item.name}
@@ -54,7 +65,6 @@ test('Transpiler transpiles jsx file in inferno way with fragment', async t => { }); test('Transpiler transpile scss file', async t => { - const jt = new Transpiler(); const code = '.a { .b { color: red; } }'; const f = { filename: 'src/foo.scss', @@ -71,7 +81,6 @@ test('Transpiler transpile scss file', async t => { }); test('Transpiler transpiles supported text file', async t => { - const jt = new Transpiler(); const code = 'lorem'; const file = await jt.transpile({ filename: 'src/foo/bar.html', @@ -85,7 +94,6 @@ test('Transpiler transpiles supported text file', async t => { }); test('Transpiler cannot transpile binary file', async t => { - const jt = new Transpiler(); t.equal(await jt.transpile({ filename: 'src/foo.jpg', content: '' @@ -93,7 +101,6 @@ test('Transpiler cannot transpile binary file', async t => { }); test('Transpiler transpiles au2 file', async t => { - const jt = new Transpiler(); const code = `export class Foo { public name: string; } @@ -118,7 +125,6 @@ test('Transpiler transpiles au2 file', async t => { }); test('eTranspiler transpiles svelte file with scss', async t => { - const jt = new Transpiler(); const code = ` diff --git a/client/test-worker/transpilers/sass.spec.js b/client/test-worker/transpilers/sass.spec.js index 778f0a4..92aa565 100644 --- a/client/test-worker/transpilers/sass.spec.js +++ b/client/test-worker/transpilers/sass.spec.js @@ -1,8 +1,76 @@ import test from 'tape'; -import {SassTranspiler} from '../../src-worker/transpilers/sass'; +import {SassTranspiler, possiblePaths} from '../../src-worker/transpilers/sass'; +import {JSDELIVR_PREFIX} from '../../src-worker/cache-primitives'; + +const primitives = { + async getJsdelivrFile(packageWithVersion, filePath) { + if (packageWithVersion === '@scope/foo@1.0.0') { + if (filePath === 'dist/_button.scss') { + return { + path: `${JSDELIVR_PREFIX}@scope/foo/dist/_button.scss`, + contents: '@import "mixin";' + }; + } else if (filePath === 'dist/_mixin.scss') { + return { + path: `${JSDELIVR_PREFIX}@scope/foo/dist/_mixin.scss`, + contents: '$green: #34C371;' + }; + } + } else if (packageWithVersion === '@scope/foo') { + if (filePath === 'package.json') { + return { + path: `${JSDELIVR_PREFIX}@scope/foo/package.json`, + contents: '{"name":"@scope/foo","version":"1.0.0"}' + }; + } + } + }, + async doesJsdelivrFileExist(packageWithVersion, filePath) { + if (packageWithVersion === '@scope/foo@1.0.0') { + return filePath === 'dist/_button.scss' || + filePath === 'dist/_mixin.scss'; + } + }, +}; + +test('possiblePaths returns nothing for non-remote path', t => { + t.deepEqual( + possiblePaths('foo'), + [] + ); + t.end(); +}); + +test('possiblePaths returns index paths', t => { + t.deepEqual( + possiblePaths('foo/bar.scss'), + [ + {packagePath: 'foo', filePath: 'bar.scss'}, + {packagePath: 'foo', filePath: 'bar.scss/_index.scss'}, + {packagePath: 'foo', filePath: 'bar.scss/_index.sass'} + ] + ); + t.end(); +}); + +test('possiblePaths returns index paths and partial paths', t => { + t.deepEqual( + possiblePaths('@scope/foo/dist/bar'), + [ + {packagePath: '@scope/foo', filePath: 'dist/bar.scss'}, + {packagePath: '@scope/foo', filePath: 'dist/bar.sass'}, + {packagePath: '@scope/foo', filePath: 'dist/bar.css'}, + {packagePath: '@scope/foo', filePath: 'dist/_bar.scss'}, + {packagePath: '@scope/foo', filePath: 'dist/_bar.sass'}, + {packagePath: '@scope/foo', filePath: 'dist/bar/_index.scss'}, + {packagePath: '@scope/foo', filePath: 'dist/bar/_index.sass'} + ] + ); + t.end(); +}); test('SassTranspiler matches sass/scss files', t => { - const jt = new SassTranspiler(); + const jt = new SassTranspiler(primitives); t.ok(jt.match({filename: 'src/foo.sass', content: ''})); t.ok(jt.match({filename: 'src/foo.scss', content: ''})); t.end(); @@ -17,7 +85,7 @@ test('SassTranspiler does not match other files', t => { }); test('SassTranspiler transpile scss file', async t => { - const jt = new SassTranspiler(); + const jt = new SassTranspiler(primitives); const code = '.a { .b { color: red; } }'; const f = { filename: 'src/foo.scss', @@ -33,7 +101,7 @@ test('SassTranspiler transpile scss file', async t => { }); test('SassTranspiler transpile empty scss file', async t => { - const jt = new SassTranspiler(); + const jt = new SassTranspiler(primitives); const code = '\n\t\n'; const f = { filename: 'src/foo.scss', @@ -47,7 +115,7 @@ test('SassTranspiler transpile empty scss file', async t => { }); test('SassTranspiler reject broken scss file', async t => { - const jt = new SassTranspiler(); + const jt = new SassTranspiler(primitives); const code = '.a {'; const f = { filename: 'src/foo.scss', @@ -62,7 +130,7 @@ test('SassTranspiler reject broken scss file', async t => { }); test('SassTranspiler cannot tranpile other file', async t => { - const jt = new SassTranspiler(); + const jt = new SassTranspiler(primitives); try { await jt.transpile({ filename: 'src/foo.js', @@ -75,7 +143,7 @@ test('SassTranspiler cannot tranpile other file', async t => { }); test('SassTranspiler ignore scss partial', async t => { - const jt = new SassTranspiler(); + const jt = new SassTranspiler(primitives); const code = '.a { .b { color: red; } }'; const f = { filename: 'src/_foo.scss', @@ -86,7 +154,7 @@ test('SassTranspiler ignore scss partial', async t => { }); test('SassTranspiler transpile scss file with partial import', async t => { - const jt = new SassTranspiler(); + const jt = new SassTranspiler(primitives); const foo = '@import "variables";\n.a { .b { color: $red; } }'; const variables = '$red: #f00;'; const f = { @@ -108,7 +176,7 @@ test('SassTranspiler transpile scss file with partial import', async t => { }); test('SassTranspiler transpile sass file with import', async t => { - const jt = new SassTranspiler(); + const jt = new SassTranspiler(primitives); const foo = `@import "bar" .a .b @@ -134,4 +202,49 @@ test('SassTranspiler transpile sass file with import', async t => { t.deepEqual(file.sourceMap.sources, ['src/foo.sass', 'src/bar.sass']); // Somehow sass.js sources content is scss format, not sass format. // t.deepEqual(file.sourceMap.sourcesContent, [foo, bar]); -}); \ No newline at end of file +}); + +test('SassTranspiler rejects missing import', async t => { + const jt = new SassTranspiler(primitives); + const foo = `@import "bar" +.a + .b + color: red +`; + const f = { + filename: 'src/foo.sass', + content: foo + }; + try { + await jt.transpile(f, [f]); + t.fail('should not pass'); + } catch (e) { + t.pass(e.message); + } +}); + +test('SassTranspiler transpile sass file with remote import', async t => { + const jt = new SassTranspiler(primitives); + const foo = `@import "@scope/foo/dist/button" +.a + .b + color: $green; +`; + const f = { + filename: 'src/foo.sass', + content: foo + }; + const file = await jt.transpile(f, [f]); + + t.equal(file.filename, 'src/foo.css'); + t.ok(file.content.includes('.a .b')); + t.ok(file.content.includes('#34C371')); + t.equal(file.sourceMap.file, 'src/foo.css'); + t.deepEqual(file.sourceMap.sources, [ + 'src/foo.sass', + 'node_modules/@scope/foo/dist/_button.scss', + 'node_modules/@scope/foo/dist/_mixin.scss', + ]); + // Somehow sass.js sources content is scss format, not sass format. + // t.deepEqual(file.sourceMap.sourcesContent, [foo, bar]); +});