Compare commits

...

46 Commits

Author SHA1 Message Date
Rich-Harris
87eae6164b -> v0.10.3 2018-04-02 15:39:34 -04:00
Rich Harris
97e00f5a9c Merge pull request #226 from naturalethic/downgrade-chokidar
Downgrade chokidar to 1.7.0
2018-04-02 15:38:40 -04:00
Joshua Kifer
bd55558b5e Downgrade chokidar to 1.7.0 2018-04-02 12:24:57 -07:00
Rich-Harris
25dc4b3a4c -> v0.10.2 2018-03-25 15:20:48 -04:00
Rich Harris
72c27b78a3 Merge pull request #215 from sveltejs/stable-sort
Stable sort
2018-03-25 15:19:43 -04:00
Rich-Harris
25809ec409 enforce stable sort 2018-03-25 15:12:35 -04:00
Rich-Harris
3220c522d7 attach store to error pages 2018-03-20 16:08:23 -04:00
Rich Harris
d5d25f1d30 -> v0.10.1 2018-03-18 22:47:11 -04:00
Rich Harris
7ccd6ba329 Merge pull request #207 from sveltejs/fix-fetch-paths
fix server-side fetch paths
2018-03-18 22:41:01 -04:00
Rich Harris
35c30ae2c5 fix server-side fetch paths 2018-03-18 22:36:55 -04:00
Rich Harris
2c61f6d396 -> v0.10.0 2018-03-18 22:17:53 -04:00
Rich Harris
86233a8eab Merge pull request #206 from sveltejs/sapper-base-error
app/template.html must have %sapper.base%
2018-03-18 22:13:24 -04:00
Rich Harris
c140b128ee expect %sapper.base% 2018-03-18 22:06:11 -04:00
Rich Harris
66ac9773c0 Merge pull request #204 from nolanlawson/ignore-source-maps
Add support for sourcemap *.map files
2018-03-18 21:23:26 -04:00
Rich Harris
e60714bb98 Merge pull request #203 from sveltejs/gh-178-fetch
implement this.fetch
2018-03-18 21:21:00 -04:00
Nolan Lawson
52dfd6e939 Don't preload .map files 2018-03-18 16:31:53 -07:00
Nolan Lawson
fc2312eba6 Add full *.map file support 2018-03-18 16:17:41 -07:00
Nolan Lawson
cf90476255 Ignore source map files in %sapper.scripts% 2018-03-18 11:13:24 -07:00
Rich Harris
1e8d7d10ab implement this.fetch (#178) 2018-03-17 19:21:25 -04:00
Rich Harris
cf6621b83c Merge pull request #202 from sveltejs/gh-178-store
add server- and client-side store management (#178)
2018-03-17 16:10:48 -04:00
Rich Harris
9812cbd71c add server- and client-side store management (#178) 2018-03-17 13:45:59 -04:00
Rich Harris
67a81a3cac Merge pull request #201 from sveltejs/simplify-tests
simplify tests
2018-03-17 13:24:42 -04:00
Rich Harris
67463683cc simplify tests 2018-03-17 13:18:18 -04:00
Rich Harris
b94481b716 Support being mounted on a path — fixes #180 2018-03-17 11:55:02 -04:00
Rich Harris
a95ddee48d add protocol to startup message 2018-03-16 10:51:19 -04:00
Rich Harris
953694f77f update deps 2018-03-16 10:51:04 -04:00
Rich Harris
2f24cb0429 -> v0.9.6 2018-03-11 22:01:06 -04:00
Rich Harris
687071902d -> v0.9.5 2018-03-11 21:30:17 -04:00
Rich Harris
cd3fcfdf3c move deps to devDeps 2018-03-11 21:29:34 -04:00
Rich Harris
dad48e4abd Merge pull request #197 from sveltejs/fix-clorox
workaround clorox bug
2018-03-11 21:29:12 -04:00
Rich Harris
37d3d57694 workaround clorox bug 2018-03-11 20:51:14 -04:00
Rich Harris
9a5d273590 -> v0.9.4 2018-03-11 19:00:39 -04:00
Rich Harris
3816fe71ad Merge pull request #196 from sveltejs/gh-186
implement --open
2018-03-11 16:52:41 -04:00
Rich Harris
69f5b9cac7 implement --open - fixes #186 2018-03-11 16:01:13 -04:00
Rich Harris
ad14320dc3 Merge pull request #195 from sveltejs/export-logs
show which files are being exported
2018-03-11 15:50:13 -04:00
Rich Harris
43563bd8e5 show which files are being exported 2018-03-11 15:44:53 -04:00
Rich Harris
02d558b97c Merge pull request #194 from sveltejs/gh-172
minify HTML on export
2018-03-11 15:19:07 -04:00
Rich Harris
866286c95e minify HTML on export 2018-03-11 14:57:10 -04:00
Rich Harris
e1b5e336dc Merge pull request #193 from sveltejs/gh-15
minify HTML templates
2018-03-11 14:14:39 -04:00
Rich Harris
1d71b86c0f remove all hard-coded locations (#181) 2018-03-11 13:43:11 -04:00
Rich Harris
bdc248f09a minify HTML at build time (also fixes #181) 2018-03-11 13:35:29 -04:00
Rich Harris
be63ea7c96 add SAPPER_BASE and SAPPER_APP environment variables 2018-03-11 13:29:39 -04:00
Rich Harris
819ec0b776 minify HTML templates - fixes #15 2018-03-11 13:10:43 -04:00
Rich Harris
d22d37fb18 Merge pull request #192 from sveltejs/misc-tidy-up
a few small tweaks
2018-03-11 13:10:15 -04:00
Rich Harris
8ec433581a a few small tweaks 2018-03-11 12:54:35 -04:00
Rich Harris
0d0e4d664e -> v0.9.3 2018-03-10 23:32:06 -05:00
43 changed files with 730 additions and 832 deletions

7
.gitignore vendored
View File

@@ -7,8 +7,7 @@ test/app/.sapper
test/app/app/manifest test/app/app/manifest
test/app/export test/app/export
test/app/build test/app/build
*.js sapper
*.js.map runtime.js
*.ts.js dist
*.ts.js.map
!rollup.config.js !rollup.config.js

View File

@@ -1,5 +1,47 @@
# sapper changelog # sapper changelog
## 0.10.3
* Downgrade chokidar ([#212](https://github.com/sveltejs/sapper/issues/212))
## 0.10.2
* Attach `store` to error pages
* Fix sorting edge case ([#215](https://github.com/sveltejs/sapper/pull/215))
## 0.10.1
* Fix server-side `fetch` paths ([#207](https://github.com/sveltejs/sapper/pull/207))
## 0.10.0
* Support mounting on a path (this requires `app/template.html` to include `%sapper.base%`) ([#180](https://github.com/sveltejs/sapper/issues/180))
* Support per-request server-side `Store` with client-side hydration ([#178](https://github.com/sveltejs/sapper/issues/178))
* Add `this.fetch` to `preload`, with credentials support ([#178](https://github.com/sveltejs/sapper/issues/178))
* Exclude sourcemaps from preload links and `<script>` block ([#204](https://github.com/sveltejs/sapper/pull/204))
* Register service worker in `<script>` block
## 0.9.6
* Whoops — `tslib` is a runtime dependency
## 0.9.5
* Stringify clorox output ([#197](https://github.com/sveltejs/sapper/pull/197))
## 0.9.4
* Add `SAPPER_BASE` and `SAPPER_APP` environment variables ([#181](https://github.com/sveltejs/sapper/issues/181))
* Minify template in `sapper build` ([#15](https://github.com/sveltejs/sapper/issues/15))
* Minify all HTML files in `sapper export` ([#172](https://github.com/sveltejs/sapper/issues/172))
* Log exported files ([#195](https://github.com/sveltejs/sapper/pull/195))
* Add `--open`/`-o` flag to `sapper dev` and `sapper start` ([#186](https://github.com/sveltejs/sapper/issues/186))
## 0.9.3
* Fix path to `sapper-dev-client`
## 0.9.2 ## 0.9.2
* Include `dist` files in package * Include `dist` files in package

View File

@@ -1,2 +0,0 @@
// TODO write to this file, instead of middleware.ts.js
module.exports = require('./dist/middleware.ts.js');

View File

@@ -1,8 +1,8 @@
{ {
"name": "sapper", "name": "sapper",
"version": "0.9.2", "version": "0.10.3",
"description": "Military-grade apps, engineered by Svelte", "description": "Military-grade apps, engineered by Svelte",
"main": "middleware.js", "main": "dist/middleware.ts.js",
"bin": { "bin": {
"sapper": "./sapper" "sapper": "./sapper"
}, },
@@ -21,45 +21,49 @@
"cheerio": "^1.0.0-rc.2", "cheerio": "^1.0.0-rc.2",
"chokidar": "^1.7.0", "chokidar": "^1.7.0",
"clorox": "^1.0.3", "clorox": "^1.0.3",
"cookie": "^0.3.1",
"devalue": "^1.0.1", "devalue": "^1.0.1",
"glob": "^7.1.2", "glob": "^7.1.2",
"html-minifier": "^3.5.11",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"node-fetch": "^1.7.3", "node-fetch": "^2.1.1",
"polka": "^0.3.4", "port-authority": "^1.0.2",
"port-authority": "^1.0.0", "pretty-bytes": "^4.0.2",
"pretty-ms": "^3.1.0", "pretty-ms": "^3.1.0",
"require-relative": "^0.8.7", "require-relative": "^0.8.7",
"rimraf": "^2.6.2", "rimraf": "^2.6.2",
"sade": "^1.4.0", "sade": "^1.4.0",
"sander": "^0.6.0", "sander": "^0.6.0",
"source-map-support": "^0.5.3", "source-map-support": "^0.5.4",
"tslib": "^1.9.0", "tslib": "^1.9.0",
"url-parse": "^1.2.0", "url-parse": "^1.2.0",
"walk-sync": "^0.3.2", "webpack-format-messages": "^1.0.2"
"webpack-format-messages": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"@std/esm": "^0.19.7", "@std/esm": "^0.25.3",
"@types/glob": "^5.0.34", "@types/glob": "^5.0.34",
"@types/mkdirp": "^0.5.2", "@types/mkdirp": "^0.5.2",
"@types/rimraf": "^2.0.2", "@types/rimraf": "^2.0.2",
"compression": "^1.7.1", "compression": "^1.7.1",
"eslint": "^4.13.1", "eslint": "^4.13.1",
"eslint-plugin-import": "^2.8.0", "eslint-plugin-import": "^2.8.0",
"express": "^4.16.3",
"get-port": "^3.2.0", "get-port": "^3.2.0",
"mocha": "^4.0.1", "mocha": "^5.0.4",
"nightmare": "^2.10.0", "nightmare": "^3.0.0",
"npm-run-all": "^4.1.2", "npm-run-all": "^4.1.2",
"rollup": "^0.56.5", "polka": "^0.3.4",
"rollup-plugin-commonjs": "^8.3.0", "rollup": "^0.57.0",
"rollup-plugin-commonjs": "^9.1.0",
"rollup-plugin-json": "^2.3.0", "rollup-plugin-json": "^2.3.0",
"rollup-plugin-string": "^2.0.2", "rollup-plugin-string": "^2.0.2",
"rollup-plugin-typescript": "^0.8.1", "rollup-plugin-typescript": "^0.8.1",
"serve-static": "^1.13.2", "serve-static": "^1.13.2",
"svelte": "^1.49.1", "svelte": "^1.57.4",
"svelte-loader": "^2.3.2", "svelte-loader": "^2.5.1",
"ts-node": "^4.1.0", "ts-node": "^5.0.1",
"typescript": "^2.6.2", "typescript": "^2.6.2",
"walk-sync": "^0.3.2",
"webpack": "^4.1.0" "webpack": "^4.1.0"
}, },
"scripts": { "scripts": {

View File

@@ -1,271 +0,0 @@
function detach(node) {
node.parentNode.removeChild(node);
}
function findAnchor(node) {
while (node && node.nodeName.toUpperCase() !== 'A')
node = node.parentNode; // SVG <a> elements have a lowercase name
return node;
}
function which(event) {
return event.which === null ? event.button : event.which;
}
function scroll_state() {
return {
x: window.scrollX,
y: window.scrollY
};
}
var component;
var target;
var routes;
var errors;
var history = typeof window !== 'undefined' ? window.history : {
pushState: function (state, title, href) { },
replaceState: function (state, title, href) { },
scrollRestoration: ''
};
var scroll_history = {};
var uid = 1;
var cid;
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
function select_route(url) {
if (url.origin !== window.location.origin)
return null;
var _loop_1 = function (route) {
var match = route.pattern.exec(url.pathname);
if (match) {
if (route.ignore)
return { value: null };
var params = route.params(match);
var query_1 = {};
if (url.search.length > 0) {
url.search.slice(1).split('&').forEach(function (searchParam) {
var _a = /([^=]+)=(.*)/.exec(searchParam), key = _a[1], value = _a[2];
query_1[key] = value || true;
});
}
return { value: { url: url, route: route, data: { params: params, query: query_1 } } };
}
};
for (var _i = 0, routes_1 = routes; _i < routes_1.length; _i++) {
var route = routes_1[_i];
var state_1 = _loop_1(route);
if (typeof state_1 === "object")
return state_1.value;
}
}
var current_token;
function render(Component, data, scroll, token) {
if (current_token !== token)
return;
if (component) {
component.destroy();
}
else {
// first load — remove SSR'd <head> contents
var start = document.querySelector('#sapper-head-start');
var end = document.querySelector('#sapper-head-end');
if (start && end) {
while (start.nextSibling !== end)
detach(start.nextSibling);
detach(start);
detach(end);
}
}
component = new Component({
target: target,
data: data,
hydrate: !component
});
if (scroll) {
window.scrollTo(scroll.x, scroll.y);
}
}
function prepare_route(Component, data) {
var redirect = null;
var error = null;
if (!Component.preload) {
return { Component: Component, data: data, redirect: redirect, error: error };
}
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) {
return { Component: Component, data: Object.assign(data, window.__SAPPER__.preloaded), redirect: redirect, error: error };
}
return Promise.resolve(Component.preload.call({
redirect: function (statusCode, location) {
redirect = { statusCode: statusCode, location: location };
},
error: function (statusCode, message) {
error = { statusCode: statusCode, message: message };
}
}, data))["catch"](function (err) {
error = { statusCode: 500, message: err };
}).then(function (preloaded) {
if (error) {
var route = error.statusCode >= 400 && error.statusCode < 500
? errors['4xx']
: errors['5xx'];
return route.load().then(function (_a) {
var Component = _a["default"];
var err = error.message instanceof Error ? error.message : new Error(error.message);
Object.assign(data, { status: error.statusCode, error: err });
return { Component: Component, data: data, redirect: null };
});
}
Object.assign(data, preloaded);
return { Component: Component, data: data, redirect: redirect };
});
}
function navigate(target, id) {
if (id) {
// popstate or initial navigation
cid = id;
}
else {
// clicked on a link. preserve scroll state
scroll_history[cid] = scroll_state();
id = cid = ++uid;
scroll_history[cid] = { x: 0, y: 0 };
}
cid = id;
var loaded = prefetching && prefetching.href === target.url.href ?
prefetching.promise :
target.route.load().then(function (mod) { return prepare_route(mod["default"], target.data); });
prefetching = null;
var token = current_token = {};
return loaded.then(function (_a) {
var Component = _a.Component, data = _a.data, redirect = _a.redirect;
if (redirect) {
return goto(redirect.location, { replaceState: true });
}
render(Component, data, scroll_history[id], token);
});
}
function handle_click(event) {
// Adapted from https://github.com/visionmedia/page.js
// MIT license https://github.com/visionmedia/page.js#license
if (which(event) !== 1)
return;
if (event.metaKey || event.ctrlKey || event.shiftKey)
return;
if (event.defaultPrevented)
return;
var a = findAnchor(event.target);
if (!a)
return;
// check if link is inside an svg
// in this case, both href and target are always inside an object
var svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString';
var href = String(svg ? a.href.baseVal : a.href);
if (href === window.location.href) {
event.preventDefault();
return;
}
// Ignore if tag has
// 1. 'download' attribute
// 2. rel='external' attribute
if (a.hasAttribute('download') || a.getAttribute('rel') === 'external')
return;
// Ignore if <a> has a target
if (svg ? a.target.baseVal : a.target)
return;
var url = new URL(href);
// Don't handle hash changes
if (url.pathname === window.location.pathname && url.search === window.location.search)
return;
var target = select_route(url);
if (target) {
navigate(target, null);
event.preventDefault();
history.pushState({ id: cid }, '', url.href);
}
}
function handle_popstate(event) {
scroll_history[cid] = scroll_state();
if (event.state) {
var url = new URL(window.location.href);
var target_1 = select_route(url);
navigate(target_1, event.state.id);
}
else {
// hashchange
cid = ++uid;
history.replaceState({ id: cid }, '', window.location.href);
}
}
var prefetching = null;
function prefetch(href) {
var selected = select_route(new URL(href));
if (selected) {
prefetching = {
href: href,
promise: selected.route.load().then(function (mod) { return prepare_route(mod["default"], selected.data); })
};
}
}
function handle_touchstart_mouseover(event) {
var a = findAnchor(event.target);
if (!a || a.rel !== 'prefetch')
return;
prefetch(a.href);
}
var inited;
function init(_target, _routes) {
target = _target;
routes = _routes.filter(function (r) { return !r.error; });
errors = {
'4xx': _routes.find(function (r) { return r.error === '4xx'; }),
'5xx': _routes.find(function (r) { return r.error === '5xx'; })
};
if (!inited) {
window.addEventListener('click', handle_click);
window.addEventListener('popstate', handle_popstate);
// prefetch
window.addEventListener('touchstart', handle_touchstart_mouseover);
window.addEventListener('mouseover', handle_touchstart_mouseover);
inited = true;
}
return Promise.resolve().then(function () {
var _a = window.location, hash = _a.hash, href = _a.href;
var deep_linked = hash && document.getElementById(hash.slice(1));
scroll_history[uid] = deep_linked ?
{ x: 0, y: deep_linked.getBoundingClientRect().top } :
scroll_state();
history.replaceState({ id: uid }, '', href);
var target = select_route(new URL(window.location.href));
return navigate(target, uid);
});
}
function goto(href, opts) {
if (opts === void 0) { opts = { replaceState: false }; }
var target = select_route(new URL(href, window.location.href));
if (target) {
navigate(target, null);
if (history)
history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);
}
else {
window.location.href = href;
}
}
function prefetchRoutes(pathnames) {
if (!routes)
throw new Error("You must call init() first");
return routes
.filter(function (route) {
if (!pathnames)
return true;
return pathnames.some(function (pathname) {
return route.error
? route.error === pathname
: route.pattern.test(pathname);
});
})
.reduce(function (promise, route) {
return promise.then(route.load);
}, Promise.resolve());
}
export { component, prefetch, init, goto, prefetchRoutes, prefetchRoutes as preloadRoutes };

View File

@@ -13,7 +13,8 @@ const prog = sade('sapper').version(pkg.version);
prog.command('dev') prog.command('dev')
.describe('Start a development server') .describe('Start a development server')
.option('-p, --port', 'Specify a port') .option('-p, --port', 'Specify a port')
.action(async (opts: { port: number }) => { .option('-o, --open', 'Open a browser window')
.action(async (opts: { port: number, open: boolean }) => {
const { dev } = await import('./cli/dev'); const { dev } = await import('./cli/dev');
dev(opts); dev(opts);
}); });
@@ -40,14 +41,16 @@ prog.command('build [dest]')
prog.command('start [dir]') prog.command('start [dir]')
.describe('Start your app') .describe('Start your app')
.option('-p, --port', 'Specify a port') .option('-p, --port', 'Specify a port')
.action(async (dir = 'build', opts: { port: number }) => { .option('-o, --open', 'Open a browser window')
.action(async (dir = 'build', opts: { port: number, open: boolean }) => {
const { start } = await import('./cli/start'); const { start } = await import('./cli/start');
start(dir, opts); start(dir, opts);
}); });
prog.command('export [dest]') prog.command('export [dest]')
.describe('Export your app as static files (if possible)') .describe('Export your app as static files (if possible)')
.action(async (dest = 'export') => { .option('--basepath', 'Specify a base path')
.action(async (dest = 'export', opts: { basepath?: string }) => {
console.log(`> Building...`); console.log(`> Building...`);
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
@@ -58,10 +61,10 @@ prog.command('export [dest]')
try { try {
const { build } = await import('./cli/build'); const { build } = await import('./cli/build');
await build(); await build();
console.error(`\n> Built in ${elapsed(start)}. Exporting...`); console.error(`\n> Built in ${elapsed(start)}. Crawling site...`);
const { exporter } = await import('./cli/export'); const { exporter } = await import('./cli/export');
await exporter(dest); await exporter(dest, opts);
console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(`npx serve ${dest}`)} to run the app.`); console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(`npx serve ${dest}`)} to run the app.`);
} catch (err) { } catch (err) {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error'); console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');

View File

@@ -3,42 +3,54 @@ import * as path from 'path';
import * as clorox from 'clorox'; import * as clorox from 'clorox';
import mkdirp from 'mkdirp'; import mkdirp from 'mkdirp';
import rimraf from 'rimraf'; import rimraf from 'rimraf';
import { create_compilers, create_app, create_routes, create_serviceworker } from '../core' import { minify_html } from './utils/minify_html';
import { src, dest, dev } from '../config'; import { create_compilers, create_main_manifests, create_routes, create_serviceworker_manifest } from '../core'
import { locations } from '../config';
export async function build() { export async function build() {
const output = dest(); const output = locations.dest();
mkdirp.sync(output); mkdirp.sync(output);
rimraf.sync(path.join(output, '**/*')); rimraf.sync(path.join(output, '**/*'));
// minify app/template.html
// TODO compile this to a function? could be quicker than str.replace(...).replace(...).replace(...)
const template = fs.readFileSync(`${locations.app()}/template.html`, 'utf-8');
// remove this in a future version
if (template.indexOf('%sapper.base%') === -1) {
console.log(`${clorox.bold.red(`> As of Sapper v0.10, your template.html file must include %sapper.base% in the <head>`)}`);
process.exit(1);
}
fs.writeFileSync(`${output}/template.html`, minify_html(template));
const routes = create_routes(); const routes = create_routes();
// create app/manifest/client.js and app/manifest/server.js // create app/manifest/client.js and app/manifest/server.js
create_app({ routes, src, dev }); create_main_manifests({ routes });
const { client, server, serviceworker } = create_compilers(); const { client, server, serviceworker } = create_compilers();
const client_stats = await compile(client); const client_stats = await compile(client);
console.log(clorox.inverse(`\nbuilt client`).toString()); console.log(`${clorox.inverse(`\nbuilt client`)}`);
console.log(client_stats.toString({ colors: true })); console.log(client_stats.toString({ colors: true }));
fs.writeFileSync(path.join(output, 'client_info.json'), JSON.stringify(client_stats.toJson())); fs.writeFileSync(path.join(output, 'client_info.json'), JSON.stringify(client_stats.toJson()));
const server_stats = await compile(server); const server_stats = await compile(server);
console.log(clorox.inverse(`\nbuilt server`).toString()); console.log(`${clorox.inverse(`\nbuilt server`)}`);
console.log(server_stats.toString({ colors: true })); console.log(server_stats.toString({ colors: true }));
let serviceworker_stats; let serviceworker_stats;
if (serviceworker) { if (serviceworker) {
create_serviceworker({ create_serviceworker_manifest({
routes, routes,
client_files: client_stats.toJson().assets.map((chunk: { name: string }) => `/client/${chunk.name}`), client_files: client_stats.toJson().assets.map((chunk: { name: string }) => `client/${chunk.name}`)
src
}); });
serviceworker_stats = await compile(serviceworker); serviceworker_stats = await compile(serviceworker);
console.log(clorox.inverse(`\nbuilt service worker`).toString()); console.log(`${clorox.inverse(`\nbuilt service worker`)}`);
console.log(serviceworker_stats.toString({ colors: true })); console.log(serviceworker_stats.toString({ colors: true }));
} }
} }

View File

@@ -9,8 +9,8 @@ import rimraf from 'rimraf';
import format_messages from 'webpack-format-messages'; import format_messages from 'webpack-format-messages';
import prettyMs from 'pretty-ms'; import prettyMs from 'pretty-ms';
import * as ports from 'port-authority'; import * as ports from 'port-authority';
import { dest } from '../config'; import { locations } from '../config';
import { create_compilers, create_app, create_routes, create_serviceworker } from '../core'; import { create_compilers, create_main_manifests, create_routes, create_serviceworker_manifest } from '../core';
type Deferred = { type Deferred = {
promise?: Promise<any>; promise?: Promise<any>;
@@ -70,37 +70,44 @@ function create_hot_update_server(port: number, interval = 10000) {
return { send }; return { send };
} }
export async function dev(opts: { port: number }) { export async function dev(opts: { port: number, open: boolean }) {
// remove this in a future version
const template = fs.readFileSync(path.join(locations.app(), 'template.html'), 'utf-8');
if (template.indexOf('%sapper.base%') === -1) {
console.log(`${clorox.bold.red(`> As of Sapper v0.10, your template.html file must include %sapper.base% in the <head>`)}`);
process.exit(1);
}
process.env.NODE_ENV = 'development'; process.env.NODE_ENV = 'development';
let port = opts.port || +process.env.PORT; let port = opts.port || +process.env.PORT;
if (port) { if (port) {
if (!await ports.check(port)) { if (!await ports.check(port)) {
console.log(clorox.bold.red(`> Port ${port} is unavailable`)); console.log(`${clorox.bold.red(`> Port ${port} is unavailable`)}`);
return; return;
} }
} else { } else {
port = await ports.find(3000); port = await ports.find(3000);
} }
const dir = dest(); const dir = locations.dest();
rimraf.sync(dir); rimraf.sync(dir);
mkdirp.sync(dir); mkdirp.sync(dir);
const dev_port = await ports.find(10000); const dev_port = await ports.find(10000);
const routes = create_routes(); const routes = create_routes();
create_app({ routes, dev_port }); create_main_manifests({ routes, dev_port });
const hot_update_server = create_hot_update_server(dev_port); const hot_update_server = create_hot_update_server(dev_port);
watch_files('routes/**/*', ['add', 'unlink'], () => { watch_files(`${locations.routes()}/**/*`, ['add', 'unlink'], () => {
const routes = create_routes(); const routes = create_routes();
create_app({ routes, dev_port }); create_main_manifests({ routes, dev_port });
}); });
watch_files('app/template.html', ['change'], () => { watch_files(`${locations.app()}/template.html`, ['change'], () => {
hot_update_server.send({ hot_update_server.send({
action: 'reload' action: 'reload'
}); });
@@ -155,15 +162,15 @@ export async function dev(opts: { port: number }) {
compiler.watch({}, (err: Error, stats: any) => { compiler.watch({}, (err: Error, stats: any) => {
if (err) { if (err) {
console.error(clorox.red(`${name}`)); console.log(`${clorox.red(`${name}`)}`);
console.error(clorox.red(err.message)); console.log(`${clorox.red(err.message)}`);
error(err); error(err);
} else { } else {
const messages = format_messages(stats); const messages = format_messages(stats);
const info = stats.toJson(); const info = stats.toJson();
if (messages.errors.length > 0) { if (messages.errors.length > 0) {
console.log(clorox.bold.red(`${name}`)); console.log(`${clorox.bold.red(`${name}`)}`);
const filtered = messages.errors.filter((message: string) => { const filtered = messages.errors.filter((message: string) => {
return !build.unique_errors.has(message); return !build.unique_errors.has(message);
@@ -180,7 +187,7 @@ export async function dev(opts: { port: number }) {
} }
} else { } else {
if (messages.warnings.length > 0) { if (messages.warnings.length > 0) {
console.log(clorox.bold.yellow(`${name}`)); console.log(`${clorox.bold.yellow(`${name}`)}`);
const filtered = messages.warnings.filter((message: string) => { const filtered = messages.warnings.filter((message: string) => {
return !build.unique_warnings.has(message); return !build.unique_warnings.has(message);
@@ -241,6 +248,8 @@ export async function dev(opts: { port: number }) {
} }
}); });
let first = true;
watch(compilers.client, { watch(compilers.client, {
name: 'client', name: 'client',
@@ -257,15 +266,21 @@ export async function dev(opts: { port: number }) {
fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify(info, null, ' ')); fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify(info, null, ' '));
deferreds.client.fulfil(); deferreds.client.fulfil();
const client_files = info.assets.map((chunk: { name: string }) => `/client/${chunk.name}`); const client_files = info.assets.map((chunk: { name: string }) => `client/${chunk.name}`);
deferreds.server.promise.then(() => { deferreds.server.promise.then(() => {
hot_update_server.send({ hot_update_server.send({
status: 'completed' status: 'completed'
}); });
if (first) {
first = false;
console.log(`${clorox.bold.cyan(`> Listening on http://localhost:${port}`)}`);
if (opts.open) child_process.exec(`open http://localhost:${port}`);
}
}); });
create_serviceworker({ create_serviceworker_manifest({
routes: create_routes(), routes: create_routes(),
client_files client_files
}); });

View File

@@ -1,14 +1,19 @@
import * as child_process from 'child_process'; import * as child_process from 'child_process';
import * as path from 'path'; import * as path from 'path';
import * as sander from 'sander'; import * as sander from 'sander';
import * as clorox from 'clorox';
import cheerio from 'cheerio'; import cheerio from 'cheerio';
import URL from 'url-parse'; import URL from 'url-parse';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import * as ports from 'port-authority'; import * as ports from 'port-authority';
import { dest } from '../config'; import prettyBytes from 'pretty-bytes';
import { minify_html } from './utils/minify_html';
import { locations } from '../config';
export async function exporter(export_dir: string) { export async function exporter(export_dir: string, { basepath = '' }) {
const build_dir = dest(); const build_dir = locations.dest();
export_dir = path.join(export_dir, basepath);
// Prep output directory // Prep output directory
sander.rimrafSync(export_dir); sander.rimrafSync(export_dir);
@@ -20,6 +25,10 @@ export async function exporter(export_dir: string) {
sander.copyFileSync(build_dir, 'service-worker.js').to(export_dir, 'service-worker.js'); sander.copyFileSync(build_dir, 'service-worker.js').to(export_dir, 'service-worker.js');
} }
if (sander.existsSync(build_dir, 'service-worker.js.map')) {
sander.copyFileSync(build_dir, 'service-worker.js.map').to(export_dir, 'service-worker.js.map');
}
const port = await ports.find(3000); const port = await ports.find(3000);
const origin = `http://localhost:${port}`; const origin = `http://localhost:${port}`;
@@ -40,49 +49,58 @@ export async function exporter(export_dir: string) {
proc.on('message', message => { proc.on('message', message => {
if (!message.__sapper__) return; if (!message.__sapper__) return;
const url = new URL(message.url, origin); let file = new URL(message.url, origin).pathname.slice(1);
let { body } = message;
if (saved.has(url.pathname)) return; if (saved.has(file)) return;
saved.add(url.pathname); saved.add(file);
if (message.type === 'text/html') { const is_html = message.type === 'text/html';
const file = `${export_dir}/${url.pathname}/index.html`;
sander.writeFileSync(file, message.body); if (is_html) {
} else { file = file === '' ? 'index.html' : `${file}/index.html`;
const file = `${export_dir}/${url.pathname}`; body = minify_html(body);
sander.writeFileSync(file, message.body);
} }
console.log(`${clorox.bold.cyan(file)} ${clorox.gray(`(${prettyBytes(body.length)})`)}`);
sander.writeFileSync(export_dir, file, body);
}); });
function handle(url: URL) { async function handle(url: URL) {
if (url.origin !== origin) return; const r = await fetch(url.href);
const range = ~~(r.status / 100);
if (seen.has(url.pathname)) return; if (range >= 4) {
seen.add(url.pathname); console.log(`${clorox.red(`> Received ${r.status} response when fetching ${url.pathname}`)}`);
return;
}
return fetch(url.href) if (range === 2) {
.then(r => { if (r.headers.get('Content-Type') === 'text/html') {
if (r.headers.get('Content-Type') === 'text/html') { const body = await r.text();
return r.text().then((body: string) => { const $ = cheerio.load(body);
const $ = cheerio.load(body); const urls: URL[] = [];
const hrefs: string[] = [];
$('a[href]').each((i: number, $a) => { const base = new URL($('base').attr('href') || '/', url.href);
hrefs.push($a.attribs.href);
});
return hrefs.reduce((promise, href) => { $('a[href]').each((i: number, $a) => {
return promise.then(() => handle(new URL(href, url.href))); const url = new URL($a.attribs.href, base.href);
}, Promise.resolve());
}); if (url.origin === origin && !seen.has(url.pathname)) {
seen.add(url.pathname);
urls.push(url);
}
});
for (const url of urls) {
await handle(url);
} }
}) }
.catch((err: Error) => { }
console.error(`Error rendering ${url.pathname}: ${err.message}`);
});
} }
return ports.wait(port) return ports.wait(port)
.then(() => handle(new URL(origin))) // TODO all static routes .then(() => handle(new URL(`/${basepath}`, origin))) // TODO all static routes
.then(() => proc.kill()); .then(() => proc.kill());
} }

View File

@@ -4,7 +4,7 @@ import * as child_process from 'child_process';
import * as clorox from 'clorox'; import * as clorox from 'clorox';
import * as ports from 'port-authority'; import * as ports from 'port-authority';
export async function start(dir: string, opts: { port: number }) { export async function start(dir: string, opts: { port: number, open: boolean }) {
let port = opts.port || +process.env.PORT; let port = opts.port || +process.env.PORT;
const resolved = path.resolve(dir); const resolved = path.resolve(dir);
@@ -32,4 +32,8 @@ export async function start(dir: string, opts: { port: number }) {
SAPPER_DEST: dir SAPPER_DEST: dir
}, process.env) }, process.env)
}); });
await ports.wait(port);
console.log(`${clorox.bold.cyan(`> Listening on http://localhost:${port}`)}`);
if (opts.open) child_process.exec(`open http://localhost:${port}`);
} }

View File

@@ -27,10 +27,10 @@ async function upgrade_sapper_main() {
if (/\%sapper\.main\%/.test(template)) { if (/\%sapper\.main\%/.test(template)) {
if (!pattern.test(template)) { if (!pattern.test(template)) {
console.log(clorox.red(`Could not replace %sapper.main% in ${file}`)); console.log(`${clorox.red(`Could not replace %sapper.main% in ${file}`)}`);
} else { } else {
write(file, template.replace(pattern, `%sapper.scripts%`)); write(file, template.replace(pattern, `%sapper.scripts%`));
console.log(clorox.green(`Replaced %sapper.main% in ${file}`)); console.log(`${clorox.green(`Replaced %sapper.main% in ${file}`)}`);
replaced = true; replaced = true;
} }
} }

View File

@@ -0,0 +1,21 @@
import { minify } from 'html-minifier';
export function minify_html(html: string) {
return minify(html, {
collapseBooleanAttributes: true,
collapseWhitespace: true,
conservativeCollapse: true,
decodeEntities: true,
html5: true,
minifyCSS: true,
minifyJS: true,
removeAttributeQuotes: true,
removeComments: true,
removeOptionalTags: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
sortAttributes: true,
sortClassName: true
});
}

View File

@@ -1,5 +1,10 @@
import * as path from 'path'; import * as path from 'path';
export const dev = () => process.env.NODE_ENV !== 'production'; export const dev = () => process.env.NODE_ENV !== 'production';
export const src = () => path.resolve(process.env.SAPPER_ROUTES || 'routes');
export const dest = () => path.resolve(process.env.SAPPER_DEST || '.sapper'); export const locations = {
base: () => path.resolve(process.env.SAPPER_BASE || ''),
app: () => path.resolve(process.env.SAPPER_BASE || '', process.env.SAPPER_APP || 'app'),
routes: () => path.resolve(process.env.SAPPER_BASE || '', process.env.SAPPER_ROUTES || 'routes'),
dest: () => path.resolve(process.env.SAPPER_BASE || '', process.env.SAPPER_DEST || '.sapper')
};

View File

@@ -1,4 +1,3 @@
export { default as create_app } from './core/create_app'; export * from './core/create_manifests';
export { default as create_serviceworker } from './core/create_serviceworker';
export { default as create_compilers } from './core/create_compilers'; export { default as create_compilers } from './core/create_compilers';
export { default as create_routes } from './core/create_routes'; export { default as create_routes } from './core/create_routes';

View File

@@ -1,36 +1,45 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import mkdirp from 'mkdirp'; import * as glob from 'glob';
import create_routes from './create_routes'; import create_routes from './create_routes';
import { fudge_mtime, posixify, write } from './utils'; import { posixify, write_if_changed } from './utils';
import { dev } from '../config'; import { dev, locations } from '../config';
import { Route } from '../interfaces'; import { Route } from '../interfaces';
// in dev mode, we avoid touching the fs unnecessarily export function create_main_manifests({ routes, dev_port }: {
let last_client_manifest: string = null;
let last_server_manifest: string = null;
export default function create_app({ routes, dev_port }: {
routes: Route[]; routes: Route[];
dev_port: number; dev_port?: number;
}) { }) {
mkdirp.sync('app/manifest'); const path_to_routes = path.relative(`${locations.app()}/manifest`, locations.routes());
const client_manifest = generate_client(routes, dev_port); const client_manifest = generate_client(routes, path_to_routes, dev_port);
const server_manifest = generate_server(routes); const server_manifest = generate_server(routes, path_to_routes);
if (client_manifest !== last_client_manifest) { write_if_changed(`${locations.app()}/manifest/client.js`, client_manifest);
write(`app/manifest/client.js`, client_manifest); write_if_changed(`${locations.app()}/manifest/server.js`, server_manifest);
last_client_manifest = client_manifest;
}
if (server_manifest !== last_server_manifest) {
write(`app/manifest/server.js`, server_manifest);
last_server_manifest = server_manifest;
}
} }
function generate_client(routes: Route[], dev_port?: number) { export function create_serviceworker_manifest({ routes, client_files }: {
routes: Route[];
client_files: string[];
}) {
const assets = glob.sync('**', { cwd: 'assets', nodir: true });
let code = `
// This file is generated by Sapper — do not edit it!
export const timestamp = ${Date.now()};
export const assets = [\n\t${assets.map((x: string) => `"${x}"`).join(',\n\t')}\n];
export const shell = [\n\t${client_files.map((x: string) => `"${x}"`).join(',\n\t')}\n];
export const routes = [\n\t${routes.filter((r: Route) => r.type === 'page' && !/^_[45]xx$/.test(r.id)).map((r: Route) => `{ 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: Route[], path_to_routes: string, dev_port?: number) {
let code = ` let code = `
// This file is generated by Sapper — do not edit it! // This file is generated by Sapper — do not edit it!
export const routes = [ export const routes = [
@@ -40,7 +49,7 @@ function generate_client(routes: Route[], dev_port?: number) {
return `{ pattern: ${route.pattern}, ignore: true }`; return `{ pattern: ${route.pattern}, ignore: true }`;
} }
const file = posixify(`../../routes/${route.file}`); const file = posixify(`${path_to_routes}/${route.file}`);
if (route.id === '_4xx' || route.id === '_5xx') { if (route.id === '_4xx' || route.id === '_5xx') {
return `{ error: '${route.id.slice(1)}', load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`; return `{ error: '${route.id.slice(1)}', load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`;
@@ -57,7 +66,7 @@ function generate_client(routes: Route[], dev_port?: number) {
if (dev()) { if (dev()) {
const sapper_dev_client = posixify( const sapper_dev_client = posixify(
path.resolve(__dirname, 'sapper-dev-client.js') path.resolve(__dirname, '../sapper-dev-client.js')
); );
code += ` code += `
@@ -72,12 +81,12 @@ function generate_client(routes: Route[], dev_port?: number) {
return code; return code;
} }
function generate_server(routes: Route[]) { function generate_server(routes: Route[], path_to_routes: string) {
let code = ` let code = `
// This file is generated by Sapper — do not edit it! // This file is generated by Sapper — do not edit it!
${routes ${routes
.map(route => { .map(route => {
const file = posixify(`../../routes/${route.file}`); const file = posixify(`${path_to_routes}/${route.file}`);
return route.type === 'page' return route.type === 'page'
? `import ${route.id} from '${file}';` ? `import ${route.id} from '${file}';`
: `import * as ${route.id} from '${file}';`; : `import * as ${route.id} from '${file}';`;

View File

@@ -1,9 +1,9 @@
import * as path from 'path'; import * as path from 'path';
import glob from 'glob'; import glob from 'glob';
import { src } from '../config'; import { locations } from '../config';
import { Route } from '../interfaces'; import { Route } from '../interfaces';
export default function create_routes({ files } = { files: glob.sync('**/*.*', { cwd: src(), nodir: true }) }) { export default function create_routes({ files } = { files: glob.sync('**/*.*', { cwd: locations.routes(), nodir: true }) }) {
const routes: Route[] = files const routes: Route[] = files
.map((file: string) => { .map((file: string) => {
if (/(^|\/|\\)_/.test(file)) return; if (/(^|\/|\\)_/.test(file)) return;
@@ -102,7 +102,10 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', {
} }
if (!a_sub_part.dynamic && a_sub_part.content !== b_sub_part.content) { if (!a_sub_part.dynamic && a_sub_part.content !== b_sub_part.content) {
return b_sub_part.content.length - a_sub_part.content.length; return (
(b_sub_part.content.length - a_sub_part.content.length) ||
(a_sub_part.content < b_sub_part.content ? -1 : 1)
);
} }
} }
} }

View File

@@ -1,26 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import glob from 'glob';
import create_routes from './create_routes';
import { fudge_mtime, posixify, write } from './utils';
import { Route } from '../interfaces';
export default function create_serviceworker({ routes, client_files }: {
routes: Route[];
client_files: string[];
}) {
const assets = glob.sync('**', { cwd: 'assets', nodir: true });
let code = `
// This file is generated by Sapper — do not edit it!
export const timestamp = ${Date.now()};
export const assets = [\n\t${assets.map((x: string) => `"${x}"`).join(',\n\t')}\n];
export const shell = [\n\t${client_files.map((x: string) => `"${x}"`).join(',\n\t')}\n];
export const routes = [\n\t${routes.filter((r: Route) => r.type === 'page' && !/^_[45]xx$/.test(r.id)).map((r: Route) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n];
`.replace(/^\t\t/gm, '').trim();
write('app/manifest/service-worker.js', code);
}

View File

@@ -1,8 +1,13 @@
import * as fs from 'fs'; import * as sander from 'sander';
export function write(file: string, code: string) { const previous_contents = new Map();
fs.writeFileSync(file, code);
fudge_mtime(file); export function write_if_changed(file: string, code: string) {
if (code !== previous_contents.get(file)) {
previous_contents.set(file, code);
sander.writeFileSync(file, code);
fudge_mtime(file);
}
} }
export function posixify(file: string) { export function posixify(file: string) {
@@ -11,8 +16,8 @@ export function posixify(file: string) {
export function fudge_mtime(file: string) { export function fudge_mtime(file: string) {
// need to fudge the mtime so that webpack doesn't go doolally // need to fudge the mtime so that webpack doesn't go doolally
const { atime, mtime } = fs.statSync(file); const { atime, mtime } = sander.statSync(file);
fs.utimesSync( sander.utimesSync(
file, file,
new Date(atime.getTime() - 999999), new Date(atime.getTime() - 999999),
new Date(mtime.getTime() - 999999) new Date(mtime.getTime() - 999999)

View File

@@ -13,3 +13,7 @@ export type Template = {
render: (data: Record<string, string>) => string; render: (data: Record<string, string>) => string;
stream: (req, res, data: Record<string, string | Promise<string>>) => void; stream: (req, res, data: Record<string, string | Promise<string>>) => void;
}; };
export type Store = {
get: () => any;
};

View File

@@ -1,12 +1,15 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { resolve, URL } from 'url';
import { ClientRequest, ServerResponse } from 'http'; import { ClientRequest, ServerResponse } from 'http';
import cookie from 'cookie';
import mkdirp from 'mkdirp'; import mkdirp from 'mkdirp';
import rimraf from 'rimraf'; import rimraf from 'rimraf';
import devalue from 'devalue'; import devalue from 'devalue';
import fetch from 'node-fetch';
import { lookup } from './middleware/mime'; import { lookup } from './middleware/mime';
import { create_routes, templates, create_compilers } from './core/index'; import { create_routes, create_compilers } from './core';
import { dest, dev } from './config'; import { locations, dev } from './config';
import { Route, Template } from './interfaces'; import { Route, Template } from './interfaces';
import sourceMapSupport from 'source-map-support'; import sourceMapSupport from 'source-map-support';
@@ -18,7 +21,7 @@ type RouteObject = {
pattern: RegExp; pattern: RegExp;
params: (match: RegExpMatchArray) => Record<string, string>; params: (match: RegExpMatchArray) => Record<string, string>;
module: { module: {
render: (data: any) => { render: (data: any, opts: { store: Store }) => {
head: string; head: string;
css: { code: string, map: any }; css: { code: string, map: any };
html: string html: string
@@ -30,23 +33,40 @@ type RouteObject = {
type Handler = (req: Req, res: ServerResponse, next: () => void) => void; type Handler = (req: Req, res: ServerResponse, next: () => void) => void;
type Store = {
get: () => any
};
interface Req extends ClientRequest { interface Req extends ClientRequest {
url: string; url: string;
baseUrl: string;
originalUrl: string;
method: string; method: string;
pathname: string; path: string;
params: Record<string, string>; params: Record<string, string>;
headers: Record<string, string>;
} }
export default function middleware({ routes }: { export default function middleware({ routes, store }: {
routes: RouteObject[] routes: RouteObject[],
store: (req: Req) => Store
}) { }) {
const output = dest(); const output = locations.dest();
const client_info = JSON.parse(fs.readFileSync(path.join(output, 'client_info.json'), 'utf-8')); const client_info = JSON.parse(fs.readFileSync(path.join(output, 'client_info.json'), 'utf-8'));
const middleware = compose_handlers([ const middleware = compose_handlers([
(req: Req, res: ServerResponse, next: () => void) => { (req: Req, res: ServerResponse, next: () => void) => {
req.pathname = req.url.replace(/\?.*/, ''); if (req.baseUrl === undefined) {
req.baseUrl = req.originalUrl
? req.originalUrl.slice(0, -req.url.length)
: '';
}
if (req.path === undefined) {
req.path = req.url.replace(/\?.*/, '');
}
next(); next();
}, },
@@ -60,12 +80,17 @@ export default function middleware({ routes }: {
cache_control: 'max-age=600' cache_control: 'max-age=600'
}), }),
fs.existsSync(path.join(output, 'service-worker.js.map')) && serve({
pathname: '/service-worker.js.map',
cache_control: 'max-age=600'
}),
serve({ serve({
prefix: '/client/', prefix: '/client/',
cache_control: 'max-age=31536000' cache_control: 'max-age=31536000'
}), }),
get_route_handler(client_info.assetsByChunkName, routes) get_route_handler(client_info.assetsByChunkName, routes, store)
].filter(Boolean)); ].filter(Boolean));
return middleware; return middleware;
@@ -77,10 +102,10 @@ function serve({ prefix, pathname, cache_control }: {
cache_control: string cache_control: string
}) { }) {
const filter = pathname const filter = pathname
? (req: Req) => req.pathname === pathname ? (req: Req) => req.path === pathname
: (req: Req) => req.pathname.startsWith(prefix); : (req: Req) => req.path.startsWith(prefix);
const output = dest(); const output = locations.dest();
const cache: Map<string, Buffer> = new Map(); const cache: Map<string, Buffer> = new Map();
@@ -90,10 +115,10 @@ function serve({ prefix, pathname, cache_control }: {
return (req: Req, res: ServerResponse, next: () => void) => { return (req: Req, res: ServerResponse, next: () => void) => {
if (filter(req)) { if (filter(req)) {
const type = lookup(req.pathname); const type = lookup(req.path);
try { try {
const data = read(req.pathname.slice(1)); const data = read(req.path.slice(1));
res.setHeader('Content-Type', type); res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', cache_control); res.setHeader('Cache-Control', cache_control);
@@ -110,13 +135,13 @@ function serve({ prefix, pathname, cache_control }: {
const resolved = Promise.resolve(); const resolved = Promise.resolve();
function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]) { function get_route_handler(chunks: Record<string, string>, routes: RouteObject[], store_getter: (req: Req) => Store) {
const template = dev() const template = dev()
? () => fs.readFileSync('app/template.html', 'utf-8') ? () => fs.readFileSync(`${locations.app()}/template.html`, 'utf-8')
: (str => () => str)(fs.readFileSync('app/template.html', 'utf-8')); : (str => () => str)(fs.readFileSync(`${locations.dest()}/template.html`, 'utf-8'));
function handle_route(route: RouteObject, req: Req, res: ServerResponse) { function handle_route(route: RouteObject, req: Req, res: ServerResponse) {
req.params = route.params(route.pattern.exec(req.pathname)); req.params = route.params(route.pattern.exec(req.path));
const mod = route.module; const mod = route.module;
@@ -127,11 +152,13 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
// TODO detect other stuff we can preload? images, CSS, fonts? // TODO detect other stuff we can preload? images, CSS, fonts?
const link = [] const link = []
.concat(chunks.main, chunks[route.id]) .concat(chunks.main, chunks[route.id])
.map(file => `</client/${file}>;rel="preload";as="script"`) .filter(file => !file.match(/\.map$/))
.map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`)
.join(', '); .join(', ');
res.setHeader('Link', link); res.setHeader('Link', link);
const store = store_getter ? store_getter(req) : null;
const data = { params: req.params, query: req.query }; const data = { params: req.params, query: req.query };
let redirect: { statusCode: number, location: string }; let redirect: { statusCode: number, location: string };
@@ -144,14 +171,50 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
}, },
error: (statusCode: number, message: Error | string) => { error: (statusCode: number, message: Error | string) => {
error = { statusCode, message }; error = { statusCode, message };
} },
fetch: (url: string, opts?: any) => {
const parsed = new URL(url, `http://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`);
if (opts) {
opts = Object.assign({}, opts);
const include_cookies = (
opts.credentials === 'include' ||
opts.credentials === 'same-origin' && parsed.origin === `http://127.0.0.1:${process.env.PORT}`
);
if (include_cookies) {
const cookies: Record<string, string> = {};
if (!opts.headers) opts.headers = {};
const str = []
.concat(
cookie.parse(req.headers.cookie || ''),
cookie.parse(opts.headers.cookie || ''),
cookie.parse(res.getHeader('Set-Cookie') || '')
)
.map(cookie => {
return Object.keys(cookie)
.map(name => `${name}=${encodeURIComponent(cookie[name])}`)
.join('; ');
})
.filter(Boolean)
.join(', ');
opts.headers.cookie = str;
}
}
return fetch(parsed.href, opts);
},
store
}, req) : {} }, req) : {}
).catch(err => { ).catch(err => {
error = { statusCode: 500, message: err }; error = { statusCode: 500, message: err };
}).then(preloaded => { }).then(preloaded => {
if (redirect) { if (redirect) {
res.statusCode = redirect.statusCode; res.statusCode = redirect.statusCode;
res.setHeader('Location', redirect.location); res.setHeader('Location', `${req.baseUrl}/${redirect.location}`);
res.end(); res.end();
return; return;
@@ -162,20 +225,36 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
return; return;
} }
const serialized = try_serialize(preloaded); // TODO bail on non-POJOs const serialized = {
preloaded: mod.preload && try_serialize(preloaded),
store: store && try_serialize(store.get())
};
Object.assign(data, preloaded); Object.assign(data, preloaded);
const { html, head, css } = mod.render(data); const { html, head, css } = mod.render(data, {
store
});
let scripts = [] let scripts = []
.concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack .concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack
.map(file => `<script src='/client/${file}'></script>`) .filter(file => !file.match(/\.map$/))
.map(file => `<script src='${req.baseUrl}/client/${file}'></script>`)
.join(''); .join('');
scripts = `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${scripts}`; let inline_script = `__SAPPER__={${[
`baseUrl: "${req.baseUrl}"`,
serialized.preloaded && `preloaded: ${serialized.preloaded}`,
serialized.store && `store: ${serialized.store}`
].filter(Boolean).join(',')}}`
const has_service_worker = fs.existsSync(path.join(locations.dest(), 'service-worker.js'));
if (has_service_worker) {
`if ('serviceWorker' in navigator) navigator.serviceWorker.register('${req.baseUrl}/service-worker.js')`
}
const page = template() const page = template()
.replace('%sapper.scripts%', scripts) .replace('%sapper.base%', `<base href="${req.baseUrl}/">`)
.replace('%sapper.scripts%', `<script>${inline_script}</script>${scripts}`)
.replace('%sapper.html%', html) .replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`) .replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : '')); .replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
@@ -277,12 +356,15 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
const rendered = route ? route.module.render({ const rendered = route ? route.module.render({
status: statusCode, status: statusCode,
error error
}, {
store: store_getter && store_getter(req)
}) : { head: '', css: null, html: title }; }) : { head: '', css: null, html: title };
const { head, css, html } = rendered; const { head, css, html } = rendered;
const page = template() const page = template()
.replace('%sapper.scripts%', `<script src='/client/${chunks.main}'></script>`) .replace('%sapper.base%', `<base href="${req.baseUrl}/">`)
.replace('%sapper.scripts%', `<script>__SAPPER__={baseUrl: "${req.baseUrl}"}</script><script src='${req.baseUrl}/client/${chunks.main}'></script>`)
.replace('%sapper.html%', html) .replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`) .replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : '')); .replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
@@ -291,11 +373,9 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
} }
return function find_route(req: Req, res: ServerResponse) { return function find_route(req: Req, res: ServerResponse) {
const url = req.pathname;
try { try {
for (const route of routes) { for (const route of routes) {
if (!route.error && route.pattern.test(url)) return handle_route(route, req, res); if (!route.error && route.pattern.test(req.path)) return handle_route(route, req, res);
} }
handle_error(req, res, 404, 'Not found'); handle_error(req, res, 404, 'Not found');

View File

@@ -29,7 +29,7 @@ application/java-archive jar
application/java-serialized-object ser application/java-serialized-object ser
application/java-vm class application/java-vm class
application/javascript js application/javascript js
application/json json application/json json map
application/jsonml+json jsonml application/jsonml+json jsonml
application/lost+xml lostxml application/lost+xml lostxml
application/mac-binhex40 hqx application/mac-binhex40 hqx

View File

@@ -1,8 +1,11 @@
import { detach, findAnchor, scroll_state, which } from './utils'; import { detach, findAnchor, scroll_state, which } from './utils';
import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition, Target } from './interfaces'; import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition, Store, Target } from './interfaces';
const manifest = typeof window !== 'undefined' && window.__SAPPER__;
export let component: Component; export let component: Component;
let target: Node; let target: Node;
let store: Store;
let routes: Route[]; let routes: Route[];
let errors: { '4xx': Route, '5xx': Route }; let errors: { '4xx': Route, '5xx': Route };
@@ -22,9 +25,12 @@ if ('scrollRestoration' in history) {
function select_route(url: URL): Target { function select_route(url: URL): Target {
if (url.origin !== window.location.origin) return null; if (url.origin !== window.location.origin) return null;
if (!url.pathname.startsWith(manifest.baseUrl)) return null;
const pathname = url.pathname.slice(manifest.baseUrl.length);
for (const route of routes) { for (const route of routes) {
const match = route.pattern.exec(url.pathname); const match = route.pattern.exec(pathname);
if (match) { if (match) {
if (route.ignore) return null; if (route.ignore) return null;
@@ -64,6 +70,7 @@ function render(Component: ComponentConstructor, data: any, scroll: ScrollPositi
component = new Component({ component = new Component({
target, target,
data, data,
store,
hydrate: !component hydrate: !component
}); });
@@ -80,11 +87,13 @@ function prepare_route(Component: ComponentConstructor, data: RouteData) {
return { Component, data, redirect, error }; return { Component, data, redirect, error };
} }
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) { if (!component && manifest.preloaded) {
return { Component, data: Object.assign(data, window.__SAPPER__.preloaded), redirect, error }; return { Component, data: Object.assign(data, manifest.preloaded), redirect, error };
} }
return Promise.resolve(Component.preload.call({ return Promise.resolve(Component.preload.call({
store,
fetch: (url: string, opts?: any) => window.fetch(url, opts),
redirect: (statusCode: number, location: string) => { redirect: (statusCode: number, location: string) => {
redirect = { statusCode, location }; redirect = { statusCode, location };
}, },
@@ -203,7 +212,7 @@ let prefetching: {
} = null; } = null;
export function prefetch(href: string) { export function prefetch(href: string) {
const selected = select_route(new URL(href)); const selected = select_route(new URL(href, document.baseURI));
if (selected) { if (selected) {
prefetching = { prefetching = {
@@ -222,7 +231,7 @@ function handle_touchstart_mouseover(event: MouseEvent | TouchEvent) {
let inited: boolean; let inited: boolean;
export function init(_target: Node, _routes: Route[]) { export function init(_target: Node, _routes: Route[], opts?: { store?: (data: any) => Store }) {
target = _target; target = _target;
routes = _routes.filter(r => !r.error); routes = _routes.filter(r => !r.error);
errors = { errors = {
@@ -230,6 +239,10 @@ export function init(_target: Node, _routes: Route[]) {
'5xx': _routes.find(r => r.error === '5xx') '5xx': _routes.find(r => r.error === '5xx')
}; };
if (opts && opts.store) {
store = opts.store(manifest.store);
}
if (!inited) { // this check makes HMR possible if (!inited) { // this check makes HMR possible
window.addEventListener('click', handle_click); window.addEventListener('click', handle_click);
window.addEventListener('popstate', handle_popstate); window.addEventListener('popstate', handle_popstate);
@@ -257,7 +270,8 @@ export function init(_target: Node, _routes: Route[]) {
} }
export function goto(href: string, opts = { replaceState: false }) { export function goto(href: string, opts = { replaceState: false }) {
const target = select_route(new URL(href, window.location.href)); const target = select_route(new URL(href, document.baseURI));
if (target) { if (target) {
navigate(target, null); navigate(target, null);
if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href); if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);

View File

@@ -1,9 +1,12 @@
import { Store } from '../interfaces';
export { Store };
export type Params = Record<string, string>; export type Params = Record<string, string>;
export type Query = Record<string, string | true>; export type Query = Record<string, string | true>;
export type RouteData = { params: Params, query: Query }; export type RouteData = { params: Params, query: Query };
export interface ComponentConstructor { export interface ComponentConstructor {
new (options: { target: Node, data: any, hydrate: boolean }): Component; new (options: { target: Node, data: any, store: Store, hydrate: boolean }): Component;
preload: (data: { params: Params, query: Query }) => Promise<any>; preload: (data: { params: Params, query: Query }) => Promise<any>;
}; };

View File

@@ -1,4 +1,4 @@
import { dest, dev } from './config'; import { locations, dev } from './config';
export default { export default {
dev: dev(), dev: dev(),
@@ -6,16 +6,16 @@ export default {
client: { client: {
entry: () => { entry: () => {
return { return {
main: './app/client' main: `${locations.app()}/client`
}; };
}, },
output: () => { output: () => {
return { return {
path: `${dest()}/client`, path: `${locations.dest()}/client`,
filename: '[hash]/[name].js', filename: '[hash]/[name].js',
chunkFilename: '[hash]/[name].[id].js', chunkFilename: '[hash]/[name].[id].js',
publicPath: '/client/' publicPath: `client/`
}; };
} }
}, },
@@ -23,13 +23,13 @@ export default {
server: { server: {
entry: () => { entry: () => {
return { return {
server: './app/server' server: `${locations.app()}/server`
}; };
}, },
output: () => { output: () => {
return { return {
path: dest(), path: locations.dest(),
filename: '[name].js', filename: '[name].js',
chunkFilename: '[hash]/[name].[id].js', chunkFilename: '[hash]/[name].[id].js',
libraryTarget: 'commonjs2' libraryTarget: 'commonjs2'
@@ -40,13 +40,13 @@ export default {
serviceworker: { serviceworker: {
entry: () => { entry: () => {
return { return {
'service-worker': './app/service-worker' 'service-worker': `${locations.app()}/service-worker`
}; };
}, },
output: () => { output: () => {
return { return {
path: dest(), path: locations.dest(),
filename: '[name].js', filename: '[name].js',
chunkFilename: '[name].[id].[hash].js' chunkFilename: '[name].[id].[hash].js'
} }

View File

@@ -1,8 +1,11 @@
import { init, prefetchRoutes } from '../../../runtime.js'; import { init, prefetchRoutes } from '../../../runtime.js';
import { Store } from 'svelte/store.js';
import { routes } from './manifest/client.js'; import { routes } from './manifest/client.js';
window.init = () => { window.init = () => {
return init(document.querySelector('#sapper'), routes); return init(document.querySelector('#sapper'), routes, {
store: data => new Store(data)
});
}; };
window.prefetchRoutes = prefetchRoutes; window.prefetchRoutes = prefetchRoutes;

View File

@@ -1,8 +1,9 @@
import fs from 'fs'; import fs from 'fs';
import polka from 'polka'; import { resolve } from 'url';
import compression from 'compression'; import express from 'express';
import serve from 'serve-static'; import serve from 'serve-static';
import sapper from '../../../middleware'; import sapper from '../../../dist/middleware.ts.js';
import { Store } from 'svelte/store.js';
import { routes } from './manifest/server.js'; import { routes } from './manifest/server.js';
let pending; let pending;
@@ -28,58 +29,76 @@ process.on('message', message => {
} }
}); });
const app = polka(); const app = express();
app.use((req, res, next) => { const { PORT = 3000, BASEPATH = '' } = process.env;
if (pending) pending.add(req.url); const base = `http://localhost:${PORT}${BASEPATH}/`;
const { write, end } = res;
const chunks = [];
res.write = function(chunk) {
chunks.push(new Buffer(chunk));
write.apply(res, arguments);
};
res.end = function(chunk) {
if (chunk) chunks.push(new Buffer(chunk));
end.apply(res, arguments);
if (pending) pending.delete(req.url);
process.send({
method: req.method,
url: req.url,
status: res.statusCode,
headers: res._headers,
body: Buffer.concat(chunks).toString()
});
if (pending && pending.size === 0 && ended) {
process.send({ type: 'done' });
}
};
next();
});
const { PORT = 3000 } = process.env;
// this allows us to do e.g. `fetch('/api/blog')` on the server // this allows us to do e.g. `fetch('/api/blog')` on the server
const fetch = require('node-fetch'); const fetch = require('node-fetch');
global.fetch = (url, opts) => { global.fetch = (url, opts) => {
if (url[0] === '/') url = `http://localhost:${PORT}${url}`; return fetch(resolve(base, url), opts);
return fetch(url, opts);
}; };
app.use(compression({ threshold: 0 })); const middlewares = [
serve('assets'),
app.use(serve('assets')); // set test cookie
(req, res, next) => {
res.setHeader('Set-Cookie', 'test=woohoo!; Max-Age=3600');
next();
},
app.use(sapper({ // emit messages so we can capture requests
routes (req, res, next) => {
})); if (!pending) return next();
app.listen(PORT, () => { pending.add(req.url);
console.log(`listening on port ${PORT}`);
}); const { write, end } = res;
const chunks = [];
res.write = function(chunk) {
chunks.push(new Buffer(chunk));
write.apply(res, arguments);
};
res.end = function(chunk) {
if (chunk) chunks.push(new Buffer(chunk));
end.apply(res, arguments);
if (pending) pending.delete(req.url);
process.send({
method: req.method,
url: req.url,
status: res.statusCode,
headers: res._headers,
body: Buffer.concat(chunks).toString()
});
if (pending && pending.size === 0 && ended) {
process.send({ type: 'done' });
}
};
next();
},
sapper({
routes,
store: () => {
return new Store({
title: 'Stored title'
});
}
})
];
if (BASEPATH) {
app.use(BASEPATH, ...middlewares);
} else {
app.use(...middlewares);
}
app.listen(PORT);

View File

@@ -5,15 +5,11 @@
<meta name='viewport' content='width=device-width'> <meta name='viewport' content='width=device-width'>
<meta name='theme-color' content='#aa1e1e'> <meta name='theme-color' content='#aa1e1e'>
<link rel='stylesheet' href='/global.css'> %sapper.base%
<link rel='manifest' href='/manifest.json'>
<link rel='icon' type='image/png' href='/favicon.png'>
<script> <link rel='stylesheet' href='global.css'>
// if ('serviceWorker' in navigator) { <link rel='manifest' href='manifest.json'>
// navigator.serviceWorker.register('/service-worker.js'); <link rel='icon' type='image/png' href='favicon.png'>
// }
</script>
<!-- Sapper generates a <style> tag containing critical CSS <!-- Sapper generates a <style> tag containing critical CSS
for the current page. CSS for the rest of the app is for the current page. CSS for the rest of the app is

View File

@@ -2,17 +2,5 @@
<title>{{status}}</title> <title>{{status}}</title>
</:Head> </:Head>
<Layout page='home'> <h1>Not found</h1>
<h1>Not found</h1> <p>{{error.message}}</p>
<p>{{error.message}}</p>
</Layout>
<script>
import Layout from './_components/Layout.html';
export default {
components: {
Layout
}
};
</script>

View File

@@ -2,17 +2,5 @@
<title>Internal server error</title> <title>Internal server error</title>
</:Head> </:Head>
<Layout page='home'> <h1>Internal server error</h1>
<h1>Internal server error</h1> <p>{{error.message}}</p>
<p>{{error.message}}</p>
</Layout>
<script>
import Layout from './_components/Layout.html';
export default {
components: {
Layout
}
};
</script>

View File

@@ -1,15 +0,0 @@
<Nav page={{page}}/>
<main>
<slot></slot>
</main>
<script>
import Nav from './Nav.html';
export default {
components: {
Nav
}
};
</script>

View File

@@ -1,57 +0,0 @@
<nav>
<ul>
<li><a href='/'>home</a></li>
<li><a href='/about'>about</a></li>
<li><a href='/slow-preload'>slow preload</a></li>
<li><a href='/redirect-from'>redirect</a></li>
<li><a href='/blog/nope'>broken link</a></li>
<li><a href='/blog/throw-an-error'>error link</a></li>
<li><a rel=prefetch class='{{page === "blog" ? "selected" : ""}}' href='/blog'>blog</a></li>
</ul>
</nav>
<style>
nav {
border-bottom: 1px solid rgba(170,30,30,0.1);
font-weight: 300;
padding: 0 1em;
}
ul {
margin: 0;
padding: 0;
}
/* clearfix */
ul::after {
content: '';
display: block;
clear: both;
}
li {
display: block;
float: left;
}
.selected {
position: relative;
display: inline-block;
}
.selected::after {
position: absolute;
content: '';
width: calc(100% - 1em);
height: 2px;
background-color: rgb(170,30,30);
display: block;
bottom: -1px;
}
a {
text-decoration: none;
padding: 1em 0.5em;
display: block;
}
</style>

View File

@@ -2,24 +2,17 @@
<title>About</title> <title>About</title>
</:Head> </:Head>
<Layout page='about'> <h1>About this site</h1>
<h1>About this site</h1>
<p>This is the 'about' page. There's not much here.</p> <p>This is the 'about' page. There's not much here.</p>
<button class='goto' on:click='goto("/blog/what-is-sapper")'>What is Sapper?</button> <button class='goto' on:click='goto("blog/what-is-sapper")'>What is Sapper?</button>
<button class='prefetch' on:click='goto("/blog/why-the-name")'>Why the name?</button> <button class='prefetch' on:click='prefetch("blog/why-the-name")'>Why the name?</button>
</Layout>
<script> <script>
import Layout from './_components/Layout.html';
import { goto, prefetch } from '../../../runtime.js'; import { goto, prefetch } from '../../../runtime.js';
export default { export default {
components: {
Layout
},
methods: { methods: {
goto, goto,
prefetch prefetch

View File

@@ -2,58 +2,14 @@
<title>{{post.title}}</title> <title>{{post.title}}</title>
</:Head> </:Head>
<Layout page='blog'> <h1>{{post.title}}</h1>
<h1>{{post.title}}</h1>
<div class='content'> <div class='content'>
{{{post.html}}} {{{post.html}}}
</div> </div>
</Layout>
<style>
/*
By default, CSS is locally scoped to the component,
and any unused styles are dead-code-eliminated.
In this page, Svelte can't know which elements are
going to appear inside the {{{post.html}}} block,
so we have to use the :global(...) modifier to target
all elements inside .content
*/
.content :global(h2) {
font-size: 1.4em;
font-weight: 500;
}
.content :global(pre) {
background-color: #f9f9f9;
box-shadow: inset 1px 1px 5px rgba(0,0,0,0.05);
padding: 0.5em;
border-radius: 2px;
overflow-x: auto;
}
.content :global(pre) :global(code) {
background-color: transparent;
padding: 0;
}
.content :global(ul) {
line-height: 1.5;
}
.content :global(li) {
margin: 0 0 0.5em 0;
}
</style>
<script> <script>
import Layout from '../_components/Layout.html';
export default { export default {
components: {
Layout
},
preload({ params, query }) { preload({ params, query }) {
// the `slug` parameter is available because this file // the `slug` parameter is available because this file
// is called [slug].html // is called [slug].html
@@ -63,7 +19,7 @@
return this.error(500, 'something went wrong'); return this.error(500, 'something went wrong');
} }
return fetch(`/blog/${slug}.json`).then(r => { return fetch(`blog/${slug}.json`).then(r => {
if (r.status === 200) { if (r.status === 200) {
return r.json().then(post => ({ post })); return r.json().then(post => ({ post }));
this.error(r.status, '') this.error(r.status, '')

View File

@@ -14,7 +14,7 @@ const posts = [
html: ` html: `
<p>First, you have to know what <a href='https://svelte.technology'>Svelte</a> is. Svelte is a UI framework with a bold new idea: rather than providing a library that you write code with (like React or Vue, for example), it's a compiler that turns your components into highly optimized vanilla JavaScript. If you haven't already read the <a href='https://svelte.technology/blog/frameworks-without-the-framework'>introductory blog post</a>, you should!</p> <p>First, you have to know what <a href='https://svelte.technology'>Svelte</a> is. Svelte is a UI framework with a bold new idea: rather than providing a library that you write code with (like React or Vue, for example), it's a compiler that turns your components into highly optimized vanilla JavaScript. If you haven't already read the <a href='https://svelte.technology/blog/frameworks-without-the-framework'>introductory blog post</a>, you should!</p>
<p>Sapper is a Next.js-style framework (<a href='/blog/how-is-sapper-different-from-next'>more on that here</a>) built around Svelte. It makes it embarrassingly easy to create extremely high performance web apps. Out of the box, you get:</p> <p>Sapper is a Next.js-style framework (<a href='blog/how-is-sapper-different-from-next'>more on that here</a>) built around Svelte. It makes it embarrassingly easy to create extremely high performance web apps. Out of the box, you get:</p>
<ul> <ul>
<li>Code-splitting, dynamic imports and hot module replacement, powered by webpack</li> <li>Code-splitting, dynamic imports and hot module replacement, powered by webpack</li>
@@ -70,8 +70,8 @@ const posts = [
<ul> <ul>
<li>It's powered by <a href='https://svelte.technology'>Svelte</a> instead of React, so it's faster and your apps are smaller</li> <li>It's powered by <a href='https://svelte.technology'>Svelte</a> instead of React, so it's faster and your apps are smaller</li>
<li>Instead of route masking, we encode route parameters in filenames. For example, the page you're looking at right now is <code>routes/blog/[slug].html</code></li> <li>Instead of route masking, we encode route parameters in filenames. For example, the page you're looking at right now is <code>routes/blog/[slug].html</code></li>
<li>As well as pages (Svelte components, which render on server or client), you can create <em>server routes</em> in your <code>routes</code> directory. These are just <code>.js</code> files that export functions corresponding to HTTP methods, and receive Express <code>request</code> and <code>response</code> objects as arguments. This makes it very easy to, for example, add a JSON API such as the one <a href='/blog/how-is-sapper-different-from-next.json'>powering this very page</a></li> <li>As well as pages (Svelte components, which render on server or client), you can create <em>server routes</em> in your <code>routes</code> directory. These are just <code>.js</code> files that export functions corresponding to HTTP methods, and receive Express <code>request</code> and <code>response</code> objects as arguments. This makes it very easy to, for example, add a JSON API such as the one <a href='blog/how-is-sapper-different-from-next.json'>powering this very page</a></li>
<li>Links are just <code>&lt;a&gt;</code> elements, rather than framework-specific <code>&lt;Link&gt;</code> components. That means, for example, that <a href='/blog/how-can-i-get-involved'>this link right here</a>, despite being inside a blob of HTML, works with the router as you'd expect.</li> <li>Links are just <code>&lt;a&gt;</code> elements, rather than framework-specific <code>&lt;Link&gt;</code> components. That means, for example, that <a href='blog/how-can-i-get-involved'>this link right here</a>, despite being inside a blob of HTML, works with the router as you'd expect.</li>
</ul> </ul>
` `
}, },

View File

@@ -2,37 +2,22 @@
<title>Blog</title> <title>Blog</title>
</:Head> </:Head>
<Layout page='blog'> <h1>Recent posts</h1>
<h1>Recent posts</h1>
<ul> <ul>
{{#each posts as post}} {{#each posts as post}}
<!-- we're using the non-standard `rel=prefetch` attribute to <!-- we're using the non-standard `rel=prefetch` attribute to
tell Sapper to load the data for the page as soon as tell Sapper to load the data for the page as soon as
the user hovers over the link or taps it, instead of the user hovers over the link or taps it, instead of
waiting for the 'click' event --> waiting for the 'click' event -->
<li><a rel='prefetch' href='/blog/{{post.slug}}'>{{post.title}}</a></li> <li><a rel='prefetch' href='blog/{{post.slug}}'>{{post.title}}</a></li>
{{/each}} {{/each}}
</ul> </ul>
</Layout>
<style>
ul {
margin: 0 0 1em 0;
line-height: 1.5;
}
</style>
<script> <script>
import Layout from '../_components/Layout.html';
export default { export default {
components: {
Layout
},
preload({ params, query }) { preload({ params, query }) {
return fetch(`/blog.json`).then(r => r.json()).then(posts => { return fetch(`blog.json`).then(r => r.json()).then(posts => {
return { posts }; return { posts };
}); });
} }

View File

@@ -0,0 +1,11 @@
<h1>{{message}}</h1>
<script>
export default {
preload({ query }) {
return this.fetch(`credentials/test.json`, {
credentials: query.creds
}).then(r => r.json());
}
};
</script>

View File

@@ -0,0 +1,28 @@
export function get(req, res) {
const cookies = req.headers.cookie
? req.headers.cookie.split(/,\s+/).reduce((cookies, cookie) => {
const [pair] = cookie.split('; ');
const [name, value] = pair.split('=');
cookies[name] = value;
return cookies;
}, {})
: {};
if (cookies.test) {
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify({
message: cookies.test
}));
} else {
res.writeHead(403, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify({
message: 'unauthorized'
}));
}
}

View File

@@ -4,7 +4,7 @@
export default { export default {
methods: { methods: {
del() { del() {
fetch(`/api/delete/42`, { method: 'DELETE' }) fetch(`api/delete/42`, { method: 'DELETE' })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
window.deleted = data; window.deleted = data;

View File

@@ -2,59 +2,25 @@
<title>Sapper project template</title> <title>Sapper project template</title>
</:Head> </:Head>
<Layout page='home'> <h1>Great success!</h1>
<h1>Great success!</h1>
<figure> <a href='.'>home</a>
<img alt='borat' src='/great-success.png'> <a href='about'>about</a>
<figcaption>HIGH FIVE!</figcaption> <a href='slow-preload'>slow preload</a>
</figure> <a href='redirect-from'>redirect</a>
<a href='blog/nope'>broken link</a>
<p><strong>Try editing this file (routes/index.html) to test hot module reloading.</strong></p> <a href='blog/throw-an-error'>error link</a>
</Layout> <a href='credentials?creds=include'>credentials</a>
<a rel=prefetch class='{{page === "blog" ? "selected" : ""}}' href='blog'>blog</a>
<div class='hydrate-test'></div> <div class='hydrate-test'></div>
<style> <style>
h1, figure, p {
text-align: center;
margin: 0 auto;
}
h1 { h1 {
text-align: center;
font-size: 2.8em; font-size: 2.8em;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
margin: 0 0 0.5em 0; margin: 0 0 0.5em 0;
} }
figure {
margin: 0 0 1em 0;
}
img {
width: 100%;
max-width: 400px;
margin: 0 0 1em 0;
}
p {
margin: 1em auto;
}
@media (min-width: 480px) {
h1 {
font-size: 4em;
}
}
</style> </style>
<script>
import Layout from './_components/Layout.html';
export default {
components: {
Layout
}
};
</script>

View File

@@ -1,7 +1,7 @@
<script> <script>
export default { export default {
preload() { preload() {
this.redirect(301, '/redirect-to'); this.redirect(301, 'redirect-to');
} }
}; };
</script> </script>

View File

@@ -0,0 +1 @@
<h1>{{$title}}</h1>

View File

@@ -12,6 +12,10 @@ Nightmare.action('page', {
this.evaluate_now(() => document.querySelector('h1').textContent, done); this.evaluate_now(() => document.querySelector('h1').textContent, done);
}, },
html(done) {
this.evaluate_now(() => document.documentElement.innerHTML, done);
},
text(done) { text(done) {
this.evaluate_now(() => document.body.textContent, done); this.evaluate_now(() => document.body.textContent, done);
} }
@@ -35,11 +39,21 @@ describe('sapper', function() {
rimraf.sync('build'); rimraf.sync('build');
rimraf.sync('.sapper'); rimraf.sync('.sapper');
this.timeout(30000); this.timeout(process.env.CI ? 30000 : 10000);
// TODO reinstate dev tests // TODO reinstate dev tests
// run('development'); // run({
run('production'); // mode: 'development'
// });
run({
mode: 'production'
});
run({
mode: 'production',
basepath: '/custom-basepath'
});
describe('export', () => { describe('export', () => {
before(() => { before(() => {
@@ -111,16 +125,29 @@ describe('sapper', function() {
}); });
}); });
function run(env) { function run({ mode, basepath = '' }) {
describe(`env=${env}`, function () { describe(`mode=${mode}`, function () {
let proc; let proc;
let nightmare;
let capture; let capture;
let base; let base;
const nightmare = new Nightmare();
nightmare.on('console', (type, ...args) => {
console[type](...args);
});
nightmare.on('page', (type, ...args) => {
if (type === 'error') {
console.error(args[1]);
} else {
console.warn(type, args);
}
});
before(() => { before(() => {
const promise = env === 'production' const promise = mode === 'production'
? exec(`node ${cli} build`).then(() => ports.find(3000)) ? exec(`node ${cli} build`).then(() => ports.find(3000))
: ports.find(3000).then(port => { : ports.find(3000).then(port => {
exec(`node ${cli} dev`); exec(`node ${cli} dev`);
@@ -129,13 +156,15 @@ function run(env) {
return promise.then(port => { return promise.then(port => {
base = `http://localhost:${port}`; base = `http://localhost:${port}`;
if (basepath) base += basepath;
const dir = env === 'production' ? 'build' : '.sapper'; const dir = mode === 'production' ? 'build' : '.sapper';
proc = require('child_process').fork(`${dir}/server.js`, { proc = require('child_process').fork(`${dir}/server.js`, {
cwd: process.cwd(), cwd: process.cwd(),
env: { env: {
NODE_ENV: env, NODE_ENV: mode,
BASEPATH: basepath,
SAPPER_DEST: dir, SAPPER_DEST: dir,
PORT: port PORT: port
} }
@@ -183,30 +212,13 @@ function run(env) {
after(() => { after(() => {
// give a chance to clean up // give a chance to clean up
return new Promise(fulfil => { return Promise.all([
proc.on('exit', fulfil); nightmare.end(),
proc.kill(); new Promise(fulfil => {
}); proc.on('exit', fulfil);
}); proc.kill();
})
beforeEach(() => { ]);
nightmare = new Nightmare();
nightmare.on('console', (type, ...args) => {
console[type](...args);
});
nightmare.on('page', (type, ...args) => {
if (type === 'error') {
console.error(args[1]);
} else {
console.warn(type, args);
}
});
});
afterEach(() => {
return nightmare.end();
}); });
describe('basic functionality', () => { describe('basic functionality', () => {
@@ -235,16 +247,16 @@ function run(env) {
}); });
it('navigates to a new page without reloading', () => { it('navigates to a new page without reloading', () => {
return capture(() => nightmare.goto(base).init().prefetchRoutes()) return nightmare.goto(base).init().prefetchRoutes()
.then(() => { .then(() => {
return capture(() => nightmare.click('a[href="/about"]')); return capture(() => nightmare.click('a[href="about"]'));
}) })
.then(requests => { .then(requests => {
assert.deepEqual(requests.map(r => r.url), []); assert.deepEqual(requests.map(r => r.url), []);
return nightmare.path(); return nightmare.path();
}) })
.then(path => { .then(path => {
assert.equal(path, '/about'); assert.equal(path, `${basepath}/about`);
return nightmare.title(); return nightmare.title();
}) })
.then(title => { .then(title => {
@@ -257,7 +269,7 @@ function run(env) {
.goto(`${base}/about`) .goto(`${base}/about`)
.init() .init()
.click('.goto') .click('.goto')
.wait(() => window.location.pathname === '/blog/what-is-sapper') .wait(url => window.location.pathname === url, `${basepath}/blog/what-is-sapper`)
.wait(100) .wait(100)
.title() .title()
.then(title => { .then(title => {
@@ -266,9 +278,7 @@ function run(env) {
}); });
it('prefetches programmatically', () => { it('prefetches programmatically', () => {
return nightmare return capture(() => nightmare.goto(`${base}/about`).init())
.goto(`${base}/about`)
.init()
.then(() => { .then(() => {
return capture(() => { return capture(() => {
return nightmare return nightmare
@@ -277,7 +287,7 @@ function run(env) {
}); });
}) })
.then(requests => { .then(requests => {
assert.ok(!!requests.find(r => r.url === '/blog/why-the-name.json')); assert.ok(!!requests.find(r => r.url === `/blog/why-the-name.json`));
}); });
}); });
@@ -298,21 +308,21 @@ function run(env) {
.then(() => { .then(() => {
return capture(() => { return capture(() => {
return nightmare return nightmare
.mouseover('[href="/blog/what-is-sapper"]') .mouseover('[href="blog/what-is-sapper"]')
.wait(200); .wait(200);
}); });
}) })
.then(mouseover_requests => { .then(mouseover_requests => {
assert.ok(mouseover_requests.findIndex(r => r.url === '/blog/what-is-sapper.json') !== -1); assert.ok(mouseover_requests.findIndex(r => r.url === `/blog/what-is-sapper.json`) !== -1);
return capture(() => { return capture(() => {
return nightmare return nightmare
.click('[href="/blog/what-is-sapper"]') .click('[href="blog/what-is-sapper"]')
.wait(200); .wait(200);
}); });
}) })
.then(click_requests => { .then(click_requests => {
assert.ok(click_requests.findIndex(r => r.url === '/blog/what-is-sapper.json') === -1); assert.ok(click_requests.findIndex(r => r.url === `/blog/what-is-sapper.json`) === -1);
}); });
}); });
@@ -320,13 +330,13 @@ function run(env) {
return nightmare return nightmare
.goto(base) .goto(base)
.init() .init()
.click('a[href="/slow-preload"]') .click('a[href="slow-preload"]')
.wait(100) .wait(100)
.click('a[href="/about"]') .click('a[href="about"]')
.wait(100) .wait(100)
.then(() => nightmare.path()) .then(() => nightmare.path())
.then(path => { .then(path => {
assert.equal(path, '/about'); assert.equal(path, `${basepath}/about`);
return nightmare.title(); return nightmare.title();
}) })
.then(title => { .then(title => {
@@ -335,7 +345,7 @@ function run(env) {
}) })
.then(() => nightmare.path()) .then(() => nightmare.path())
.then(path => { .then(path => {
assert.equal(path, '/about'); assert.equal(path, `${basepath}/about`);
return nightmare.title(); return nightmare.title();
}) })
.then(title => { .then(title => {
@@ -348,7 +358,7 @@ function run(env) {
.goto(`${base}/show-url`) .goto(`${base}/show-url`)
.init() .init()
.evaluate(() => document.querySelector('p').innerHTML) .evaluate(() => document.querySelector('p').innerHTML)
.end().then(html => { .then(html => {
assert.equal(html, `URL is /show-url`); assert.equal(html, `URL is /show-url`);
}); });
}); });
@@ -384,7 +394,7 @@ function run(env) {
return nightmare.goto(`${base}/redirect-from`) return nightmare.goto(`${base}/redirect-from`)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/redirect-to'); assert.equal(path, `${basepath}/redirect-to`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -394,12 +404,12 @@ function run(env) {
it('redirects in client', () => { it('redirects in client', () => {
return nightmare.goto(base) return nightmare.goto(base)
.wait('[href="/redirect-from"]') .wait('[href="redirect-from"]')
.click('[href="/redirect-from"]') .click('[href="redirect-from"]')
.wait(200) .wait(200)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/redirect-to'); assert.equal(path, `${basepath}/redirect-to`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -411,7 +421,7 @@ function run(env) {
return nightmare.goto(`${base}/blog/nope`) return nightmare.goto(`${base}/blog/nope`)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/blog/nope'); assert.equal(path, `${basepath}/blog/nope`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -422,11 +432,11 @@ function run(env) {
it('handles 4xx error in client', () => { it('handles 4xx error in client', () => {
return nightmare.goto(base) return nightmare.goto(base)
.init() .init()
.click('[href="/blog/nope"]') .click('[href="blog/nope"]')
.wait(200) .wait(200)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/blog/nope'); assert.equal(path, `${basepath}/blog/nope`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -438,7 +448,7 @@ function run(env) {
return nightmare.goto(`${base}/blog/throw-an-error`) return nightmare.goto(`${base}/blog/throw-an-error`)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/blog/throw-an-error'); assert.equal(path, `${basepath}/blog/throw-an-error`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -449,11 +459,11 @@ function run(env) {
it('handles non-4xx error in client', () => { it('handles non-4xx error in client', () => {
return nightmare.goto(base) return nightmare.goto(base)
.init() .init()
.click('[href="/blog/throw-an-error"]') .click('[href="blog/throw-an-error"]')
.wait(200) .wait(200)
.path() .path()
.then(path => { .then(path => {
assert.equal(path, '/blog/throw-an-error'); assert.equal(path, `${basepath}/blog/throw-an-error`);
}) })
.then(() => nightmare.page.title()) .then(() => nightmare.page.title())
.then(title => { .then(title => {
@@ -464,7 +474,7 @@ function run(env) {
it('does not attempt client-side navigation to server routes', () => { it('does not attempt client-side navigation to server routes', () => {
return nightmare.goto(`${base}/blog/how-is-sapper-different-from-next`) return nightmare.goto(`${base}/blog/how-is-sapper-different-from-next`)
.init() .init()
.click(`[href="/blog/how-is-sapper-different-from-next.json"]`) .click(`[href="blog/how-is-sapper-different-from-next.json"]`)
.wait(200) .wait(200)
.page.text() .page.text()
.then(text => { .then(text => {
@@ -511,11 +521,49 @@ function run(env) {
assert.equal(title, '42'); assert.equal(title, '42');
}); });
}); });
it('renders store props', () => {
return nightmare.goto(`${base}/store`)
.page.title()
.then(title => {
assert.equal(title, 'Stored title');
return nightmare.init().page.title();
})
.then(title => {
assert.equal(title, 'Stored title');
});
});
it('sends cookies when using this.fetch with credentials: "include"', () => {
return nightmare.goto(`${base}/credentials?creds=include`)
.page.title()
.then(title => {
assert.equal(title, 'woohoo!');
});
});
it('does not send cookies when using this.fetch without credentials', () => {
return nightmare.goto(`${base}/credentials`)
.page.title()
.then(title => {
assert.equal(title, 'unauthorized');
});
});
it('delegates to fetch on the client', () => {
return nightmare.goto(base).init()
.click('[href="credentials?creds=include"]')
.wait(100)
.page.title()
.then(title => {
assert.equal(title, 'woohoo!');
});
});
}); });
describe('headers', () => { describe('headers', () => {
it('sets Content-Type and Link...preload headers', () => { it('sets Content-Type and Link...preload headers', () => {
return capture(() => nightmare.goto(base).end()).then(requests => { return capture(() => nightmare.goto(base)).then(requests => {
const { headers } = requests[0]; const { headers } = requests[0];
assert.equal( assert.equal(
@@ -523,8 +571,16 @@ function run(env) {
'text/html' 'text/html'
); );
const str = ['main', '_\\.\\d+']
.map(file => {
return `<${basepath}/client/[^/]+/${file}\\.js>;rel="preload";as="script"`;
})
.join(', ');
const regex = new RegExp(str);
assert.ok( assert.ok(
/<\/client\/[^/]+\/main\.js>;rel="preload";as="script", <\/client\/[^/]+\/_\.\d+\.js>;rel="preload";as="script"/.test(headers['link']), regex.test(headers['link']),
headers['link'] headers['link']
); );
}); });
@@ -535,7 +591,7 @@ function run(env) {
function exec(cmd) { function exec(cmd) {
return new Promise((fulfil, reject) => { return new Promise((fulfil, reject) => {
const parts = cmd.split(' '); const parts = cmd.trim().split(' ');
const proc = require('child_process').spawn(parts.shift(), parts); const proc = require('child_process').spawn(parts.shift(), parts);
proc.stdout.on('data', data => { proc.stdout.on('data', data => {

View File

@@ -12,8 +12,8 @@ describe('create_routes', () => {
[ [
'index.html', 'index.html',
'about.html', 'about.html',
'post/foo.html',
'post/bar.html', 'post/bar.html',
'post/foo.html',
'post/f[xx].html', 'post/f[xx].html',
'post/[id].json.js', 'post/[id].json.js',
'post/[id].html', 'post/[id].html',
@@ -23,7 +23,7 @@ describe('create_routes', () => {
}); });
it('prefers index page to nested route', () => { it('prefers index page to nested route', () => {
const routes = create_routes({ let routes = create_routes({
files: [ files: [
'api/examples/[slug].js', 'api/examples/[slug].js',
'api/examples/index.js', 'api/examples/index.js',
@@ -55,6 +55,45 @@ describe('create_routes', () => {
'api/gists/[id].js', 'api/gists/[id].js',
] ]
); );
routes = create_routes({
files: [
'4xx.html',
'5xx.html',
'api/blog/[slug].js',
'api/blog/index.js',
'api/guide/contents.js',
'api/guide/index.js',
'blog/[slug].html',
'blog/index.html',
'blog/rss.xml.js',
'gist/[id].js',
'gist/create.js',
'guide/index.html',
'index.html',
'repl/index.html'
]
});
assert.deepEqual(
routes.map(r => r.file),
[
'4xx.html',
'5xx.html',
'index.html',
'guide/index.html',
'blog/index.html',
'blog/rss.xml.js',
'blog/[slug].html',
'gist/create.js',
'gist/[id].js',
'repl/index.html',
'api/guide/index.js',
'api/guide/contents.js',
'api/blog/index.js',
'api/blog/[slug].js',
]
);
}); });
it('generates params', () => { it('generates params', () => {