diff --git a/package-lock.json b/package-lock.json index 3a588fe..bae5e68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "sapper", - "version": "0.18.5", + "version": "0.18.7", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2235,7 +2235,7 @@ }, "eslint": { "version": "4.19.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", + "resolved": "http://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", "integrity": "sha512-bT3/1x1EbZB7phzYu7vCr1v3ONuzDtX8WjuM9c0iYxe+cq+pwcKEoQjl7zd3RpC6YOLgnSy3cTN58M2jcoPDIQ==", "dev": true, "requires": { @@ -6505,6 +6505,11 @@ "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", "dev": true }, + "sourcemap-codec": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.1.tgz", + "integrity": "sha512-hX1eNBNuilj8yfFnECh0DzLgwKpBLMIvmhgEhixXNui8lMLBInTI8Kyxt++RwJnMNu7cAUo635L2+N1TxMJCzA==" + }, "spdx-correct": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.0.0.tgz", @@ -6703,6 +6708,11 @@ "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", "dev": true }, + "string-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", + "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=" + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", diff --git a/package.json b/package.json index a14a964..26da3ca 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "html-minifier": "^3.5.16", "shimport": "^0.0.10", "source-map-support": "^0.5.6", + "sourcemap-codec": "^1.4.1", + "string-hash": "^1.1.3", "tslib": "^1.9.1" }, "devDependencies": { diff --git a/src/api/build.ts b/src/api/build.ts index 06ed54a..e6090b3 100644 --- a/src/api/build.ts +++ b/src/api/build.ts @@ -3,15 +3,24 @@ import * as path from 'path'; import mkdirp from 'mkdirp'; import rimraf from 'rimraf'; import { EventEmitter } from 'events'; +import * as codec from 'sourcemap-codec'; +import hash from 'string-hash'; import minify_html from './utils/minify_html'; -import { create_compilers, create_main_manifests, create_routes, create_serviceworker_manifest } from '../core'; +import { create_compilers, create_main_manifests, create_manifest_data, create_serviceworker_manifest } from '../core'; import * as events from './interfaces'; import { copy_shimport } from './utils/copy_shimport'; +import { Dirs, PageComponent } from '../interfaces'; +import { CompileResult } from '../core/create_compilers/interfaces'; -export function build(opts: {}) { +type Opts = { + legacy: boolean; + bundler: string; +}; + +export function build(opts: Opts, dirs: Dirs) { const emitter = new EventEmitter(); - execute(emitter, opts).then( + execute(emitter, opts, dirs).then( () => { emitter.emit('done', {}); // TODO do we need to pass back any info? }, @@ -25,22 +34,14 @@ export function build(opts: {}) { return emitter; } -async function execute(emitter: EventEmitter, { - dest = 'build', - app = 'app', - legacy, - bundler, - webpack = 'webpack', - rollup = 'rollup', - routes = 'routes' -} = {}) { - rimraf.sync(path.join(dest, '**/*')); - mkdirp.sync(`${dest}/client`); - copy_shimport(dest); +async function execute(emitter: EventEmitter, opts: Opts, dirs: Dirs) { + rimraf.sync(path.join(dirs.dest, '**/*')); + mkdirp.sync(`${dirs.dest}/client`); + copy_shimport(dirs.dest); // minify app/template.html // TODO compile this to a function? could be quicker than str.replace(...).replace(...).replace(...) - const template = fs.readFileSync(`${app}/template.html`, 'utf-8'); + const template = fs.readFileSync(`${dirs.app}/template.html`, 'utf-8'); // remove this in a future version if (template.indexOf('%sapper.base%') === -1) { @@ -49,14 +50,14 @@ async function execute(emitter: EventEmitter, { throw error; } - fs.writeFileSync(`${dest}/template.html`, minify_html(template)); + fs.writeFileSync(`${dirs.dest}/template.html`, minify_html(template)); - const route_objects = create_routes(); + const manifest_data = create_manifest_data(); // create app/manifest/client.js and app/manifest/server.js - create_main_manifests({ bundler, routes: route_objects }); + create_main_manifests({ bundler: opts.bundler, manifest_data }); - const { client, server, serviceworker } = create_compilers(bundler, { webpack, rollup }); + const { client, server, serviceworker } = create_compilers(opts.bundler, dirs); const client_result = await client.compile(); emitter.emit('build', { @@ -65,20 +66,11 @@ async function execute(emitter: EventEmitter, { result: client_result }); - const build_info: { - bundler: string; - shimport: string; - assets: Record; - legacy_assets?: Record; - } = { - bundler, - shimport: bundler === 'rollup' && require('shimport/package.json').version, - assets: client_result.assets - }; + const build_info = client_result.to_json(manifest_data, dirs); - if (legacy) { + if (opts.legacy) { process.env.SAPPER_LEGACY_BUILD = 'true'; - const { client } = create_compilers(bundler, { webpack, rollup }); + const { client } = create_compilers(opts.bundler, dirs); const client_result = await client.compile(); @@ -92,7 +84,7 @@ async function execute(emitter: EventEmitter, { delete process.env.SAPPER_LEGACY_BUILD; } - fs.writeFileSync(path.join(dest, 'build.json'), JSON.stringify(build_info)); + fs.writeFileSync(path.join(dirs.dest, 'build.json'), JSON.stringify(build_info)); const server_stats = await server.compile(); emitter.emit('build', { @@ -105,8 +97,8 @@ async function execute(emitter: EventEmitter, { if (serviceworker) { create_serviceworker_manifest({ - routes: route_objects, - client_files: client_result.chunks.map((file: string) => `client/${file}`) + manifest_data, + client_files: client_result.chunks.map(chunk => `client/${chunk.file}`) }); serviceworker_stats = await serviceworker.compile(); diff --git a/src/api/dev.ts b/src/api/dev.ts index 8b3d30f..c996b06 100644 --- a/src/api/dev.ts +++ b/src/api/dev.ts @@ -7,12 +7,14 @@ import mkdirp from 'mkdirp'; import rimraf from 'rimraf'; import { locations } from '../config'; import { EventEmitter } from 'events'; -import { create_routes, create_main_manifests, create_compilers, create_serviceworker_manifest } from '../core'; -import { Compiler, Compilers, CompileResult, CompileError } from '../core/create_compilers'; +import { create_manifest_data, create_main_manifests, create_compilers, create_serviceworker_manifest } from '../core'; +import { Compiler, Compilers } from '../core/create_compilers'; +import { CompileResult, CompileError } from '../core/create_compilers/interfaces'; import Deferred from './utils/Deferred'; import * as events from './interfaces'; import validate_bundler from '../cli/utils/validate_bundler'; import { copy_shimport } from './utils/copy_shimport'; +import { ManifestData } from '../interfaces'; export function dev(opts) { return new Watcher(opts); @@ -127,9 +129,11 @@ class Watcher extends EventEmitter { if (!this.dev_port) this.dev_port = await ports.find(10000); + let manifest_data: ManifestData; + try { - const routes = create_routes(); - create_main_manifests({ bundler: this.bundler, routes, dev_port: this.dev_port }); + manifest_data = create_manifest_data(); + create_main_manifests({ bundler: this.bundler, manifest_data, dev_port: this.dev_port }); } catch (err) { this.emit('fatal', { message: err.message @@ -149,12 +153,11 @@ class Watcher extends EventEmitter { return true; }, () => { - const routes = create_routes(); - create_main_manifests({ bundler: this.bundler, routes, dev_port: this.dev_port }); - try { - const routes = create_routes(); - create_main_manifests({ bundler: this.bundler, routes, dev_port: this.dev_port }); + const new_manifest_data = create_manifest_data(); + create_main_manifests({ bundler: this.bundler, manifest_data, dev_port: this.dev_port }); + + manifest_data = new_manifest_data; } catch (err) { this.emit('error', { message: err.message @@ -173,10 +176,7 @@ class Watcher extends EventEmitter { let deferred = new Deferred(); // TODO watch the configs themselves? - const compilers: Compilers = create_compilers(this.bundler, { - webpack: this.dirs.webpack, - rollup: this.dirs.rollup - }); + const compilers: Compilers = create_compilers(this.bundler, this.dirs); let log = ''; @@ -282,16 +282,17 @@ class Watcher extends EventEmitter { }, handle_result: (result: CompileResult) => { - fs.writeFileSync(path.join(dest, 'build.json'), JSON.stringify({ - bundler: this.bundler, - shimport: this.bundler === 'rollup' && require('shimport/package.json').version, - assets: result.assets - }, null, ' ')); + fs.writeFileSync( + path.join(dest, 'build.json'), - const client_files = result.chunks.map((file: string) => `client/${file}`); + // TODO should be more explicit that to_json has effects + JSON.stringify(result.to_json(manifest_data, this.dirs), null, ' ') + ); + + const client_files = result.chunks.map(chunk => `client/${chunk.file}`); create_serviceworker_manifest({ - routes: create_routes(), + manifest_data, client_files }); diff --git a/src/api/find_page.ts b/src/api/find_page.ts index bf843d0..e986176 100644 --- a/src/api/find_page.ts +++ b/src/api/find_page.ts @@ -1,8 +1,8 @@ import { locations } from '../config'; -import { create_routes } from '../core'; +import { create_manifest_data } from '../core'; export function find_page(pathname: string, cwd = locations.routes()) { - const { pages } = create_routes(cwd); + const { pages } = create_manifest_data(cwd); for (let i = 0; i < pages.length; i += 1) { const page = pages[i]; diff --git a/src/api/interfaces.ts b/src/api/interfaces.ts index 0be020d..1bd8e74 100644 --- a/src/api/interfaces.ts +++ b/src/api/interfaces.ts @@ -42,4 +42,4 @@ export type FailureEvent = { } -export type DoneEvent = {} \ No newline at end of file +export type DoneEvent = {}; \ No newline at end of file diff --git a/src/cli/build.ts b/src/cli/build.ts index d13c00e..362bf99 100644 --- a/src/cli/build.ts +++ b/src/cli/build.ts @@ -14,11 +14,12 @@ export function build(opts: { bundler?: string, legacy?: boolean }) { return new Promise((fulfil, reject) => { try { const emitter = _build({ + legacy: opts.legacy, + bundler + }, { dest: locations.dest(), app: locations.app(), routes: locations.routes(), - legacy: opts.legacy, - bundler, webpack: 'webpack', rollup: 'rollup' }); diff --git a/src/core.ts b/src/core.ts index c88a89d..7d813dc 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,3 +1,3 @@ export * from './core/create_manifests'; -export { default as create_compilers } from './core/create_compilers'; -export { default as create_routes } from './core/create_routes'; \ No newline at end of file +export { default as create_compilers } from './core/create_compilers/index'; +export { default as create_manifest_data } from './core/create_manifest_data'; \ No newline at end of file diff --git a/src/core/create_compilers.ts b/src/core/create_compilers.ts deleted file mode 100644 index 6c91caf..0000000 --- a/src/core/create_compilers.ts +++ /dev/null @@ -1,377 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import colors from 'kleur'; -import pb from 'pretty-bytes'; -import relative from 'require-relative'; -import format_messages from 'webpack-format-messages'; -import { left_pad } from '../utils'; - -let r: any; -let wp: any; - -export class CompileError { - file: string; - message: string; -} - -export class CompileResult { - duration: number; - errors: CompileError[]; - warnings: CompileError[]; - chunks: string[]; - assets: Record; -} - -class RollupResult extends CompileResult { - summary: string; - - constructor(duration: number, compiler: RollupCompiler) { - super(); - - this.duration = duration; - - this.errors = compiler.errors.map(munge_rollup_warning_or_error); - this.warnings = compiler.warnings.map(munge_rollup_warning_or_error); // TODO emit this as they happen - - this.chunks = compiler.chunks.map(chunk => chunk.fileName); - - // TODO populate this properly. We don't have namedcompiler. chunks, as in - // webpack, but we can have a route -> [chunk] map or something - this.assets = {}; - - compiler.chunks.forEach(chunk => { - if (compiler.input in chunk.modules) { - this.assets.main = chunk.fileName; - } - }); - - this.summary = compiler.chunks.map(chunk => { - const size_color = chunk.code.length > 150000 ? colors.bold.red : chunk.code.length > 50000 ? colors.bold.yellow : colors.bold.white; - const size_label = left_pad(pb(chunk.code.length), 10); - - const lines = [size_color(`${size_label} ${chunk.fileName}`)]; - - const deps = Object.keys(chunk.modules) - .map(file => { - return { - file: path.relative(process.cwd(), file), - size: chunk.modules[file].renderedLength - }; - }) - .filter(dep => dep.size > 0) - .sort((a, b) => b.size - a.size); - - const total_unminified = deps.reduce((t, d) => t + d.size, 0); - - deps.forEach((dep, i) => { - const c = i === deps.length - 1 ? '└' : '│'; - let line = ` ${c} ${dep.file}`; - - if (deps.length > 1) { - const p = (100 * dep.size / total_unminified).toFixed(1); - line += ` (${p}%)`; - } - - lines.push(colors.gray(line)); - }); - - return lines.join('\n'); - }).join('\n'); - } - - print() { - const blocks: string[] = this.warnings.map(warning => { - return warning.file - ? `> ${colors.bold(warning.file)}\n${warning.message}` - : `> ${warning.message}`; - }); - - blocks.push(this.summary); - - return blocks.join('\n\n'); - } -} - -class WebpackResult extends CompileResult { - stats: any; - - constructor(stats: any) { - super(); - - this.stats = stats; - - const info = stats.toJson(); - - const messages = format_messages(stats); - - this.errors = messages.errors.map(munge_webpack_warning_or_error); - this.warnings = messages.warnings.map(munge_webpack_warning_or_error); - - this.duration = info.time; - - this.chunks = info.assets.map((chunk: { name: string }) => chunk.name); - this.assets = info.assetsByChunkName; - } - - print() { - return this.stats.toString({ colors: true }); - } -} - -export class RollupCompiler { - _: Promise; - _oninvalid: (filename: string) => void; - _start: number; - input: string; - warnings: any[]; - errors: any[]; - chunks: any[]; // TODO types - - constructor(config: any) { - this._ = this.get_config(path.resolve(config)); - this.input = null; - this.warnings = []; - this.errors = []; - this.chunks = []; - } - - async get_config(input: string) { - const bundle = await r.rollup({ - input, - external: (id: string) => { - return (id[0] !== '.' && !path.isAbsolute(id)) || id.slice(-5, id.length) === '.json'; - } - }); - - const { code } = await bundle.generate({ format: 'cjs' }); - - // temporarily override require - const defaultLoader = require.extensions['.js']; - require.extensions['.js'] = (module: any, filename: string) => { - if (filename === input) { - module._compile(code, filename); - } else { - defaultLoader(module, filename); - } - }; - - const mod: any = require(input); - delete require.cache[input]; - - (mod.plugins || (mod.plugins = [])).push({ - name: 'sapper-internal', - options: (opts: any) => { - this.input = opts.input; - }, - renderChunk: (code: string, chunk: any) => { - if (chunk.isEntry) { - this.chunks.push(chunk); - } - } - }); - - const onwarn = mod.onwarn || ((warning: any, handler: (warning: any) => void) => { - handler(warning); - }); - - mod.onwarn = (warning: any) => { - onwarn(warning, (warning: any) => { - this.warnings.push(warning); - }); - }; - - return mod; - } - - oninvalid(cb: (filename: string) => void) { - this._oninvalid = cb; - } - - async compile(): Promise { - const config = await this._; - - const start = Date.now(); - - try { - const bundle = await r.rollup(config); - await bundle.write(config.output); - - return new RollupResult(Date.now() - start, this); - } catch (err) { - if (err.filename) { - // TODO this is a bit messy. Also, can - // Rollup emit other kinds of error? - err.message = [ - `Failed to build — error in ${err.filename}: ${err.message}`, - err.frame - ].filter(Boolean).join('\n'); - } - - throw err; - } - } - - async watch(cb: (err?: Error, stats?: any) => void) { - const config = await this._; - - const watcher = r.watch(config); - - watcher.on('change', (id: string) => { - this.chunks = []; - this.warnings = []; - this.errors = []; - this._oninvalid(id); - }); - - watcher.on('event', (event: any) => { - switch (event.code) { - case 'FATAL': - // TODO kill the process? - if (event.error.filename) { - // TODO this is a bit messy. Also, can - // Rollup emit other kinds of error? - event.error.message = [ - `Failed to build — error in ${event.error.filename}: ${event.error.message}`, - event.error.frame - ].filter(Boolean).join('\n'); - } - - cb(event.error); - break; - - case 'ERROR': - this.errors.push(event.error); - cb(null, new RollupResult(Date.now() - this._start, this)); - break; - - case 'START': - case 'END': - // TODO is there anything to do with this info? - break; - - case 'BUNDLE_START': - this._start = Date.now(); - break; - - case 'BUNDLE_END': - cb(null, new RollupResult(Date.now() - this._start, this)); - break; - - default: - console.log(`Unexpected event ${event.code}`); - } - }); - } -} - -export class WebpackCompiler { - _: any; - - constructor(config: any) { - this._ = wp(require(path.resolve(config))); - } - - oninvalid(cb: (filename: string) => void) { - this._.hooks.invalid.tap('sapper', cb); - } - - compile(): Promise { - return new Promise((fulfil, reject) => { - this._.run((err: Error, stats: any) => { - if (err) { - reject(err); - process.exit(1); - } - - const result = new WebpackResult(stats); - - if (result.errors.length) { - // TODO print errors - // console.error(stats.toString({ colors: true })); - reject(new Error(`Encountered errors while building app`)); - } - - else { - fulfil(result); - } - }); - }); - } - - watch(cb: (err?: Error, stats?: any) => void) { - this._.watch({}, (err?: Error, stats?: any) => { - cb(err, stats && new WebpackResult(stats)); - }); - } -} - -export type Compiler = RollupCompiler | WebpackCompiler; - -export type Compilers = { - client: Compiler; - server: Compiler; - serviceworker?: Compiler; -} - -export default function create_compilers(bundler: string, { webpack, rollup }: { webpack: string, rollup: string }): Compilers { - if (bundler === 'rollup') { - if (!r) r = relative('rollup', process.cwd()); - - const sw = `${rollup}/service-worker.config.js`; - - return { - client: new RollupCompiler(`${rollup}/client.config.js`), - server: new RollupCompiler(`${rollup}/server.config.js`), - serviceworker: fs.existsSync(sw) && new RollupCompiler(sw) - }; - } - - if (bundler === 'webpack') { - if (!wp) wp = relative('webpack', process.cwd()); - - const sw = `${webpack}/service-worker.config.js`; - - return { - client: new WebpackCompiler(`${webpack}/client.config.js`), - server: new WebpackCompiler(`${webpack}/server.config.js`), - serviceworker: fs.existsSync(sw) && new WebpackCompiler(sw) - }; - } - - // this shouldn't be possible... - throw new Error(`Invalid bundler option '${bundler}'`); -} - -const locPattern = /\((\d+):(\d+)\)$/; - -function munge_webpack_warning_or_error(message: string) { - // TODO this is all a bit rube goldberg... - const lines = message.split('\n'); - - const file = lines.shift() - .replace('', '') // careful — there is a special character at the beginning of this string - .replace('', '') - .replace('./', ''); - - let line = null; - let column = null; - - const match = locPattern.exec(lines[0]); - if (match) { - lines[0] = lines[0].replace(locPattern, ''); - line = +match[1]; - column = +match[2]; - } - - return { - file, - message: lines.join('\n') - }; -} - -function munge_rollup_warning_or_error(warning_or_error: any) { - return { - file: warning_or_error.filename, - message: [warning_or_error.message, warning_or_error.frame].filter(Boolean).join('\n') - }; -} \ No newline at end of file diff --git a/src/core/create_compilers/RollupCompiler.ts b/src/core/create_compilers/RollupCompiler.ts new file mode 100644 index 0000000..2a01cd0 --- /dev/null +++ b/src/core/create_compilers/RollupCompiler.ts @@ -0,0 +1,160 @@ +import * as path from 'path'; +import relative from 'require-relative'; +import { CompileResult } from './interfaces'; +import RollupResult from './RollupResult'; + +let rollup: any; + +export default class RollupCompiler { + _: Promise; + _oninvalid: (filename: string) => void; + _start: number; + input: string; + warnings: any[]; + errors: any[]; + chunks: any[]; + css_files: Array<{ id: string, code: string }>; + + constructor(config: string) { + this._ = this.get_config(path.resolve(config)); + this.input = null; + this.warnings = []; + this.errors = []; + this.chunks = []; + this.css_files = []; + } + + async get_config(input: string) { + if (!rollup) rollup = relative('rollup', process.cwd()); + + const bundle = await rollup.rollup({ + input, + external: (id: string) => { + return (id[0] !== '.' && !path.isAbsolute(id)) || id.slice(-5, id.length) === '.json'; + } + }); + + const { code } = await bundle.generate({ format: 'cjs' }); + + // temporarily override require + const defaultLoader = require.extensions['.js']; + require.extensions['.js'] = (module: any, filename: string) => { + if (filename === input) { + module._compile(code, filename); + } else { + defaultLoader(module, filename); + } + }; + + const mod: any = require(input); + delete require.cache[input]; + + (mod.plugins || (mod.plugins = [])).push({ + name: 'sapper-internal', + options: (opts: any) => { + this.input = opts.input; + }, + renderChunk: (code: string, chunk: any) => { + this.chunks.push(chunk); + }, + transform: (code: string, id: string) => { + if (/\.css$/.test(id)) { + this.css_files.push({ id, code }); + return ``; + } + } + }); + + const onwarn = mod.onwarn || ((warning: any, handler: (warning: any) => void) => { + handler(warning); + }); + + mod.onwarn = (warning: any) => { + onwarn(warning, (warning: any) => { + this.warnings.push(warning); + }); + }; + + return mod; + } + + oninvalid(cb: (filename: string) => void) { + this._oninvalid = cb; + } + + async compile(): Promise { + const config = await this._; + + const start = Date.now(); + + try { + const bundle = await rollup.rollup(config); + await bundle.write(config.output); + + return new RollupResult(Date.now() - start, this); + } catch (err) { + if (err.filename) { + // TODO this is a bit messy. Also, can + // Rollup emit other kinds of error? + err.message = [ + `Failed to build — error in ${err.filename}: ${err.message}`, + err.frame + ].filter(Boolean).join('\n'); + } + + throw err; + } + } + + async watch(cb: (err?: Error, stats?: any) => void) { + const config = await this._; + + const watcher = rollup.watch(config); + + watcher.on('change', (id: string) => { + this.chunks = []; + this.warnings = []; + this.errors = []; + this._oninvalid(id); + }); + + watcher.on('event', (event: any) => { + switch (event.code) { + case 'FATAL': + // TODO kill the process? + if (event.error.filename) { + // TODO this is a bit messy. Also, can + // Rollup emit other kinds of error? + event.error.message = [ + `Failed to build — error in ${event.error.filename}: ${event.error.message}`, + event.error.frame + ].filter(Boolean).join('\n'); + } + + cb(event.error); + break; + + case 'ERROR': + this.errors.push(event.error); + cb(null, new RollupResult(Date.now() - this._start, this)); + break; + + case 'START': + case 'END': + // TODO is there anything to do with this info? + break; + + case 'BUNDLE_START': + this._start = Date.now(); + break; + + case 'BUNDLE_END': + cb(null, new RollupResult(Date.now() - this._start, this)); + break; + + default: + console.log(`Unexpected event ${event.code}`); + } + }); + } +} \ No newline at end of file diff --git a/src/core/create_compilers/RollupResult.ts b/src/core/create_compilers/RollupResult.ts new file mode 100644 index 0000000..87ea226 --- /dev/null +++ b/src/core/create_compilers/RollupResult.ts @@ -0,0 +1,111 @@ +import * as path from 'path'; +import colors from 'kleur'; +import pb from 'pretty-bytes'; +import RollupCompiler from './RollupCompiler'; +import extract_css from './extract_css'; +import { left_pad } from '../../utils'; +import { CompileResult, BuildInfo, CompileError, Chunk, CssFile } from './interfaces'; +import { ManifestData, Dirs, PageComponent } from '../../interfaces'; + +export default class RollupResult implements CompileResult { + duration: number; + errors: CompileError[]; + warnings: CompileError[]; + chunks: Chunk[]; + assets: Record; + css_files: CssFile[]; + css: { + main: string, + chunks: Record + }; + summary: string; + + constructor(duration: number, compiler: RollupCompiler) { + this.duration = duration; + + this.errors = compiler.errors.map(munge_warning_or_error); + this.warnings = compiler.warnings.map(munge_warning_or_error); // TODO emit this as they happen + + this.chunks = compiler.chunks.map(chunk => ({ + file: chunk.fileName, + imports: chunk.imports.filter(Boolean), + modules: Object.keys(chunk.modules) + })); + + this.css_files = compiler.css_files; + + // TODO populate this properly. We don't have named chunks, as in + // webpack, but we can have a route -> [chunk] map or something + this.assets = {}; + + compiler.chunks.forEach(chunk => { + if (compiler.input in chunk.modules) { + this.assets.main = chunk.fileName; + } + }); + + this.summary = compiler.chunks.map(chunk => { + const size_color = chunk.code.length > 150000 ? colors.bold.red : chunk.code.length > 50000 ? colors.bold.yellow : colors.bold.white; + const size_label = left_pad(pb(chunk.code.length), 10); + + const lines = [size_color(`${size_label} ${chunk.fileName}`)]; + + const deps = Object.keys(chunk.modules) + .map(file => { + return { + file: path.relative(process.cwd(), file), + size: chunk.modules[file].renderedLength + }; + }) + .filter(dep => dep.size > 0) + .sort((a, b) => b.size - a.size); + + const total_unminified = deps.reduce((t, d) => t + d.size, 0); + + deps.forEach((dep, i) => { + const c = i === deps.length - 1 ? '└' : '│'; + let line = ` ${c} ${dep.file}`; + + if (deps.length > 1) { + const p = (100 * dep.size / total_unminified).toFixed(1); + line += ` (${p}%)`; + } + + lines.push(colors.gray(line)); + }); + + return lines.join('\n'); + }).join('\n'); + } + + to_json(manifest_data: ManifestData, dirs: Dirs): BuildInfo { + // TODO extract_css has side-effects that don't belong + // in a method called to_json + return { + bundler: 'rollup', + shimport: require('shimport/package.json').version, + assets: this.assets, + css: extract_css(this, manifest_data.components, dirs) + }; + } + + print() { + const blocks: string[] = this.warnings.map(warning => { + return warning.file + ? `> ${colors.bold(warning.file)}\n${warning.message}` + : `> ${warning.message}`; + }); + + blocks.push(this.summary); + + return blocks.join('\n\n'); + } +} + +function munge_warning_or_error(warning_or_error: any) { + return { + file: warning_or_error.filename, + message: [warning_or_error.message, warning_or_error.frame].filter(Boolean).join('\n') + }; +} + diff --git a/src/core/create_compilers/WebpackCompiler.ts b/src/core/create_compilers/WebpackCompiler.ts new file mode 100644 index 0000000..7c8863f --- /dev/null +++ b/src/core/create_compilers/WebpackCompiler.ts @@ -0,0 +1,48 @@ +import * as path from 'path'; +import relative from 'require-relative'; +import { CompileResult } from './interfaces'; +import WebpackResult from './WebpackResult'; + +let webpack: any; + +export class WebpackCompiler { + _: any; + + constructor(config: string) { + if (!webpack) webpack = relative('webpack', process.cwd()); + this._ = webpack(require(path.resolve(config))); + } + + oninvalid(cb: (filename: string) => void) { + this._.hooks.invalid.tap('sapper', cb); + } + + compile(): Promise { + return new Promise((fulfil, reject) => { + this._.run((err: Error, stats: any) => { + if (err) { + reject(err); + process.exit(1); + } + + const result = new WebpackResult(stats); + + if (result.errors.length) { + // TODO print errors + // console.error(stats.toString({ colors: true })); + reject(new Error(`Encountered errors while building app`)); + } + + else { + fulfil(result); + } + }); + }); + } + + watch(cb: (err?: Error, stats?: any) => void) { + this._.watch({}, (err?: Error, stats?: any) => { + cb(err, stats && new WebpackResult(stats)); + }); + } +} \ No newline at end of file diff --git a/src/core/create_compilers/WebpackResult.ts b/src/core/create_compilers/WebpackResult.ts new file mode 100644 index 0000000..4b015a6 --- /dev/null +++ b/src/core/create_compilers/WebpackResult.ts @@ -0,0 +1,73 @@ +import format_messages from 'webpack-format-messages'; +import { CompileResult, BuildInfo, CompileError, Chunk, CssFile } from './interfaces'; +import { ManifestData, Dirs } from '../../interfaces'; + +const locPattern = /\((\d+):(\d+)\)$/; + +function munge_warning_or_error(message: string) { + // TODO this is all a bit rube goldberg... + const lines = message.split('\n'); + + const file = lines.shift() + .replace('', '') // careful — there is a special character at the beginning of this string + .replace('', '') + .replace('./', ''); + + let line = null; + let column = null; + + const match = locPattern.exec(lines[0]); + if (match) { + lines[0] = lines[0].replace(locPattern, ''); + line = +match[1]; + column = +match[2]; + } + + return { + file, + message: lines.join('\n') + }; +} + +export default class WebpackResult implements CompileResult { + duration: number; + errors: CompileError[]; + warnings: CompileError[]; + chunks: Chunk[]; + assets: Record; + css_files: CssFile[]; + stats: any; + + constructor(stats: any) { + this.stats = stats; + + const info = stats.toJson(); + + const messages = format_messages(stats); + + this.errors = messages.errors.map(munge_warning_or_error); + this.warnings = messages.warnings.map(munge_warning_or_error); + + this.duration = info.time; + + this.chunks = info.assets.map((chunk: { name: string }) => ({ file: chunk.name })); + this.assets = info.assetsByChunkName; + } + + to_json(manifest_data: ManifestData, dirs: Dirs): BuildInfo { + return { + bundler: 'webpack', + shimport: null, // webpack has its own loader + assets: this.assets, + css: { + // TODO + main: null, + chunks: {} + } + }; + } + + print() { + return this.stats.toString({ colors: true }); + } +} \ No newline at end of file diff --git a/src/core/create_compilers/extract_css.ts b/src/core/create_compilers/extract_css.ts new file mode 100644 index 0000000..6791ab6 --- /dev/null +++ b/src/core/create_compilers/extract_css.ts @@ -0,0 +1,230 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import hash from 'string-hash'; +import * as codec from 'sourcemap-codec'; +import { PageComponent, Dirs } from '../../interfaces'; +import { CompileResult } from './interfaces'; + +const inline_sourcemap_header = 'data:application/json;charset=utf-8;base64,'; + +function extract_sourcemap(raw: string, id: string) { + let raw_map: string; + let map = null; + + const code = raw.replace(/\/\*#\s+sourceMappingURL=(.+)\s+\*\//g, (m, url) => { + if (raw_map) { + // TODO should not happen! + throw new Error(`Found multiple sourcemaps in single CSS file (${id})`); + } + + raw_map = url; + return ''; + }).trim(); + + if (raw_map) { + if (raw_map.startsWith(inline_sourcemap_header)) { + const json = Buffer.from(raw_map.slice(inline_sourcemap_header.length), 'base64').toString(); + map = JSON.parse(json); + } else { + // TODO do we want to handle non-inline sourcemaps? could be a rabbit hole + } + } + + return { + code, + map + }; +} + +type SourceMap = { + version: 3; + file: string; + sources: string[]; + sourcesContent: string[]; + names: string[]; + mappings: string; +}; + +export default function extract_css(client_result: CompileResult, components: PageComponent[], dirs: Dirs) { + const result: { + main: string | null; + chunks: Record + } = { + main: null, + chunks: {} + }; + + if (!client_result.css_files) return; // Rollup-only for now + + const unaccounted_for = new Set(); + + const css_map = new Map(); + client_result.css_files.forEach(css => { + unaccounted_for.add(css.id); + css_map.set(css.id, css.code); + }); + + const chunk_map = new Map(); + client_result.chunks.forEach(chunk => { + chunk_map.set(chunk.file, chunk); + }); + + const chunks_with_css = new Set(); + + // figure out which chunks belong to which components... + const component_owners = new Map(); + client_result.chunks.forEach(chunk => { + chunk.modules.forEach(module => { + const component = path.relative(dirs.routes, module); + component_owners.set(component, chunk); + }); + }); + + const chunks_depended_upon_by_component = new Map(); + + // ...so we can figure out which chunks don't belong + components.forEach(component => { + const chunk = component_owners.get(component.file); + if (!chunk) { + // this should never happen! + throw new Error(`Could not find chunk that owns ${component.file}`); + } + + const chunks = new Set([chunk]); + chunks.forEach(chunk => { + chunk.imports.forEach((file: string) => { + const chunk = chunk_map.get(file); + if (chunk) chunks.add(chunk); + }); + }); + + chunks.forEach(chunk => { + chunk.modules.forEach((module: string) => { + unaccounted_for.delete(module); + }); + }); + + chunks_depended_upon_by_component.set( + component, + chunks + ); + }); + + function get_css_from_modules(modules: string[]) { + const parts: string[] = []; + const mappings: number[][][] = []; + + const combined_map: SourceMap = { + version: 3, + file: null, + sources: [], + sourcesContent: [], + names: [], + mappings: null + }; + + modules.forEach(module => { + if (!/\.css$/.test(module)) return; + + const css = css_map.get(module); + + const { code, map } = extract_sourcemap(css, module); + + parts.push(code); + + if (map) { + const lines = codec.decode(map.mappings); + + if (combined_map.sources.length > 0 || combined_map.names.length > 0) { + lines.forEach(line => { + line.forEach(segment => { + // adjust source index + segment[1] += combined_map.sources.length; + + // adjust name index + if (segment[4]) segment[4] += combined_map.names.length; + }); + }); + } + + combined_map.sources.push(...map.sources); + combined_map.sourcesContent.push(...map.sourcesContent); + combined_map.names.push(...map.names); + + mappings.push(...lines); + } + }); + + if (parts.length > 0) { + combined_map.mappings = codec.encode(mappings); + + combined_map.sources = combined_map.sources.map(source => path.relative(`${dirs.dest}/client`, source)); + + return { + code: parts.join('\n'), + map: combined_map + }; + } + + return null; + } + + const main = client_result.assets.main; + const entry = fs.readFileSync(`${dirs.dest}/client/${main}`, 'utf-8'); + + const replacements = new Map(); + + chunks_depended_upon_by_component.forEach((chunks, component) => { + const chunks_with_css = Array.from(chunks).filter(chunk => { + const css = get_css_from_modules(chunk.modules); + + if (css) { + const { code, map } = css; + + const output_file_name = chunk.file.replace(/\.js$/, '.css'); + + map.file = output_file_name; + map.sources = map.sources.map(source => path.relative(`${dirs.dest}/client`, source)); + + fs.writeFileSync(`${dirs.dest}/client/${output_file_name}`, `${code}\n/* sourceMappingURL=client/${output_file_name}.map */`); + fs.writeFileSync(`${dirs.dest}/client/${output_file_name}.map`, JSON.stringify(map, null, ' ')); + + return true; + } + }); + + const files = chunks_with_css.map(chunk => chunk.file.replace(/\.js$/, '.css')); + + replacements.set( + component.file, + files + ); + + result.chunks[component.file] = files; + }); + + const replaced = entry.replace(/["']__SAPPER_CSS_PLACEHOLDER:(.+?)__["']/g, (m, route) => { + return JSON.stringify(replacements.get(route)); + }); + + fs.writeFileSync(`${dirs.dest}/client/${main}`, replaced); + + const leftover = get_css_from_modules(Array.from(unaccounted_for)); + if (leftover) { + const { code, map } = leftover; + + const main_hash = hash(code); + + const output_file_name = `main.${main_hash}.css`; + + map.file = output_file_name; + map.sources = map.sources.map(source => path.relative(`${dirs.dest}/client`, source)); + + fs.writeFileSync(`${dirs.dest}/client/${output_file_name}`, `${code}\n/* sourceMappingURL=client/${output_file_name}.map */`); + fs.writeFileSync(`${dirs.dest}/client/${output_file_name}.map`, JSON.stringify(map, null, ' ')); + + result.main = output_file_name; + } + + return result; +} \ No newline at end of file diff --git a/src/core/create_compilers/index.ts b/src/core/create_compilers/index.ts new file mode 100644 index 0000000..cf9c13a --- /dev/null +++ b/src/core/create_compilers/index.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import { Dirs } from '../../interfaces'; +import RollupCompiler from './RollupCompiler'; +import { WebpackCompiler } from './WebpackCompiler'; + +export type Compiler = RollupCompiler | WebpackCompiler; + +export type Compilers = { + client: Compiler; + server: Compiler; + serviceworker?: Compiler; +} + +export default function create_compilers(bundler: string, dirs: Dirs): Compilers { + if (bundler === 'rollup') { + const sw = `${dirs.rollup}/service-worker.config.js`; + + return { + client: new RollupCompiler(`${dirs.rollup}/client.config.js`), + server: new RollupCompiler(`${dirs.rollup}/server.config.js`), + serviceworker: fs.existsSync(sw) && new RollupCompiler(sw) + }; + } + + if (bundler === 'webpack') { + const sw = `${dirs.webpack}/service-worker.config.js`; + + return { + client: new WebpackCompiler(`${dirs.webpack}/client.config.js`), + server: new WebpackCompiler(`${dirs.webpack}/server.config.js`), + serviceworker: fs.existsSync(sw) && new WebpackCompiler(sw) + }; + } + + // this shouldn't be possible... + throw new Error(`Invalid bundler option '${bundler}'`); +} \ No newline at end of file diff --git a/src/core/create_compilers/interfaces.ts b/src/core/create_compilers/interfaces.ts new file mode 100644 index 0000000..11b8b0e --- /dev/null +++ b/src/core/create_compilers/interfaces.ts @@ -0,0 +1,39 @@ +import { ManifestData, Dirs } from '../../interfaces'; + +export type Chunk = { + file: string; + imports: string[]; + modules: string[]; +} + +export type CssFile = { + id: string; + code: string; +}; + +export class CompileError { + file: string; + message: string; +} + +export interface CompileResult { + duration: number; + errors: CompileError[]; + warnings: CompileError[]; + chunks: Chunk[]; + assets: Record; + css_files: CssFile[]; + + to_json: (manifest_data: ManifestData, dirs: Dirs) => BuildInfo +} + +export type BuildInfo = { + bundler: string; + shimport: string; + assets: Record; + legacy_assets?: Record; + css: { + main: string | null, + chunks: Record + } +} \ No newline at end of file diff --git a/src/core/create_routes.ts b/src/core/create_manifest_data.ts similarity index 97% rename from src/core/create_routes.ts rename to src/core/create_manifest_data.ts index 96ac8c0..83788c2 100644 --- a/src/core/create_routes.ts +++ b/src/core/create_manifest_data.ts @@ -1,10 +1,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { locations } from '../config'; -import { Page, PageComponent, ServerRoute } from '../interfaces'; +import { Page, PageComponent, ServerRoute, ManifestData } from '../interfaces'; import { posixify } from './utils'; -export default function create_routes(cwd = locations.routes()) { +export default function create_manifest_data(cwd = locations.routes()): ManifestData { const components: PageComponent[] = []; const pages: Page[] = []; const server_routes: ServerRoute[] = []; diff --git a/src/core/create_manifests.ts b/src/core/create_manifests.ts index 518f692..3c904c9 100644 --- a/src/core/create_manifests.ts +++ b/src/core/create_manifests.ts @@ -3,11 +3,11 @@ import * as path from 'path'; import glob from 'tiny-glob/sync.js'; import { posixify, write_if_changed } from './utils'; import { dev, locations } from '../config'; -import { Page, PageComponent, ServerRoute } from '../interfaces'; +import { Page, PageComponent, ServerRoute, ManifestData } from '../interfaces'; -export function create_main_manifests({ bundler, routes, dev_port }: { +export function create_main_manifests({ bundler, manifest_data, dev_port }: { bundler: string, - routes: { components: PageComponent[], pages: Page[], server_routes: ServerRoute[] }; + manifest_data: ManifestData; dev_port?: number; }) { const manifest_dir = path.join(locations.app(), 'manifest'); @@ -15,8 +15,8 @@ export function create_main_manifests({ bundler, routes, dev_port }: { const path_to_routes = path.relative(manifest_dir, locations.routes()); - const client_manifest = generate_client(routes, path_to_routes, bundler, dev_port); - const server_manifest = generate_server(routes, path_to_routes); + const client_manifest = generate_client(manifest_data, path_to_routes, bundler, dev_port); + const server_manifest = generate_server(manifest_data, path_to_routes); write_if_changed( `${manifest_dir}/default-layout.html`, @@ -26,8 +26,8 @@ export function create_main_manifests({ bundler, routes, dev_port }: { write_if_changed(`${manifest_dir}/server.js`, server_manifest); } -export function create_serviceworker_manifest({ routes, client_files }: { - routes: { components: PageComponent[], pages: Page[], server_routes: ServerRoute[] }; +export function create_serviceworker_manifest({ manifest_data, client_files }: { + manifest_data: ManifestData; client_files: string[]; }) { const assets = glob('**', { cwd: 'assets', filesOnly: true }); @@ -40,44 +40,47 @@ export function create_serviceworker_manifest({ routes, client_files }: { export const shell = [\n\t${client_files.map((x: string) => `"${x}"`).join(',\n\t')}\n]; - export const routes = [\n\t${routes.pages.map((r: Page) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n]; + export const routes = [\n\t${manifest_data.pages.map((r: Page) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n]; `.replace(/^\t\t/gm, '').trim(); write_if_changed(`${locations.app()}/manifest/service-worker.js`, code); } function generate_client( - routes: { root: PageComponent, components: PageComponent[], pages: Page[], server_routes: ServerRoute[] }, + manifest_data: ManifestData, path_to_routes: string, bundler: string, dev_port?: number ) { - const page_ids = new Set(routes.pages.map(page => + const page_ids = new Set(manifest_data.pages.map(page => page.pattern.toString())); - const server_routes_to_ignore = routes.server_routes.filter(route => + const server_routes_to_ignore = manifest_data.server_routes.filter(route => !page_ids.has(route.pattern.toString())); let code = ` // This file is generated by Sapper — do not edit it! - import root from '${get_file(path_to_routes, routes.root)}'; + import root from '${get_file(path_to_routes, manifest_data.root)}'; import error from '${posixify(`${path_to_routes}/_error.html`)}'; - ${routes.components.map(component => { + ${manifest_data.components.map(component => { const annotation = bundler === 'webpack' ? `/* webpackChunkName: "${component.name}" */ ` : ''; const source = get_file(path_to_routes, component); - return `const ${component.name} = () => import(${annotation}'${source}');`; + return `const ${component.name} = { + js: () => import(${annotation}'${source}'), + css: "__SAPPER_CSS_PLACEHOLDER:${component.file}__" + };`; }).join('\n')} export const manifest = { ignore: [${server_routes_to_ignore.map(route => route.pattern).join(', ')}], pages: [ - ${routes.pages.map(page => `{ + ${manifest_data.pages.map(page => `{ // ${page.parts[page.parts.length - 1].component.file} pattern: ${page.pattern}, parts: [ @@ -119,15 +122,15 @@ function generate_client( } function generate_server( - routes: { root: PageComponent, components: PageComponent[], pages: Page[], server_routes: ServerRoute[] }, + manifest_data: ManifestData, path_to_routes: string ) { const imports = [].concat( - routes.server_routes.map(route => + manifest_data.server_routes.map(route => `import * as ${route.name} from '${posixify(`${path_to_routes}/${route.file}`)}';`), - routes.components.map(component => + manifest_data.components.map(component => `import ${component.name} from '${get_file(path_to_routes, component)}';`), - `import root from '${get_file(path_to_routes, routes.root)}';`, + `import root from '${get_file(path_to_routes, manifest_data.root)}';`, `import error from '${posixify(`${path_to_routes}/_error.html`)}';` ); @@ -137,7 +140,7 @@ function generate_server( export const manifest = { server_routes: [ - ${routes.server_routes.map(route => `{ + ${manifest_data.server_routes.map(route => `{ // ${route.file} pattern: ${route.pattern}, handlers: ${route.name}, @@ -148,7 +151,7 @@ function generate_server( ], pages: [ - ${routes.pages.map(page => `{ + ${manifest_data.pages.map(page => `{ // ${page.parts[page.parts.length - 1].component.file} pattern: ${page.pattern}, parts: [ @@ -157,6 +160,7 @@ function generate_server( const props = [ `name: "${part.component.name}"`, + `file: "${part.component.file}"`, `component: ${part.component.name}` ]; diff --git a/src/interfaces.ts b/src/interfaces.ts index e63c086..313da87 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -39,4 +39,19 @@ export type ServerRoute = { pattern: RegExp; file: string; params: string[]; +}; + +export type Dirs = { + dest: string, + app: string, + routes: string, + webpack: string, + rollup: string +}; + +export type ManifestData = { + root: PageComponent; + components: PageComponent[]; + pages: Page[]; + server_routes: ServerRoute[]; }; \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 900bbf1..171481d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -499,12 +499,36 @@ function get_page_handler( script += ``) .replace('%sapper.html%', () => html) .replace('%sapper.head%', () => `${head}`) - .replace('%sapper.styles%', () => (css && css.code ? `` : '')); + .replace('%sapper.styles%', () => styles); res.statusCode = status; res.end(body); diff --git a/src/rollup.ts b/src/rollup.ts index 399c1a7..2941021 100644 --- a/src/rollup.ts +++ b/src/rollup.ts @@ -16,7 +16,8 @@ export default { dir, entryFileNames: '[name].[hash].js', chunkFileNames: '[name].[hash].js', - format: 'esm' + format: 'esm', + sourcemap: dev() }; } }, diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 8e74336..1d7210c 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,5 +1,5 @@ import { detach, findAnchor, scroll_state, which } from './utils'; -import { Component, ComponentConstructor, Params, Query, Redirect, Manifest, RouteData, ScrollPosition, Store, Target } from './interfaces'; +import { Component, ComponentConstructor, Params, Query, Redirect, Manifest, RouteData, ScrollPosition, Store, Target, ComponentLoader } from './interfaces'; const initial_data = typeof window !== 'undefined' && window.__SAPPER__; @@ -131,6 +131,30 @@ function changed(a: Record, b: Record; let root_data: any; +function load_css(chunk: string) { + const href = `${initial_data.baseUrl}client/${chunk}`; + if (document.querySelector(`link[href="${href}"]`)) return; + + return new Promise((fulfil, reject) => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + + link.onload = () => fulfil(); + link.onerror = reject; + + document.head.appendChild(link); + }); +} + +function load_component(component: ComponentLoader): Promise { + // TODO this is temporary — once placeholders are + // always rewritten, scratch the ternary + const promises: Array> = (typeof component.css === 'string' ? [] : component.css.map(load_css)); + promises.unshift(component.js()); + return Promise.all(promises).then(values => values[0].default); +} + function prepare_page(target: Target): Promise<{ redirect?: Redirect; data?: any; @@ -177,7 +201,8 @@ function prepare_page(target: Target): Promise<{ if (i < changed_from) return null; if (!part) return null; - const { default: Component } = await part.component(); + const Component = await load_component(part.component); + const req = { path, query, @@ -468,9 +493,9 @@ export function prefetchRoutes(pathnames: string[]) { if (!pathnames) return true; return pathnames.some(pathname => route.pattern.test(pathname)); }) - .reduce((promise: Promise, route) => { - return promise.then(route.load); - }, Promise.resolve()); + .reduce((promise: Promise, route) => promise.then(() => { + return Promise.all(route.parts.map(part => part && load_component(part.component))); + }), Promise.resolve()); } // remove this in 0.9 diff --git a/src/runtime/interfaces.ts b/src/runtime/interfaces.ts index 5fbc381..457d667 100644 --- a/src/runtime/interfaces.ts +++ b/src/runtime/interfaces.ts @@ -15,10 +15,15 @@ export interface Component { destroy: () => void; } +export type ComponentLoader = { + js: () => Promise<{ default: ComponentConstructor }>, + css: string[] +}; + export type Page = { pattern: RegExp; parts: Array<{ - component: () => Promise<{ default: ComponentConstructor }>; + component: ComponentLoader; params?: (match: RegExpExecArray) => Record; }>; }; diff --git a/test/unit/create_routes/index.ts b/test/unit/create_manifest_data/index.ts similarity index 81% rename from test/unit/create_routes/index.ts rename to test/unit/create_manifest_data/index.ts index 02f8483..e847b8a 100644 --- a/test/unit/create_routes/index.ts +++ b/test/unit/create_manifest_data/index.ts @@ -1,10 +1,10 @@ import * as path from 'path'; import * as assert from 'assert'; -import create_routes from '../../../src/core/create_routes'; +import manifest_data from '../../../src/core/create_manifest_data'; -describe('create_routes', () => { +describe('manifest_data', () => { it('creates routes', () => { - const { components, pages, server_routes } = create_routes(path.join(__dirname, 'samples/basic')); + const { components, pages, server_routes } = manifest_data(path.join(__dirname, 'samples/basic')); const index = { name: 'index', file: 'index.html' }; const about = { name: 'about', file: 'about.html' }; @@ -68,7 +68,7 @@ describe('create_routes', () => { }); it('encodes invalid characters', () => { - const { components, pages } = create_routes(path.join(__dirname, 'samples/encoding')); + const { components, pages } = manifest_data(path.join(__dirname, 'samples/encoding')); // had to remove ? and " because windows @@ -90,7 +90,7 @@ describe('create_routes', () => { }); it('allows regex qualifiers', () => { - const { pages } = create_routes(path.join(__dirname, 'samples/qualifiers')); + const { pages } = manifest_data(path.join(__dirname, 'samples/qualifiers')); assert.deepEqual(pages.map(p => p.pattern), [ /^\/([0-9-a-z]{3,})\/?$/, @@ -100,7 +100,7 @@ describe('create_routes', () => { }); it('sorts routes correctly', () => { - const { pages } = create_routes(path.join(__dirname, 'samples/sorting')); + const { pages } = manifest_data(path.join(__dirname, 'samples/sorting')); assert.deepEqual(pages.map(p => p.parts.map(part => part && part.component.file)), [ ['index.html'], @@ -116,7 +116,7 @@ describe('create_routes', () => { }); it('ignores files and directories with leading underscores', () => { - const { server_routes } = create_routes(path.join(__dirname, 'samples/hidden-underscore')); + const { server_routes } = manifest_data(path.join(__dirname, 'samples/hidden-underscore')); assert.deepEqual(server_routes.map(r => r.file), [ 'index.js', @@ -125,7 +125,7 @@ describe('create_routes', () => { }); it('ignores files and directories with leading dots except .well-known', () => { - const { server_routes } = create_routes(path.join(__dirname, 'samples/hidden-dot')); + const { server_routes } = manifest_data(path.join(__dirname, 'samples/hidden-dot')); assert.deepEqual(server_routes.map(r => r.file), [ '.well-known/dnt-policy.txt.js' @@ -134,24 +134,24 @@ describe('create_routes', () => { it('fails on clashes', () => { assert.throws(() => { - const { pages } = create_routes(path.join(__dirname, 'samples/clash-pages')); + const { pages } = manifest_data(path.join(__dirname, 'samples/clash-pages')); }, /The \[bar\]\/index\.html and \[foo\]\.html pages clash/); assert.throws(() => { - const { server_routes } = create_routes(path.join(__dirname, 'samples/clash-routes')); + const { server_routes } = manifest_data(path.join(__dirname, 'samples/clash-routes')); console.log(server_routes); }, /The \[bar\]\/index\.js and \[foo\]\.js routes clash/); }); it('fails if dynamic params are not separated', () => { assert.throws(() => { - create_routes(path.join(__dirname, 'samples/invalid-params')); + manifest_data(path.join(__dirname, 'samples/invalid-params')); }, /Invalid route \[foo\]\[bar\]\.js — parameters must be separated/); }); it('errors when trying to use reserved characters in route regexp', () => { assert.throws(() => { - create_routes(path.join(__dirname, 'samples/invalid-qualifier')); + manifest_data(path.join(__dirname, 'samples/invalid-qualifier')); }, /Invalid route \[foo\(\[a-z\]\(\[0-9\]\)\)\].js — cannot use \(, \), \? or \: in route qualifiers/); }); }); \ No newline at end of file diff --git a/test/unit/create_routes/samples/basic/about.html b/test/unit/create_manifest_data/samples/basic/about.html similarity index 100% rename from test/unit/create_routes/samples/basic/about.html rename to test/unit/create_manifest_data/samples/basic/about.html diff --git a/test/unit/create_routes/samples/basic/blog/[slug].html b/test/unit/create_manifest_data/samples/basic/blog/[slug].html similarity index 100% rename from test/unit/create_routes/samples/basic/blog/[slug].html rename to test/unit/create_manifest_data/samples/basic/blog/[slug].html diff --git a/test/unit/create_routes/samples/basic/blog/[slug].json.js b/test/unit/create_manifest_data/samples/basic/blog/[slug].json.js similarity index 100% rename from test/unit/create_routes/samples/basic/blog/[slug].json.js rename to test/unit/create_manifest_data/samples/basic/blog/[slug].json.js diff --git a/test/unit/create_routes/samples/basic/blog/_default.html b/test/unit/create_manifest_data/samples/basic/blog/_default.html similarity index 100% rename from test/unit/create_routes/samples/basic/blog/_default.html rename to test/unit/create_manifest_data/samples/basic/blog/_default.html diff --git a/test/unit/create_routes/samples/basic/blog/index.html b/test/unit/create_manifest_data/samples/basic/blog/index.html similarity index 100% rename from test/unit/create_routes/samples/basic/blog/index.html rename to test/unit/create_manifest_data/samples/basic/blog/index.html diff --git a/test/unit/create_routes/samples/basic/blog/index.json.js b/test/unit/create_manifest_data/samples/basic/blog/index.json.js similarity index 100% rename from test/unit/create_routes/samples/basic/blog/index.json.js rename to test/unit/create_manifest_data/samples/basic/blog/index.json.js diff --git a/test/unit/create_routes/samples/basic/index.html b/test/unit/create_manifest_data/samples/basic/index.html similarity index 100% rename from test/unit/create_routes/samples/basic/index.html rename to test/unit/create_manifest_data/samples/basic/index.html diff --git a/test/unit/create_routes/samples/clash-pages/[bar]/index.html b/test/unit/create_manifest_data/samples/clash-pages/[bar]/index.html similarity index 100% rename from test/unit/create_routes/samples/clash-pages/[bar]/index.html rename to test/unit/create_manifest_data/samples/clash-pages/[bar]/index.html diff --git a/test/unit/create_routes/samples/clash-pages/[foo].html b/test/unit/create_manifest_data/samples/clash-pages/[foo].html similarity index 100% rename from test/unit/create_routes/samples/clash-pages/[foo].html rename to test/unit/create_manifest_data/samples/clash-pages/[foo].html diff --git a/test/unit/create_routes/samples/clash-pages/index.html b/test/unit/create_manifest_data/samples/clash-pages/index.html similarity index 100% rename from test/unit/create_routes/samples/clash-pages/index.html rename to test/unit/create_manifest_data/samples/clash-pages/index.html diff --git a/test/unit/create_routes/samples/clash-routes/[bar]/index.js b/test/unit/create_manifest_data/samples/clash-routes/[bar]/index.js similarity index 100% rename from test/unit/create_routes/samples/clash-routes/[bar]/index.js rename to test/unit/create_manifest_data/samples/clash-routes/[bar]/index.js diff --git a/test/unit/create_routes/samples/clash-routes/[foo].js b/test/unit/create_manifest_data/samples/clash-routes/[foo].js similarity index 100% rename from test/unit/create_routes/samples/clash-routes/[foo].js rename to test/unit/create_manifest_data/samples/clash-routes/[foo].js diff --git a/test/unit/create_routes/samples/clash-routes/index.html b/test/unit/create_manifest_data/samples/clash-routes/index.html similarity index 100% rename from test/unit/create_routes/samples/clash-routes/index.html rename to test/unit/create_manifest_data/samples/clash-routes/index.html diff --git a/test/unit/create_routes/samples/encoding/#.html b/test/unit/create_manifest_data/samples/encoding/#.html similarity index 100% rename from test/unit/create_routes/samples/encoding/#.html rename to test/unit/create_manifest_data/samples/encoding/#.html diff --git a/test/unit/create_routes/samples/hidden-dot/.unknown/foo.txt.js b/test/unit/create_manifest_data/samples/hidden-dot/.unknown/foo.txt.js similarity index 100% rename from test/unit/create_routes/samples/hidden-dot/.unknown/foo.txt.js rename to test/unit/create_manifest_data/samples/hidden-dot/.unknown/foo.txt.js diff --git a/test/unit/create_routes/samples/hidden-dot/.well-known/dnt-policy.txt.js b/test/unit/create_manifest_data/samples/hidden-dot/.well-known/dnt-policy.txt.js similarity index 100% rename from test/unit/create_routes/samples/hidden-dot/.well-known/dnt-policy.txt.js rename to test/unit/create_manifest_data/samples/hidden-dot/.well-known/dnt-policy.txt.js diff --git a/test/unit/create_routes/samples/hidden-underscore/_foo.js b/test/unit/create_manifest_data/samples/hidden-underscore/_foo.js similarity index 100% rename from test/unit/create_routes/samples/hidden-underscore/_foo.js rename to test/unit/create_manifest_data/samples/hidden-underscore/_foo.js diff --git a/test/unit/create_routes/samples/hidden-underscore/a/_b/c/d.js b/test/unit/create_manifest_data/samples/hidden-underscore/a/_b/c/d.js similarity index 100% rename from test/unit/create_routes/samples/hidden-underscore/a/_b/c/d.js rename to test/unit/create_manifest_data/samples/hidden-underscore/a/_b/c/d.js diff --git a/test/unit/create_routes/samples/hidden-underscore/e/f/g/h.js b/test/unit/create_manifest_data/samples/hidden-underscore/e/f/g/h.js similarity index 100% rename from test/unit/create_routes/samples/hidden-underscore/e/f/g/h.js rename to test/unit/create_manifest_data/samples/hidden-underscore/e/f/g/h.js diff --git a/test/unit/create_routes/samples/hidden-underscore/i/_j.js b/test/unit/create_manifest_data/samples/hidden-underscore/i/_j.js similarity index 100% rename from test/unit/create_routes/samples/hidden-underscore/i/_j.js rename to test/unit/create_manifest_data/samples/hidden-underscore/i/_j.js diff --git a/test/unit/create_routes/samples/hidden-underscore/index.html b/test/unit/create_manifest_data/samples/hidden-underscore/index.html similarity index 100% rename from test/unit/create_routes/samples/hidden-underscore/index.html rename to test/unit/create_manifest_data/samples/hidden-underscore/index.html diff --git a/test/unit/create_routes/samples/hidden-underscore/index.js b/test/unit/create_manifest_data/samples/hidden-underscore/index.js similarity index 100% rename from test/unit/create_routes/samples/hidden-underscore/index.js rename to test/unit/create_manifest_data/samples/hidden-underscore/index.js diff --git a/test/unit/create_routes/samples/invalid-params/[foo][bar].js b/test/unit/create_manifest_data/samples/invalid-params/[foo][bar].js similarity index 100% rename from test/unit/create_routes/samples/invalid-params/[foo][bar].js rename to test/unit/create_manifest_data/samples/invalid-params/[foo][bar].js diff --git a/test/unit/create_routes/samples/invalid-qualifier/[foo([a-z]([0-9]))].js b/test/unit/create_manifest_data/samples/invalid-qualifier/[foo([a-z]([0-9]))].js similarity index 100% rename from test/unit/create_routes/samples/invalid-qualifier/[foo([a-z]([0-9]))].js rename to test/unit/create_manifest_data/samples/invalid-qualifier/[foo([a-z]([0-9]))].js diff --git a/test/unit/create_routes/samples/qualifiers/[slug([0-9-a-z]{3,})].html b/test/unit/create_manifest_data/samples/qualifiers/[slug([0-9-a-z]{3,})].html similarity index 100% rename from test/unit/create_routes/samples/qualifiers/[slug([0-9-a-z]{3,})].html rename to test/unit/create_manifest_data/samples/qualifiers/[slug([0-9-a-z]{3,})].html diff --git a/test/unit/create_routes/samples/qualifiers/[slug([a-z]{2})].html b/test/unit/create_manifest_data/samples/qualifiers/[slug([a-z]{2})].html similarity index 100% rename from test/unit/create_routes/samples/qualifiers/[slug([a-z]{2})].html rename to test/unit/create_manifest_data/samples/qualifiers/[slug([a-z]{2})].html diff --git a/test/unit/create_routes/samples/qualifiers/[slug].html b/test/unit/create_manifest_data/samples/qualifiers/[slug].html similarity index 100% rename from test/unit/create_routes/samples/qualifiers/[slug].html rename to test/unit/create_manifest_data/samples/qualifiers/[slug].html diff --git a/test/unit/create_routes/samples/sorting/[wildcard].html b/test/unit/create_manifest_data/samples/sorting/[wildcard].html similarity index 100% rename from test/unit/create_routes/samples/sorting/[wildcard].html rename to test/unit/create_manifest_data/samples/sorting/[wildcard].html diff --git a/test/unit/create_routes/samples/sorting/_layout.html b/test/unit/create_manifest_data/samples/sorting/_layout.html similarity index 100% rename from test/unit/create_routes/samples/sorting/_layout.html rename to test/unit/create_manifest_data/samples/sorting/_layout.html diff --git a/test/unit/create_routes/samples/sorting/about.html b/test/unit/create_manifest_data/samples/sorting/about.html similarity index 100% rename from test/unit/create_routes/samples/sorting/about.html rename to test/unit/create_manifest_data/samples/sorting/about.html diff --git a/test/unit/create_routes/samples/sorting/index.html b/test/unit/create_manifest_data/samples/sorting/index.html similarity index 100% rename from test/unit/create_routes/samples/sorting/index.html rename to test/unit/create_manifest_data/samples/sorting/index.html diff --git a/test/unit/create_routes/samples/sorting/post/[id([0-9-a-z]{3,})].html b/test/unit/create_manifest_data/samples/sorting/post/[id([0-9-a-z]{3,})].html similarity index 100% rename from test/unit/create_routes/samples/sorting/post/[id([0-9-a-z]{3,})].html rename to test/unit/create_manifest_data/samples/sorting/post/[id([0-9-a-z]{3,})].html diff --git a/test/unit/create_routes/samples/sorting/post/[id].html b/test/unit/create_manifest_data/samples/sorting/post/[id].html similarity index 100% rename from test/unit/create_routes/samples/sorting/post/[id].html rename to test/unit/create_manifest_data/samples/sorting/post/[id].html diff --git a/test/unit/create_routes/samples/sorting/post/_default.html b/test/unit/create_manifest_data/samples/sorting/post/_default.html similarity index 100% rename from test/unit/create_routes/samples/sorting/post/_default.html rename to test/unit/create_manifest_data/samples/sorting/post/_default.html diff --git a/test/unit/create_routes/samples/sorting/post/bar.html b/test/unit/create_manifest_data/samples/sorting/post/bar.html similarity index 100% rename from test/unit/create_routes/samples/sorting/post/bar.html rename to test/unit/create_manifest_data/samples/sorting/post/bar.html diff --git a/test/unit/create_routes/samples/sorting/post/f[xx].html b/test/unit/create_manifest_data/samples/sorting/post/f[xx].html similarity index 100% rename from test/unit/create_routes/samples/sorting/post/f[xx].html rename to test/unit/create_manifest_data/samples/sorting/post/f[xx].html diff --git a/test/unit/create_routes/samples/sorting/post/foo.html b/test/unit/create_manifest_data/samples/sorting/post/foo.html similarity index 100% rename from test/unit/create_routes/samples/sorting/post/foo.html rename to test/unit/create_manifest_data/samples/sorting/post/foo.html diff --git a/test/unit/create_routes/samples/sorting/post/index.html b/test/unit/create_manifest_data/samples/sorting/post/index.html similarity index 100% rename from test/unit/create_routes/samples/sorting/post/index.html rename to test/unit/create_manifest_data/samples/sorting/post/index.html