#!/usr/bin/env node 'use strict'; function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var fs = require('fs'); var path = require('path'); var mkdirp = _interopDefault(require('mkdirp')); var rimraf = _interopDefault(require('rimraf')); var relative = _interopDefault(require('require-relative')); var glob = _interopDefault(require('glob')); var chalk = _interopDefault(require('chalk')); var framer = _interopDefault(require('code-frame')); var locateCharacter = require('locate-character'); var sander = require('sander'); var express = _interopDefault(require('express')); var cheerio = _interopDefault(require('cheerio')); var fetch = _interopDefault(require('node-fetch')); var URL = _interopDefault(require('url-parse')); var serialize = _interopDefault(require('serialize-javascript')); var escape_html = _interopDefault(require('escape-html')); const webpack = relative('webpack', process.cwd()); const client = webpack( require(path.resolve('webpack.client.config.js')) ); const server = webpack( require(path.resolve('webpack.server.config.js')) ); var compilers = Object.freeze({ client: client, server: server }); function create_matchers(files) { const routes = files .map(file => { if (/(^|\/|\\)_/.test(file)) return; const parts = file.replace(/\.(html|js|mjs)$/, '').split('/'); // glob output is always posix-style if (parts[parts.length - 1] === 'index') parts.pop(); const id = ( parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_') ) || '_'; const dynamic = parts .filter(part => part[0] === '[') .map(part => part.slice(1, -1)); let pattern_string = ''; let i = parts.length; let nested = true; while (i--) { const part = parts[i]; const dynamic = part[0] === '['; if (dynamic) { pattern_string = nested ? `(?:\\/([^/]+)${pattern_string})?` : `\\/([^/]+)${pattern_string}`; } else { nested = false; pattern_string = `\\/${part}${pattern_string}`; } } const pattern = new RegExp(`^${pattern_string}\\/?$`); const test = url => pattern.test(url); const exec = url => { const match = pattern.exec(url); if (!match) return; const params = {}; dynamic.forEach((param, i) => { params[param] = match[i + 1]; }); return params; }; return { id, type: path.extname(file) === '.html' ? 'page' : 'route', file, pattern, test, exec, parts, dynamic }; }) .filter(Boolean) .sort((a, b) => { let same = true; for (let i = 0; true; i += 1) { const a_part = a.parts[i]; const b_part = b.parts[i]; if (!a_part && !b_part) { if (same) throw new Error(`The ${a.file} and ${b.file} routes clash`); return 0; } if (!a_part) return -1; if (!b_part) return 1; const a_is_dynamic = a_part[0] === '['; const b_is_dynamic = b_part[0] === '['; if (a_is_dynamic === b_is_dynamic) { if (!a_is_dynamic && a_part !== b_part) same = false; continue; } return a_is_dynamic ? 1 : -1; } }); return routes; } const dev = process.env.NODE_ENV !== 'production'; const templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates'); const src = path.resolve(process.env.SAPPER_ROUTES || 'routes'); const dest = path.resolve(process.env.SAPPER_DEST || '.sapper'); if (dev) { mkdirp.sync(dest); rimraf.sync(path.join(dest, '**/*')); } const entry = { client: path.resolve(templates, '.main.rendered.js'), server: path.resolve(dest, 'server-entry.js') }; const callbacks = []; function onchange(fn) { callbacks.push(fn); } let routes; function update() { routes = create_matchers( glob.sync('**/*.+(html|js|mjs)', { cwd: src }) ); callbacks.forEach(fn => fn()); } update(); if (dev) { const watcher = require('chokidar').watch(`${src}/**/*.+(html|js|mjs)`, { ignoreInitial: true, persistent: false }); watcher.on('add', update); watcher.on('change', update); watcher.on('unlink', update); } var route_manager = Object.freeze({ onchange: onchange, get routes () { return routes; } }); function posixify(file) { return file.replace(/[/\\]/g, '/'); } function create_app() { const { routes: routes$$1 } = route_manager; function create_client_main() { const template = fs.readFileSync('templates/main.js', 'utf-8'); const code = `[${ routes$$1 .filter(route => route.type === 'page') .map(route => { const params = route.dynamic.length === 0 ? '{}' : `{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ') } }`; const file = posixify(`${src}/${route.file}`); return `{ pattern: ${route.pattern}, params: match => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }` }) .join(', ') }]`; let main = template .replace(/__app__/g, posixify(path.resolve(__dirname, '../../runtime/app.js'))) .replace(/__routes__/g, code) .replace(/__dev__/g, String(dev)); if (dev) { const hmr_client = posixify(require.resolve(`webpack-hot-middleware/client`)); main += `\n\nimport('${hmr_client}?path=/__webpack_hmr&timeout=20000'); if (module.hot) module.hot.accept();`; } fs.writeFileSync(entry.client, main); // need to fudge the mtime, because webpack is soft in the head const { atime, mtime } = fs.statSync(entry.client); fs.utimesSync(entry.client, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999)); } function create_server_routes() { const imports = routes$$1 .map(route => { const file = posixify(`${src}/${route.file}`); return route.type === 'page' ? `import ${route.id} from '${file}';` : `import * as ${route.id} from '${file}';`; }) .join('\n'); const exports = `export { ${routes$$1.map(route => route.id)} };`; fs.writeFileSync(entry.server, `${imports}\n\n${exports}`); const { atime, mtime } = fs.statSync(entry.server); fs.utimesSync(entry.server, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999)); } create_client_main(); create_server_routes(); } if (dev) { onchange(create_app); const watcher = require('chokidar').watch(`templates/main.js`, { ignoreInitial: true, persistent: false }); watcher.on('add', create_app); watcher.on('change', create_app); watcher.on('unlink', create_app); } let templates$1; function error(e) { if (e.title) console.error(chalk.bold.red(e.title)); if (e.body) console.error(chalk.red(e.body)); if (e.url) console.error(chalk.cyan(e.url)); if (e.frame) console.error(chalk.grey(e.frame)); process.exit(1); } function create_templates() { templates$1 = glob.sync('*.html', { cwd: 'templates' }) .map(file => { const template = fs.readFileSync(`templates/${file}`, 'utf-8'); const status = file.replace('.html', '').toLowerCase(); if (!/^[0-9x]{3}$/.test(status)) { error({ title: `templates/${file}`, body: `Bad template — should be a valid status code like 404.html, or a wildcard like 2xx.html` }); } const index = template.indexOf('%sapper.main%'); if (index !== -1) { // TODO remove this in a future version const { line, column } = locateCharacter.locate(template, index, { offsetLine: 1 }); const frame = framer(template, line, column); error({ title: `templates/${file}`, body: ``; if (serialized) { return `${main}`; } return main; }), html: promise.then(({ rendered }) => rendered.html), head: promise.then(({ rendered }) => `${rendered.head}`), styles: promise.then(({ rendered }) => (rendered.css && rendered.css.code ? `` : '')) }); } else { const { html, head, css } = mod.render(data); const page = render(200, { scripts: ``, html, head: `${head}`, styles: (css && css.code ? `` : '') }); res.end(page); } } else { const method = req.method.toLowerCase(); // 'delete' cannot be exported from a module because it is a keyword, // so check for 'del' instead const method_export = method === 'delete' ? 'del' : method; const handler = mod[method_export]; if (handler) { handler(req, res, next); } else { // no matching handler for method — 404 next(); } } } return function find_route(req, res, next) { const url = req.pathname; // whatever happens, we're going to serve some HTML res.setHeader('Content-Type', 'text/html'); resolved .then(() => { for (const route of routes) { if (route.test(url)) return handle_route(route, req, res, next, fn()); } // no matching route — 404 next(); }) .catch(err => { res.statusCode = 500; res.end(render(500, { title: (err && err.name) || 'Internal server error', url, error: escape_html(err && (err.details || err.message || err) || 'Unknown error'), stack: err && err.stack.split('\n').slice(1).join('\n') })); }); }; } function get_not_found_handler(fn) { return function handle_not_found(req, res) { const asset_cache = fn(); res.statusCode = 404; res.end(render(404, { title: 'Not found', status: 404, method: req.method, scripts: ``, url: req.url })); }; } function compose_handlers(handlers) { return (req, res, next) => { let i = 0; function go() { const handler = handlers[i]; if (handler) { handler(req, res, () => { i += 1; go(); }); } else { next(); } } go(); }; } function read_json$1(file) { return JSON.parse(fs.readFileSync(file, 'utf-8')); } function try_serialize(data) { try { return serialize(data); } catch (err) { return null; } } const { PORT = 3000, OUTPUT_DIR = 'dist' } = process.env; const origin = `http://localhost:${PORT}`; const app = express(); function read_json(file) { return JSON.parse(sander.readFileSync(file, { encoding: 'utf-8' })); } function exporter() { // Prep output directory sander.rimrafSync(OUTPUT_DIR); const { service_worker } = generate_asset_cache( read_json(path.join(dest, 'stats.client.json')), read_json(path.join(dest, 'stats.server.json')) ); sander.copydirSync('assets').to(OUTPUT_DIR); sander.copydirSync(dest, 'client').to(OUTPUT_DIR, 'client'); sander.writeFileSync(OUTPUT_DIR, 'service-worker.js', service_worker); // Intercept server route fetches function save(res) { res = res.clone(); return res.text().then(body => { const { pathname } = new URL(res.url); let dest$$1 = OUTPUT_DIR + pathname; const type = res.headers.get('Content-Type'); if (type.startsWith('text/html')) dest$$1 += '/index.html'; sander.writeFileSync(dest$$1, body); return body; }); } global.fetch = (url, opts) => { if (url[0] === '/') { url = `http://localhost:${PORT}${url}`; return fetch(url, opts) .then(r => { save(r); return r; }); } return fetch(url, opts); }; app.use(middleware()); const server = app.listen(PORT); const seen = new Set(); function handle(url) { if (url.origin !== origin) return; if (seen.has(url.pathname)) return; seen.add(url.pathname); return fetch(url.href) .then(r => { save(r); return r.text(); }) .then(body => { const $ = cheerio.load(body); const hrefs = []; $('a[href]').each((i, $a) => { hrefs.push($a.attribs.href); }); return hrefs.reduce((promise, href) => { return promise.then(() => handle(new URL(href, url.href))); }, Promise.resolve()); }) .catch(err => { console.error(`Error rendering ${url.pathname}: ${err.message}`); }); } return handle(new URL(origin)) // TODO all static routes .then(() => server.close()); } const cmd = process.argv[2]; const start = Date.now(); if (cmd === 'build') { build() .then(() => { const elapsed = Date.now() - start; console.error(`built in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds' }) .catch(err => { console.error(err ? err.details || err.stack || err.message || err : 'Unknown error'); }); } else if (cmd === 'export') { const start = Date.now(); build() .then(() => exporter()) .then(() => { const elapsed = Date.now() - start; console.error(`extracted in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds' }) .catch(err => { console.error(err ? err.details || err.stack || err.message || err : 'Unknown error'); }); }