Compare commits

...

96 Commits

Author SHA1 Message Date
Rich Harris
8604088f3d -> v0.2.0 2017-12-18 15:48:28 -05:00
Rich Harris
8da3ca16ab Separate build step (closes #21) 2017-12-18 15:38:56 -05:00
Rich Harris
d0dd1d6cc9 Merge pull request #23 from sveltejs/remove-webpack-dev-middleware
remove webpack-dev-middleware
2017-12-18 12:02:27 -05:00
Rich Harris
8270463281 remove webpack-dev-middleware 2017-12-18 11:41:02 -05:00
Rich Harris
514331b5e3 -> v0.1.5 2017-12-17 21:39:55 -05:00
Rich Harris
63d39575be starting to lose my sense of humour 2017-12-17 21:39:43 -05:00
Rich Harris
3af5503009 -> v0.1.4 2017-12-17 21:34:28 -05:00
Rich Harris
c1de442dd1 argh not again 2017-12-17 21:34:16 -05:00
Rich Harris
0c6b7e3836 -> v0.1.3 2017-12-17 21:07:55 -05:00
Rich Harris
dc5e2543cb argh copypasta fail 2017-12-17 21:07:34 -05:00
Rich Harris
ecc7b80d91 -> v0.1.2 2017-12-17 20:43:52 -05:00
Rich Harris
40024e7d86 Merge pull request #18 from sveltejs/make-dest-dir
create dest dir immediately - should fix some CI failures
2017-12-17 20:41:55 -05:00
Rich Harris
6f71f7ad4d Merge pull request #17 from sveltejs/gh-11
use atime.getTime() and mtime.getTime() - hopefully fixes #11
2017-12-17 20:41:30 -05:00
Rich Harris
6eb99b195e use atime.getTime() and mtime.getTime() - hopefully fixes #11 2017-12-17 20:39:18 -05:00
Rich Harris
9e08fee9a1 create dest dir immediately - should fix some CI failures 2017-12-17 20:24:58 -05:00
Rich Harris
442ce366e2 -> v0.1.1 2017-12-17 15:18:03 -05:00
Rich Harris
dc929fcd83 inject app path, since dest might be /tmp causing resolution failure 2017-12-17 15:17:23 -05:00
Rich Harris
2dc246398b -> v0.1.0 2017-12-17 15:05:51 -05:00
Rich Harris
b7ac067459 always write to .sapper, not templates, since we cant guarantee fs access 2017-12-17 15:04:20 -05:00
Rich Harris
8b50ff34b8 named exports, for tree-shakeability when we add new stuff 2017-12-17 15:01:26 -05:00
Rich Harris
62abdb2a87 remove magic return values from server routes 2017-12-17 15:01:05 -05:00
Rich Harris
34d0bae4a1 -> v0.0.22 2017-12-16 20:31:37 -05:00
Rich Harris
4f0b336627 fix prod mode 2017-12-16 20:30:14 -05:00
Rich Harris
e71bf298fb inject __dev__ variable 2017-12-16 20:19:02 -05:00
Rich Harris
e4936375db support HMR 2017-12-16 20:10:49 -05:00
Rich Harris
08ff7ad234 shuffle things around 2017-12-16 17:19:28 -05:00
Rich Harris
5995b7ae6a run webpack in watch mode during dev 2017-12-16 16:50:46 -05:00
Rich Harris
71ed3864b7 run webpack in watch mode 2017-12-16 16:24:36 -05:00
Rich Harris
bd7f6e2b1a expose main.js 2017-12-16 15:40:22 -05:00
Rich Harris
dd1f2d79ff -> v0.0.21 2017-12-16 12:18:34 -05:00
Rich Harris
dccd3cdeb0 prevent default when navigating to current location 2017-12-16 10:59:46 -05:00
Rich Harris
b3b5d9f352 handle hashchange correctly 2017-12-16 08:53:42 -05:00
Rich Harris
10ddaeb7a3 dont navigate on hashchange 2017-12-16 08:16:09 -05:00
Rich Harris
060f9b2f5e tidy up dependencies 2017-12-16 08:07:40 -05:00
Rich Harris
32dfa94247 dont include directories in cache manifest 2017-12-16 08:07:22 -05:00
Rich Harris
797cc3cde1 remove esm stuff - no longer used 2017-12-16 08:07:10 -05:00
Rich Harris
9eca90067c -> v0.0.20 2017-12-15 18:37:19 -05:00
Rich Harris
57f293e872 dont cache shell/sw in dev 2017-12-15 18:37:08 -05:00
Rich Harris
7e65c481d8 doh 2017-12-15 18:34:38 -05:00
Rich Harris
0fe93cd177 -> v0.0.19 2017-12-15 18:27:04 -05:00
Rich Harris
67fe570f6d dont try to prevent event where none exists 2017-12-15 18:26:55 -05:00
Rich Harris
a3d44aba31 -> v0.0.18 2017-12-15 17:20:36 -05:00
Rich Harris
80ae909b73 serve assets from memory, use caching 2017-12-15 17:20:24 -05:00
Rich Harris
892b18cf80 -> v0.0.17 2017-12-15 16:47:34 -05:00
Rich Harris
0eb96bf01f oops, remove logging 2017-12-15 16:47:29 -05:00
Rich Harris
419f5c5235 -> v0.0.16 2017-12-15 16:46:27 -05:00
Rich Harris
4c61ed5fdd better link click handling, track scroll position 2017-12-15 16:46:11 -05:00
Rich Harris
c19447cf05 -> v0.0.15 2017-12-15 15:29:30 -05:00
Rich Harris
cb2364f476 add noscript message (todo - make it configurable) 2017-12-15 15:29:08 -05:00
Rich Harris
de427d400e -> v0.0.14 2017-12-15 14:49:56 -05:00
Rich Harris
e810ead93f move files around 2017-12-15 14:46:23 -05:00
Rich Harris
f5a19ef34b tweak service worker stuff 2017-12-15 14:43:38 -05:00
Rich Harris
b8c03d330b refactor some webpack stuff, support service worker generation 2017-12-14 15:38:41 -05:00
Rich Harris
6e769496ec -> v0.0.13 2017-12-14 08:23:00 -05:00
Rich Harris
e46aceb2fe implement prefetching anchors with rel=prefetch 2017-12-14 08:21:32 -05:00
Rich Harris
a87cac2481 refactor some stuff 2017-12-14 07:53:17 -05:00
Rich Harris
608fdb7533 -> v0.0.12 2017-12-14 07:15:07 -05:00
Rich Harris
80166b5a7d -> v0.0.11 2017-12-14 07:12:01 -05:00
Rich Harris
24b259f80b fix location, so that runtime can be found from /tmp 2017-12-14 07:11:52 -05:00
Rich Harris
8a9f4bd268 -> v0.0.10 2017-12-14 07:07:47 -05:00
Rich Harris
d940da1a77 serve errors 2017-12-14 07:07:29 -05:00
Rich Harris
91269f5705 -> v0.0.9 2017-12-13 22:38:18 -05:00
Rich Harris
80a9818e95 dont assume error was caught 2017-12-13 22:38:09 -05:00
Rich Harris
478ccf53cc -> v0.0.8 2017-12-13 22:33:24 -05:00
Rich Harris
b32278e88b argh 2017-12-13 22:33:12 -05:00
Rich Harris
881d411db0 -> v0.0.7 2017-12-13 22:30:15 -05:00
Rich Harris
2a5786d7d7 -> v0.0.6 2017-12-13 22:22:03 -05:00
Rich Harris
8b89a5f27e make now-friendly 2017-12-13 22:21:55 -05:00
Rich Harris
d46a270cf0 remove requires 2017-12-13 22:11:28 -05:00
Rich Harris
72efde9515 -> v0.0.5 2017-12-13 22:08:29 -05:00
Rich Harris
7c0789cabf move webpack config out of sapper, into the app 2017-12-13 21:35:56 -05:00
Rich Harris
bffffe0035 detach SSRd <head> contents 2017-12-13 20:24:58 -05:00
Rich Harris
22d3cb2b1e serve 404 pages etc 2017-12-13 14:22:46 -05:00
Rich Harris
e1ed1896e6 easier templates 2017-12-13 14:14:06 -05:00
Rich Harris
6d4c26d15d server-side preloading, and critical CSS rendering 2017-12-13 13:30:27 -05:00
Rich Harris
941867f0a4 add client-side preloading logic, move router into runtime module 2017-12-13 13:29:38 -05:00
Rich Harris
c7e3fc4493 handle errors. (TODO: handle errors *nicely*) 2017-12-13 12:24:12 -05:00
Rich Harris
a8373c1568 always create valid route id 2017-12-13 12:23:49 -05:00
Rich Harris
db4223133e -> v0.0.4 2017-12-12 11:42:35 -05:00
Rich Harris
ef3a3e83e8 install rimraf 2017-12-12 11:42:18 -05:00
Rich Harris
86292d119b create all routes simultaneously, differentiate with type property 2017-12-12 11:42:06 -05:00
Rich Harris
2549477e05 rename create_matchers -> create_routes 2017-12-12 11:41:45 -05:00
Rich Harris
cf0ab4b9c7 bundle server code as well 2017-12-12 11:41:18 -05:00
Rich Harris
58768ae27d make selector customisable 2017-12-12 06:19:53 -05:00
Rich Harris
fa70024a92 dont treat files and dirs with leading _ as routes 2017-12-12 06:19:28 -05:00
Rich Harris
33fcb865c8 fix navigation and ESM stuff 2017-12-11 18:05:07 -05:00
Rich Harris
c6778c961b add devDependencies 2017-12-11 17:23:30 -05:00
Rich Harris
cea14b4b53 tidy up 2017-12-11 17:23:22 -05:00
Rich Harris
12661449c2 fix deps 2017-12-11 17:05:16 -05:00
Rich Harris
2f51435d93 render with params 2017-12-11 17:05:01 -05:00
Rich Harris
642c2904df switch to using [param].html style filenames 2017-12-11 17:04:21 -05:00
Rich Harris
727782aca2 -> v0.0.3 2017-12-11 13:13:00 -05:00
Rich Harris
b6f789e50c use absolute path 2017-12-11 13:12:51 -05:00
Rich Harris
e723f781a8 -> v0.0.2 2017-12-11 13:08:35 -05:00
Rich Harris
a22e28a11f start generating client-side bundle. WIP 2017-12-11 13:08:25 -05:00
Rich Harris
ad8a410ba4 update README 2017-12-11 10:39:17 -05:00
21 changed files with 4402 additions and 132 deletions

39
.eslintrc.json Normal file
View File

@@ -0,0 +1,39 @@
{
"root": true,
"rules": {
"indent": [ 2, "tab", { "SwitchCase": 1 } ],
"semi": [ 2, "always" ],
"space-before-blocks": [ 2, "always" ],
"no-mixed-spaces-and-tabs": [ 2, "smart-tabs" ],
"no-cond-assign": 0,
"no-unused-vars": 2,
"object-shorthand": [ 2, "always" ],
"no-const-assign": 2,
"no-class-assign": 2,
"no-this-before-super": 2,
"no-var": 2,
"no-unreachable": 2,
"valid-typeof": 2,
"quote-props": [ 2, "as-needed" ],
"one-var": [ 2, "never" ],
"prefer-arrow-callback": 2,
"prefer-const": [ 2, { "destructuring": "all" } ],
"arrow-spacing": 2,
"no-inner-declarations": 0
},
"env": {
"es6": true,
"browser": true,
"node": true,
"mocha": true
},
"extends": [
"eslint:recommended",
"plugin:import/errors",
"plugin:import/warnings"
],
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
}
}

22
CHANGELOG.md Normal file
View File

@@ -0,0 +1,22 @@
# sapper changelog
## 0.2.0
* Separate `sapper build` from prod server ([#21](https://github.com/sveltejs/sapper/issues/21))
## 0.1.3-5
* Fix typo
## 0.1.2
* Use `atime.getTime()` and `mtime.getTime()` instead of `atimeMs` and `mtimeMs` ([#11](https://github.com/sveltejs/sapper/issues/11))
* Make dest dir before anyone tries to write to it ([#18](https://github.com/sveltejs/sapper/pull/18))
## 0.1.1
* Expose resolved pathname to `sapper/runtime/app.js` as `__app__` inside main.js
## 0.1.0
* First public preview

View File

@@ -16,6 +16,7 @@ High-level goals:
* Code-splitting and HMR out of the box (probably via webpack)
* Best-in-class performance
* As little magic as possible. Anyone should be able to understand how everything fits together, and e.g. make changes to the webpack config
* Links are just `<a>` tags, no special `<Link>` components
## Design
@@ -26,8 +27,6 @@ A Sapper app is just an Express app (conventionally, `server.js`) that uses the
const app = require('express')();
const sapper = require('sapper');
const app = express();
app.use(sapper());
const { PORT = 3000 } = process.env;
@@ -45,13 +44,13 @@ Like Next, routes are defined by the project directory structure, but with some
* Files with an `.html` extension are treated as Svelte components. The `routes/about.html` (or `routes/about/index.html`) would create the `/about` route.
* Files with a `.js` or `.mjs` extension are more generic route handlers. These files should export functions corresponding to the HTTP methods they support (example below).
* Instead of route masking, we embed parameters in the filename. For example `post/%id%.html` maps to `/post/:id`, and the component will be rendered with the appropriate parameter.
* Nested routes (read [this article](https://joshduff.com/2015-06-why-you-need-a-state-router.md)) can be handled by creating a file that matches the subroute — for example, `routes/app/settings/%submenu%.html` would match `/app/settings/profile` *and* `app/settings`, but in the latter case the `submenu` parameter would be `null`.
* Instead of route masking, we embed parameters in the filename. For example `post/[id].html` maps to `/post/:id`, and the component will be rendered with the appropriate parameter.
* Nested routes (read [this article](https://joshduff.com/2015-06-why-you-need-a-state-router.md)) can be handled by creating a file that matches the subroute — for example, `routes/app/settings/[submenu].html` would match `/app/settings/profile` *and* `app/settings`, but in the latter case the `submenu` parameter would be `null`.
An example of a generic route:
```js
// routes/api/post/%id%.js
// routes/api/post/[id].js
export async function get(req, res) {
try {
const data = await getPostFromDatabase(req.params.id);
@@ -72,13 +71,71 @@ export async function get(req, res) {
Or, if you omit the `res` argument, it can use the return value:
```js
// routes/api/post/%id%.js
export async function get(req, res) {
// routes/api/post/[id].js
export async function get(req) {
return await getPostFromDatabase(req.params.id);
}
```
## Client-side app
Sapper will create (and in development mode, update) a barebones `main.js` file that dynamically imports individual routes and renders them — something like this:
```js
window.addEventListener('click', event => {
let a = event.target;
while (a && a.nodeName !== 'A') a = a.parentNode;
if (!a) return;
if (navigate(new URL(a.href))) event.preventDefault();
});
const target = document.querySelector('#sapper');
let component;
function navigate(url) {
if (url.origin !== window.location.origin) return;
let match;
let params = {};
const query = {};
function render(mod) {
if (component) {
component.destroy();
} else {
target.innerHTML = '';
}
component = new mod.default({
target,
data: { query, params },
hydrate: !!component
});
}
if (url.pathname === '/about') {
import('/about/index.html').then(render);
} else if (url.pathname === '/') {
import('/index.js').then(render);
} else if (match = /^\/post\/([^\/]+)$/.exec(url.pathname)) {
params.id = match[1];
import('/post/[id].html').then(render);
} else if (match = /^\/([^\/]+)$/.exec(url.pathname)) {
params.wildcard = match[1];
import('/[wildcard].html').then(render);
}
return true;
}
navigate(window.location);
```
We're glossing over a lot of important stuff here — e.g. handling `popstate` — but you get the idea. Knowledge of all the possible routes means we can generate optimal code, much in the same way that statically analysing Svelte templates allows the compiler to generate optimal code.
## Things to figure out
* How to customise the overall page template
@@ -87,4 +144,7 @@ export async function get(req, res) {
* `store` integration
* Route transitions
* Equivalent of `next export`
* A good story for realtime/GraphQL stuff
* Service worker
* Using `Link...rel=preload` headers to push main.js/[route].js plus styles
* ...and lots of other things that haven't occurred to me yet.

8
cli/index.js Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env node
const cmd = process.argv[2];
if (cmd === 'build') {
process.env.NODE_ENV = 'production';
require('../lib/build.js')();
}

View File

@@ -1,67 +0,0 @@
require('svelte/ssr/register');
const esm = require('@std/esm');
const path = require('path');
const glob = require('glob');
const create_matchers = require('./utils/create_matchers.js');
require = esm(module, {
esm: 'all'
});
module.exports = function connect(opts = {}) {
const routes = path.resolve('routes');
const out = path.resolve('.sapper');
let pages = glob.sync('**/*.html', { cwd: routes });
let page_matchers = create_matchers(pages);
let server_routes = glob.sync('**/*.+(js|mjs)', { cwd: routes });
let server_route_matchers = create_matchers(server_routes);
return async function(req, res, next) {
const url = req.url.replace(/\?.+/, '');
for (let i = 0; i < page_matchers.length; i += 1) {
const matcher = page_matchers[i];
if (matcher.test(url)) {
const params = matcher.exec(url);
const Component = require(`${routes}/${matcher.file}`);
res.end(Component.render({
params,
query: req.query
}));
return;
}
}
for (let i = 0; i < server_route_matchers.length; i += 1) {
const matcher = server_route_matchers[i];
if (matcher.test(url)) {
req.params = matcher.exec(url);
const route = require(`${routes}/${matcher.file}`);
const handler = route[req.method.toLowerCase()];
if (handler) {
if (handler.length === 2) {
handler(req, res);
} else {
const data = await handler(req);
// TODO headers, error handling
if (typeof data === 'string') {
res.end(data);
} else {
res.end(JSON.stringify(data));
}
}
return;
}
}
}
next();
};
};

38
lib/build.js Normal file
View File

@@ -0,0 +1,38 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const { client, server } = require('./utils/compilers.js');
const create_app = require('./utils/create_app.js');
const generate_asset_cache = require('./utils/generate_asset_cache.js');
const { dest } = require('./config.js');
module.exports = () => {
// create main.js and server-routes.js
create_app();
function handleErrors(err, stats) {
if (err) {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
process.exit(1);
}
if (stats.hasErrors()) {
console.log(stats.toString({ colors: true }));
process.exit(1);
}
}
client.run((err, clientStats) => {
handleErrors(err, clientStats);
const clientInfo = clientStats.toJson();
fs.writeFileSync(path.join(dest, 'stats.client.json'), JSON.stringify(clientInfo, null, ' '));
server.run((err, serverStats) => {
handleErrors(err, serverStats);
const serverInfo = serverStats.toJson();
fs.writeFileSync(path.join(dest, 'stats.server.json'), JSON.stringify(serverInfo, null, ' '));
generate_asset_cache(clientInfo, serverInfo);
});
});
};

21
lib/config.js Normal file
View File

@@ -0,0 +1,21 @@
const path = require('path');
const mkdirp = require('mkdirp');
const rimraf = require('rimraf');
exports.dev = process.env.NODE_ENV !== 'production';
exports.templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates');
exports.src = path.resolve(process.env.SAPPER_ROUTES || 'routes');
exports.dest = path.resolve(
process.env.NOW ? '/tmp' :
process.env.SAPPER_DEST || '.sapper'
);
if (exports.dev) {
mkdirp(exports.dest);
rimraf.sync(path.join(exports.dest, '**/*'));
}
exports.server_routes = path.resolve(exports.dest, 'server-routes.js');

208
lib/index.js Normal file
View File

@@ -0,0 +1,208 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const rimraf = require('rimraf');
const mkdirp = require('mkdirp');
const webpack = require('webpack');
const route_manager = require('./route_manager.js');
const templates = require('./templates.js');
const create_app = require('./utils/create_app.js');
const create_watcher = require('./utils/create_watcher.js');
const compilers = require('./utils/compilers.js');
const generate_asset_cache = require('./utils/generate_asset_cache.js');
const escape_html = require('escape-html');
const { src, dest, dev } = require('./config.js');
function connect_dev() {
// create main.js and server-routes.js
// TODO update on changes
create_app();
const watcher = create_watcher();
let asset_cache;
return compose_handlers([
require('webpack-hot-middleware')(compilers.client, {
reload: true,
path: '/__webpack_hmr',
heartbeat: 10 * 1000
}),
async (req, res, next) => {
asset_cache = await watcher.ready;
next();
},
set_req_pathname,
get_asset_handler({
filter: pathname => pathname === '/index.html',
type: 'text/html',
cache: 'max-age=600',
fn: () => asset_cache.client.index
}),
get_asset_handler({
filter: pathname => pathname === '/service-worker.js',
type: 'application/javascript',
cache: 'max-age=600',
fn: () => asset_cache.client.service_worker
}),
get_asset_handler({
filter: pathname => pathname.startsWith('/client/'),
type: 'application/javascript',
cache: 'max-age=31536000',
fn: pathname => asset_cache.client.chunks[pathname]
}),
get_route_handler(() => asset_cache),
not_found
]);
}
function connect_prod() {
const asset_cache = generate_asset_cache(
read_json(path.join(dest, 'stats.client.json')),
read_json(path.join(dest, 'stats.server.json'))
);
return compose_handlers([
set_req_pathname,
get_asset_handler({
filter: pathname => pathname === '/index.html',
type: 'text/html',
cache: 'max-age=600',
fn: () => asset_cache.client.index
}),
get_asset_handler({
filter: pathname => pathname === '/service-worker.js',
type: 'application/javascript',
cache: 'max-age=600',
fn: () => asset_cache.client.service_worker
}),
get_asset_handler({
filter: pathname => pathname.startsWith('/client/'),
type: 'application/javascript',
cache: 'max-age=31536000',
fn: pathname => asset_cache.client.chunks[pathname]
}),
get_route_handler(() => asset_cache),
not_found
]);
}
module.exports = dev ? connect_dev : connect_prod;
function set_req_pathname(req, res, next) {
req.pathname = req.url.replace(/\?.+/, '');
next();
}
function get_asset_handler(opts) {
return (req, res, next) => {
if (!opts.filter(req.pathname)) return next();
res.set({
'Content-Type': opts.type,
'Cache-Control': opts.cache
});
res.end(opts.fn(req.pathname));
};
}
function get_route_handler(fn) {
return async function handle_route(req, res, next) {
const url = req.pathname;
const { client, server } = fn();
// whatever happens, we're going to serve some HTML
res.set({
'Content-Type': 'text/html'
});
try {
for (const route of route_manager.routes) {
if (route.test(url)) {
req.params = route.exec(url);
const mod = require(server.entry)[route.id];
if (route.type === 'page') {
let data = { params: req.params, query: req.query };
if (mod.preload) data = Object.assign(data, await mod.preload(data));
const { html, head, css } = mod.render(data);
const page = templates.render(200, {
main: client.main_file,
html,
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
styles: (css && css.code ? `<style>${css.code}</style>` : '')
});
res.status(200);
res.end(page);
}
else {
const handler = mod[req.method.toLowerCase()];
if (handler) handler(req, res, next);
}
return;
}
}
next();
} catch(err) {
res.status(500).end(templates.render(500, {
title: (err && err.name) || 'Internal server error',
url,
error: escape_html(err && (err.details || err.message || err) || 'Unknown error'),
stack: err && err.stack.split('\n').slice(1).join('\n')
}));
}
}
}
function not_found(req, res) {
res.status(404).end(templates.render(404, {
title: 'Not found',
status: 404,
method: req.method,
url
}));
}
function compose_handlers(handlers) {
return (req, res, next) => {
let i = 0;
function go() {
const handler = handlers[i];
if (handler) {
handler(req, res, () => {
i += 1;
go();
});
} else {
next();
}
}
go();
}
}
function read_json(file) {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
}

16
lib/route_manager.js Normal file
View File

@@ -0,0 +1,16 @@
const glob = require('glob');
const create_routes = require('./utils/create_routes.js');
const { src } = require('./config.js');
const route_manager = {
routes: create_routes(
glob.sync('**/*.+(html|js|mjs)', { cwd: src })
),
onchange(fn) {
// TODO in dev mode, keep this updated, and allow
// webpack compiler etc to hook into it
}
};
module.exports = route_manager;

43
lib/templates.js Normal file
View File

@@ -0,0 +1,43 @@
const fs = require('fs');
const glob = require('glob');
const templates = glob.sync('*.html', { cwd: 'templates' })
.map(file => {
const template = fs.readFileSync(`templates/${file}`, 'utf-8');
const status = file.replace('.html', '').toLowerCase();
if (!/^[0-9x]{3}$/.test(status)) {
throw new Error(`Bad template — should be a valid status code like 404.html, or a wildcard like 2xx.html`);
}
const specificity = (
(status[0] === 'x' ? 0 : 4) +
(status[1] === 'x' ? 0 : 2) +
(status[2] === 'x' ? 0 : 1)
);
const pattern = new RegExp(`^${status.split('').map(d => d === 'x' ? '\\d' : d).join('')}$`);
return {
test: status => pattern.test(status),
specificity,
render(data) {
return template.replace(/%sapper\.(\w+)%/g, (match, key) => {
return key in data ? data[key] : '';
});
}
}
})
.sort((a, b) => b.specificity - a.specificity);
exports.render = (status, data) => {
const template = templates.find(template => template.test(status));
if (template) return template.render(data);
return `Missing template for status code ${status}`;
};
exports.onchange = fn => {
// TODO in dev mode, keep this updated, and allow
// webpack compiler etc to hook into it
};

11
lib/utils/compilers.js Normal file
View File

@@ -0,0 +1,11 @@
const path = require('path');
const relative = require('require-relative');
const webpack = relative('webpack', process.cwd());
exports.client = webpack(
require(path.resolve('webpack.client.config.js'))
);
exports.server = webpack(
require(path.resolve('webpack.server.config.js'))
);

59
lib/utils/create_app.js Normal file
View File

@@ -0,0 +1,59 @@
const fs = require('fs');
const path = require('path');
const route_manager = require('../route_manager.js');
const { src, dest, server_routes, dev } = require('../config.js');
module.exports = function create_app() {
const { routes } = route_manager;
function create_client_main() {
const template = fs.readFileSync('templates/main.js', 'utf-8');
const code = `[${
routes
.filter(route => route.type === 'page')
.map(route => {
const params = route.dynamic.length === 0 ?
'{}' :
`{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ') } }`;
return `{ pattern: ${route.pattern}, params: match => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${src}/${route.file}') }`
})
.join(', ')
}]`;
const main = template
.replace(/__app__/g, path.resolve(__dirname, '../../runtime/app.js'))
.replace(/__routes__/g, code)
.replace(/__dev__/g, String(dev));
const file = path.resolve(dest, 'main.js');
fs.writeFileSync(file, main);
// need to fudge the mtime, because webpack is soft in the head
const { atime, mtime } = fs.statSync(file);
fs.utimesSync(file, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999));
}
function create_server_routes() {
const imports = routes
.map(route => {
return route.type === 'page' ?
`import ${route.id} from '${src}/${route.file}';` :
`import * as ${route.id} from '${src}/${route.file}';`;
})
.join('\n');
const exports = `export { ${routes.map(route => route.id)} };`;
fs.writeFileSync(server_routes, `${imports}\n\n${exports}`);
const { atime, mtime } = fs.statSync(server_routes);
fs.utimesSync(server_routes, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999));
}
// TODO in dev mode, watch files
create_client_main();
create_server_routes();
};

View File

@@ -3,15 +3,21 @@ const path = require('path');
module.exports = function create_matchers(files) {
return files
.map(file => {
if (/(^|\/|\\)_/.test(file)) return;
const parts = file.replace(/\.(html|js|mjs)$/, '').split(path.sep);
if (parts[parts.length - 1] === 'index') parts.pop();
const id = (
parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_')
) || '_';
const dynamic = parts
.filter(part => part[0] === '%')
.filter(part => part[0] === '[')
.map(part => part.slice(1, -1));
const pattern = new RegExp(
`^\\/${parts.map(p => p[0] === '%' ? '([^/]+)' : p).join('\\/')}$`
`^\\/${parts.map(p => p[0] === '[' ? '([^/]+)' : p).join('\\/')}$`
);
const test = url => pattern.test(url);
@@ -29,24 +35,21 @@ module.exports = function create_matchers(files) {
};
return {
id,
type: path.extname(file) === '.html' ? 'page' : 'route',
file,
pattern,
test,
exec,
parts,
dynamic
};
})
.filter(Boolean)
.sort((a, b) => {
return (
(a.dynamic.length - b.dynamic.length) || // match static paths first
(b.parts.length - a.parts.length) // match longer paths first
);
})
.map(matcher => {
return {
file: matcher.file,
test: matcher.test,
exec: matcher.exec
}
});
}

View File

@@ -0,0 +1,63 @@
const path = require('path');
const chalk = require('chalk');
const compilers = require('./compilers.js');
const generate_asset_cache = require('./generate_asset_cache.js');
function deferred() {
const d = {};
d.promise = new Promise((fulfil, reject) => {
d.fulfil = fulfil;
d.reject = reject;
});
return d;
}
module.exports = function create_watcher() {
const deferreds = {
client: deferred(),
server: deferred()
};
const invalidate = () => Promise.all([
deferreds.client.promise,
deferreds.server.promise
]).then(([client_stats, server_stats]) => {
return generate_asset_cache(
client_stats.toJson(),
server_stats.toJson()
);
});
watcher = {
ready: invalidate()
};
function watch_compiler(type) {
const compiler = compilers[type];
compiler.plugin('invalid', filename => {
console.log(chalk.red(`${type} bundle invalidated, file changed: ${chalk.bold(filename)}`));
deferreds[type] = deferred();
watcher.ready = invalidate();
});
compiler.plugin('failed', err => {
deferreds[type].reject(err);
});
compiler.watch({}, (err, stats) => {
if (stats.hasErrors()) {
deferreds[type].reject(stats.toJson().errors[0]);
} else {
deferreds[type].fulfil(stats);
}
});
}
watch_compiler('client');
watch_compiler('server');
return watcher;
};

View File

@@ -0,0 +1,67 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const templates = require('../templates.js');
const route_manager = require('../route_manager.js');
const { dest } = require('../config.js');
module.exports = function generate_asset_cache(clientInfo, serverInfo) {
const main_file = `/client/${clientInfo.assetsByChunkName.main}`;
const chunk_files = clientInfo.assets.map(chunk => `/client/${chunk.name}`);
const service_worker = generate_service_worker(chunk_files);
const index = generate_index(main_file);
fs.writeFileSync(path.join(dest, 'service-worker.js'), service_worker);
fs.writeFileSync(path.join(dest, 'index.html'), index);
return {
client: {
main_file,
chunk_files,
main: read(`${dest}${main_file}`),
chunks: chunk_files.reduce((lookup, file) => {
lookup[file] = read(`${dest}${file}`);
return lookup;
}, {}),
index,
service_worker
},
server: {
entry: path.resolve(dest, 'server', serverInfo.assetsByChunkName.server_routes)
}
};
};
function generate_service_worker(chunk_files) {
const assets = glob.sync('**', { cwd: 'assets', nodir: true });
const route_code = `[${
route_manager.routes
.filter(route => route.type === 'page')
.map(route => `{ pattern: ${route.pattern} }`)
.join(', ')
}]`;
return read('templates/service-worker.js')
.replace('__timestamp__', Date.now())
.replace('__assets__', JSON.stringify(assets))
.replace('__shell__', JSON.stringify(chunk_files.concat('/index.html')))
.replace('__routes__', route_code);
}
function generate_index(main_file) {
return templates.render(200, {
styles: '',
head: '',
html: '<noscript>Please enable JavaScript!</noscript>',
main: main_file
});
}
function read(file) {
return fs.readFileSync(file, 'utf-8');
}

3492
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,27 @@
{
"name": "sapper",
"version": "0.0.1",
"description": "Combat-ready apps, engineered by Svelte",
"main": "connect.js",
"version": "0.2.0",
"description": "Military-grade apps, engineered by Svelte",
"main": "lib/index.js",
"bin": {
"sapper": "cli/index.js"
},
"directories": {
"test": "test"
},
"dependencies": {
"@std/esm": "^0.18.0"
"chalk": "^2.3.0",
"escape-html": "^1.0.3",
"mkdirp": "^0.5.1",
"relative": "^3.0.2",
"require-relative": "^0.8.7",
"rimraf": "^2.6.2",
"webpack": "^3.10.0",
"webpack-hot-middleware": "^2.21.0"
},
"devDependencies": {
"mocha": "^4.0.1",
"svelte": "^1.47.0"
"eslint": "^4.13.1",
"mocha": "^4.0.1"
},
"scripts": {
"test": "mocha --opts mocha.opts"
@@ -29,8 +39,5 @@
"bugs": {
"url": "https://github.com/sveltejs/sapper/issues"
},
"homepage": "https://github.com/sveltejs/sapper#readme",
"@std/esm": {
"esm": "js"
}
"homepage": "https://github.com/sveltejs/sapper#readme"
}

192
runtime/app.js Normal file
View File

@@ -0,0 +1,192 @@
const detach = node => {
node.parentNode.removeChild(node);
};
export let component;
let target;
let routes;
const scroll_history = {};
let uid = 1;
let cid;
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual'
}
function select_route(url) {
if (url.origin !== window.location.origin) return null;
for (const route of routes) {
const match = route.pattern.exec(url.pathname);
if (match) {
const params = route.params(match);
const query = {};
for (const [key, value] of url.searchParams) query[key] = value || true;
return { route, data: { params, query } };
}
}
}
function render(Component, data, scroll) {
Promise.resolve(
Component.preload ? Component.preload(data) : {}
).then(preloaded => {
if (component) {
component.destroy();
} else {
// first load — remove SSR'd <head> contents
const start = document.querySelector('#sapper-head-start');
let end = document.querySelector('#sapper-head-end');
if (start && end) {
while (start.nextSibling !== end) detach(start.nextSibling);
detach(start);
detach(end);
}
// preload additional routes
routes.reduce((promise, route) => promise.then(route.load), Promise.resolve());
}
component = new Component({
target,
data: Object.assign(data, preloaded),
hydrate: !!component
});
if (scroll) {
window.scrollTo(scroll.x, scroll.y);
}
});
}
function navigate(url, id) {
const selected = select_route(url);
if (selected) {
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 };
history.pushState({ id }, '', url.href);
}
selected.route.load().then(mod => {
render(mod.default, selected.data, scroll_history[id]);
});
cid = id;
return true;
}
}
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;
const 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
const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString';
const href = 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;
const url = new URL(href);
// Don't handle hash changes
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
if (navigate(url, null)) {
event.preventDefault();
}
}
function handle_popstate(event) {
scroll_history[cid] = scroll_state();
if (event.state) {
navigate(new URL(window.location), event.state.id);
} else {
// hashchange
cid = ++uid;
history.replaceState({ id: cid }, '', window.location.href);
}
}
function prefetch(event) {
const a = findAnchor(event.target);
if (!a || a.rel !== 'prefetch') return;
const selected = select_route(new URL(a.href));
if (selected) {
selected.route.load().then(mod => {
if (mod.default.preload) mod.default.preload(selected.data);
});
}
}
function findAnchor(node) {
while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG <a> elements have a lowercase name
return node;
}
let inited;
export function init(_target, _routes) {
target = _target;
routes = _routes;
if (!inited) { // this check makes HMR possible
window.addEventListener('click', handle_click);
window.addEventListener('popstate', handle_popstate);
// prefetch
window.addEventListener('touchstart', prefetch);
window.addEventListener('mouseover', prefetch);
inited = true;
}
const scroll = scroll_history[uid] = scroll_state();
history.replaceState({ id: uid }, '', window.location.href);
navigate(new URL(window.location), uid);
}
function which(event) {
event = event || window.event;
return event.which === null ? event.button : event.which;
}
function scroll_state() {
return {
x: window.scrollX,
y: window.scrollY
};
}

View File

@@ -1,25 +1,25 @@
const path = require('path');
const assert = require('assert');
const create_matchers = require('./create_matchers.js');
const create_matchers = require('../../lib/utils/create_routes.js');
describe('create_matchers', () => {
it('sorts routes correctly', () => {
const matchers = create_matchers(['index.html', 'about.html', '%wildcard%.html', 'post/%id%.html']);
const matchers = create_matchers(['index.html', 'about.html', '[wildcard].html', 'post/[id].html']);
assert.deepEqual(
matchers.map(m => m.file),
[
'about.html',
'index.html',
'post/%id%.html',
'%wildcard%.html'
'post/[id].html',
'[wildcard].html'
]
);
});
it('generates params', () => {
const matchers = create_matchers(['index.html', 'about.html', '%wildcard%.html', 'post/%id%.html']);
const matchers = create_matchers(['index.html', 'about.html', '[wildcard].html', 'post/[id].html']);
let file;
let params;
@@ -31,9 +31,21 @@ describe('create_matchers', () => {
}
}
assert.equal(file, 'post/%id%.html');
assert.equal(file, 'post/[id].html');
assert.deepEqual(params, {
id: '123'
});
});
it('ignores files and directories with leading underscores', () => {
const matches = create_matchers(['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html']);
assert.deepEqual(
matches.map(m => m.file),
[
'e/f/g/h.html',
'index.html'
]
);
});
});

41
webpack/config.js Normal file
View File

@@ -0,0 +1,41 @@
const path = require('path');
const route_manager = require('../lib/route_manager.js');
const { src, dest, dev, server_routes } = require('../lib/config.js');
module.exports = {
dev,
client: {
entry: () => {
return {
main: `${dest}/main.js`
};
},
output: () => {
return {
path: `${dest}/client`,
filename: '[name].[hash].js',
chunkFilename: '[name].[id].[hash].js',
publicPath: '/client/'
};
}
},
server: {
entry: () => {
return {
server_routes
}
},
output: () => {
return {
path: `${dest}/server`,
filename: '[name].[hash].js',
chunkFilename: '[name].[id].[hash].js',
libraryTarget: 'commonjs2'
};
}
}
};

1
webpack/hmr.js Normal file
View File

@@ -0,0 +1 @@
import 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000';