Compare commits

...

83 Commits

Author SHA1 Message Date
Rich Harris
8faa98af6a -> v0.4.0 2018-01-14 12:07:06 -05:00
Rich Harris
14df138528 Merge pull request #87 from sveltejs/gh-3
Serialize preloaded data for initial page and serve to client
2018-01-14 11:50:18 -05:00
Rich Harris
44285cdb2f serialized preloaded data and send to client as initial payload - fixes #3 2018-01-14 11:22:00 -05:00
Rich Harris
bd656cfd5b Merge branch 'fix-tests-without-async' into gh-3 2018-01-14 10:45:55 -05:00
Rich Harris
c4b4bd587d use %sapper.scripts% 2018-01-14 10:45:21 -05:00
Rich Harris
2abfdb03d5 wait for window.READY so that this tests we dont double-preload 2018-01-14 10:44:38 -05:00
Rich Harris
a80ac3a8b8 change tests back to non-async versions 2018-01-14 00:43:57 -05:00
Rich Harris
887cb09386 merge 2018-01-14 00:21:55 -05:00
Rich Harris
cfeeafded4 Merge pull request #84 from sveltejs/test-in-node-6
test in node 6
2018-01-14 00:18:23 -05:00
Rich Harris
2cae674033 Merge branch 'master' into test-in-node-6 2018-01-14 00:14:40 -05:00
Rich Harris
7c0f32662d remove async from new test 2018-01-14 00:10:20 -05:00
Rich Harris
b4fb1c3268 Merge branch 'master' of github.com:sveltejs/sapper 2018-01-14 00:02:32 -05:00
Rich Harris
ecd0f673a9 test in node 6 2018-01-14 00:01:51 -05:00
Rich Harris
40d16852f7 prevent unnecessary promise chains, and hoist handler function 2018-01-14 00:01:10 -05:00
Rich Harris
133be03791 Merge pull request #72 from nolanlawson/fix-style-loader
Explicitly load style-loader/css-loader deps
2018-01-13 23:33:38 -05:00
Rich Harris
727a76ebb5 fall through to 404 if no handler exists for method 2018-01-13 23:29:41 -05:00
Rich Harris
e3c047831a add test for #77 2018-01-13 23:29:16 -05:00
Emil Tholin
81b5e0d764 Handle DELETE requests with del export 2018-01-10 19:24:22 +01:00
Emil Tholin
98e904dcfc Remove async/await from test.js 2018-01-07 12:22:40 +01:00
Nolan Lawson
ca51372150 Explicitly load style-loader/css-loader deps
This appears to fix sveltejs/sapper-template#27
2018-01-06 17:21:40 -08:00
Luke Edwards
7cef1f1120 only return from show-url if in server context 2018-01-06 16:11:39 -08:00
Luke Edwards
1b73baabce [temp - debug]: log out the preload value 2018-01-06 15:40:40 -08:00
Luke Edwards
5aa01b922b add page.title() Nightmare action 2018-01-06 15:16:18 -08:00
Luke Edwards
f0bc68be88 use built-in for document.title 2018-01-06 15:07:43 -08:00
Luke Edwards
be7c53becc quick attempt at fixing test for travis 2018-01-06 14:53:14 -08:00
Emil Tholin
2b3472b1b1 Remove async/await from lib 2018-01-05 23:19:40 +01:00
Rich Harris
ee94f355d5 update marked due to vulnerability 2018-01-04 17:17:08 -05:00
Rich Harris
bea9b7965a update package-lock 2018-01-04 17:13:17 -05:00
Rich Harris
1312aede1f -> v0.3.2 2018-01-03 13:48:03 -05:00
Rich Harris
50e307e0c0 add test for programmatic prefetch 2018-01-03 13:46:07 -05:00
Emil Tholin
e87ac1f367 Export prefetch 2018-01-03 18:39:51 +01:00
Rich Harris
5da9d0926a update README, add license 2018-01-01 16:50:21 -05:00
Rich Harris
9538499d51 -> v0.3.1 2017-12-31 16:36:02 -05:00
Rich Harris
ff1e632057 -> v0.3.0 2017-12-31 16:10:36 -05:00
Rich Harris
aeeb231477 Merge branch 'master' of github.com:sveltejs/sapper 2017-12-31 16:08:14 -05:00
Rich Harris
d1940db8c0 Merge pull request #56 from sveltejs/gh-55
handle nested routes
2017-12-31 16:08:08 -05:00
Rich Harris
98f9a64b64 specify pkg.files 2017-12-31 16:05:26 -05:00
Rich Harris
b9bef802d3 lint 2017-12-31 16:05:18 -05:00
Rich Harris
a7024b3806 fix index route 2017-12-31 16:04:54 -05:00
Rich Harris
423e02aeae handle nested routes - fixes #55 2017-12-31 13:25:52 -05:00
Rich Harris
12b73ecebf Merge pull request #54 from sveltejs/preload-req
pass server request object to preload
2017-12-31 13:02:24 -05:00
Rich Harris
e1bc38b5a7 bump timeout 2017-12-31 12:49:17 -05:00
Rich Harris
b66f624f01 pass server request object to preload 2017-12-30 17:31:07 -05:00
Rich Harris
502dd547d1 only preload once - fixes #49 2017-12-25 18:06:27 -05:00
Rich Harris
4c343490d2 merge master -> gh-49 2017-12-25 17:34:27 -05:00
Rich Harris
b3027c5816 port runtime to typescript, move runtime/app.js to runtime.js 2017-12-25 17:14:39 -05:00
Rich Harris
c29e8022cc replace all occurences of variables 2017-12-25 16:18:19 -05:00
Rich Harris
e4cd4c9cb0 Merge pull request #52 from sveltejs/gh-48
cancel navigation if overtaken by a second navigation
2017-12-25 16:17:48 -05:00
Rich Harris
feddad42b2 failing test for #49 2017-12-24 21:34:28 -05:00
Rich Harris
3c4ebcda30 cancel navigation if overtaken by a second navigation - fixes #48 2017-12-24 20:37:00 -05:00
Rich Harris
75aedf4663 -> v0.2.10 2017-12-24 07:56:01 -05:00
Rich Harris
c8366dec74 handle deep links 2017-12-24 07:53:39 -05:00
Rich Harris
9a936669c6 -> v0.2.9 2017-12-23 15:01:49 -05:00
Rich Harris
0226bd90c6 dont write service-worker.js and index.html to disk in prod mode 2017-12-23 15:01:29 -05:00
Rich Harris
e1926e1bcb -> v0.2.8 2017-12-23 13:16:58 -05:00
Rich Harris
db1c1f332a Add goto function (#46)
* WIP

* programmatic navigation

* get tests working

* wait longer
2017-12-23 13:15:40 -05:00
Rich Harris
e8d510b261 dont use /tmp, it fails when now server wakes after a sleep 2017-12-23 12:27:51 -05:00
Rich Harris
f8e237b265 make invalidation message look less like an error 2017-12-23 10:55:55 -05:00
Rich Harris
68c2f2e388 linting 2017-12-22 09:19:32 -05:00
Rich Harris
0bcb61650b use nightmare for testing 2017-12-21 16:48:53 -05:00
Rich Harris
43a12a8331 -> v0.2.7 2017-12-21 13:40:10 -05:00
Rich Harris
f0feab5738 fix typo 2017-12-21 13:39:13 -05:00
Rich Harris
e9203b4d71 empty .sapper when building 2017-12-21 12:01:53 -05:00
Rich Harris
8e79e706e6 -> v0.2.6 2017-12-21 11:56:04 -05:00
Rich Harris
4b495f44fd Merge pull request #42 from sveltejs/gh-40
render main.js back to templates, to allow relative imports
2017-12-21 11:54:53 -05:00
Rich Harris
222a750b7b render main.js back to templates, to allow relative imports - fixes #40 2017-12-21 11:38:03 -05:00
Rich Harris
5b214c964c -> v0.2.5 2017-12-21 08:59:00 -05:00
Rich Harris
95f99fd378 Merge pull request #39 from sveltejs/windows-nested-routes
use /, not path.sep, when creating routes
2017-12-21 08:55:41 -05:00
Rich Harris
1bed4b0670 use /, not path.sep, when creating routes 2017-12-21 08:46:40 -05:00
Rich Harris
9d4890913a Merge pull request #37 from sveltejs/gh-33
error if routes clash
2017-12-20 21:00:04 -05:00
Rich Harris
f50d3c4262 error if routes clash - fixes #33 2017-12-20 20:46:28 -05:00
Rich Harris
8925e541d5 Merge pull request #36 from sveltejs/gh-19
stream responses
2017-12-20 18:50:06 -05:00
Rich Harris
a48afb77d3 stream responses - fixes #19 2017-12-20 18:39:39 -05:00
Rich Harris
45e845ee92 Merge pull request #35 from sveltejs/gh-2
add preload header for main.js and current route
2017-12-20 18:13:07 -05:00
Rich Harris
492f024d2a add preload header for main.js and current route - fixes #2 2017-12-20 18:04:11 -05:00
Rich Harris
8d40992cf1 Merge pull request #34 from sveltejs/gh-13
Rebundle when routes/templates change
2017-12-20 17:51:04 -05:00
Rich Harris
4232f75b19 rebundle when main.js changes 2017-12-20 17:36:22 -05:00
Rich Harris
fefb0d96d7 rebundle when routes and templates change 2017-12-20 17:34:23 -05:00
Rich Harris
cd91bf2ca4 -> v0.2.4 2017-12-20 16:33:54 -05:00
Rich Harris
7466e8da82 -> v0.2.3 2017-12-20 09:31:51 -05:00
Rich Harris
463307db86 pass URL to 404 handler 2017-12-20 08:29:28 -05:00
Rich Harris
2a68394dce Merge pull request #31 from sveltejs/possible-windows-fix-3
posixify import paths
2017-12-20 08:28:32 -05:00
Rich Harris
c8fe0679ae posixify import paths 2017-12-19 20:48:44 -05:00
51 changed files with 5493 additions and 2223 deletions

View File

@@ -1,7 +1,6 @@
{
"root": true,
"rules": {
"indent": [ 2, "tab", { "SwitchCase": 1 } ],
"semi": [ 2, "always" ],
"space-before-blocks": [ 2, "always" ],
"no-mixed-spaces-and-tabs": [ 2, "smart-tabs" ],
@@ -33,7 +32,7 @@
"plugin:import/warnings"
],
"parserOptions": {
"ecmaVersion": 6,
"ecmaVersion": 8,
"sourceType": "module"
}
}

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.DS_Store
node_modules
cypress/screenshots
test/app/.sapper
test/app/.sapper
runtime.js

View File

@@ -1,10 +1,22 @@
sudo: false
language: node_js
node_js:
- "6"
- "stable"
env:
global:
- BUILD_TIMEOUT=10000
addons:
apt:
packages:
- xvfb
install:
- export DISPLAY=':99.0'
- Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
- npm install
- (cd test/app && npm install)

View File

@@ -1,5 +1,67 @@
# sapper changelog
## 0.4.0
* `%sapper.main%` has been replaced with `%sapper.scripts%` ([#86](https://github.com/sveltejs/sapper/issues/86))
* Node 6 support ([#67](https://github.com/sveltejs/sapper/pull/67))
* Explicitly load css-loader and style-loader ([#72](https://github.com/sveltejs/sapper/pull/72))
* DELETE requests are handled with `del` exports ([#77](https://github.com/sveltejs/sapper/issues/77))
* Send preloaded data for first route to client, where possible ([#3](https://github.com/sveltejs/sapper/issues/3))
## 0.3.2
* Expose `prefetch` function ([#61](https://github.com/sveltejs/sapper/pull/61))
## 0.3.1
* Fix missing `runtime.js`
## 0.3.0
* Move `sapper/runtime/app.js` to `sapper/runtime.js`
* Cancel navigation if overtaken by second navigation ([#48](https://github.com/sveltejs/sapper/issues/48))
* Store preloaded data, to avoiding double prefetching ([#49](https://github.com/sveltejs/sapper/issues/49))
* Pass server request object to `preload` ([#54](https://github.com/sveltejs/sapper/pull/54))
* Nested routes ([#55](https://github.com/sveltejs/sapper/issues/55))
## 0.2.10
* Handle deep links correctly ([#44](https://github.com/sveltejs/sapper/issues/44))
## 0.2.9
* Don't write files to disk in prod mode
## 0.2.8
* Add `goto` function ([#29](https://github.com/sveltejs/sapper/issues/29))
* Don't use `/tmp` as destination in Now environments
## 0.2.7
* Fix streaming bug
## 0.2.6
* Render main.js back to templates, to allow relative imports ([#40](https://github.com/sveltejs/sapper/issues/40))
## 0.2.5
* Fix nested routes on Windows ([#39](https://github.com/sveltejs/sapper/pull/39))
* Rebundle when routes and main.js change ([#34](https://github.com/sveltejs/sapper/pull/34))
* Add `Link...preload` headers for JavaScript assets ([#2](https://github.com/sveltejs/sapper/issues/2))
* Stream document up to first dynamic content ([#19](https://github.com/sveltejs/sapper/issues/19))
* Error if routes clash ([#33](https://github.com/sveltejs/sapper/issues/33))
## 0.2.4
* Posixify path to HMR client
## 0.2.3
* Posixify import paths, even on Windows ([#31](https://github.com/sveltejs/sapper/pull/31))
* Pass `url` to 404 handler
## 0.2.2
* Create destination directory when building, don't assume it's already there from dev mode

9
LICENSE Normal file
View File

@@ -0,0 +1,9 @@
Copyright (c) 2017 [these people](https://github.com/sveltejs/sapper/graphs/contributors).
Permission is hereby granted by the authors of this software, to any person, to use the software for any purpose, free of charge, including the rights to run, read, copy, change, distribute and sell it, and including usage rights to any patents the authors may hold on it, subject to the following conditions:
This license, or a link to its text, must be included with all copies of the software and any derivative works.
Any modification to the software submitted to the authors may be incorporated into the software under the terms of this license.
The software is provided "as is", without warranty of any kind, including but not limited to the warranties of title, fitness, merchantability and non-infringement. The authors have no obligation to provide support or updates for the software, and may not be held liable for any damages, claims or other liability arising from its use.

151
README.md
View File

@@ -1,150 +1,37 @@
# sapper
Combat-ready apps, engineered by Svelte.
## This is not a thing yet
If you visit this README in a few weeks, hopefully it will have blossomed into the app development framework we deserve. Right now, it's just a set of ideas.
---
[Next.js](https://github.com/zeit/next.js/) introduced a beautiful idea — that you should be able to build your app as universal React components in a special `pages` directory, and the framework should take care of routing and rendering on both client and server. What if we did the same thing for Svelte?
High-level goals:
* Extreme ease of development
* 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
[Military-grade progressive web apps, powered by Svelte.](https://sapper.svelte.technology)
## Design
## What is Sapper?
A Sapper app is just an Express app (conventionally, `server.js`) that uses the `sapper` middleware:
Sapper is a framework for building high-performance universal web apps. [Read the guide](https://sapper.svelte.technology/guide) or the [introductory blog post](https://svelte.technology/blog/sapper-towards-the-ideal-web-app-framework) to learn more.
```js
const app = require('express')();
const sapper = require('sapper');
app.use(sapper());
## Get started
const { PORT = 3000 } = process.env;
app.listen(PORT, () => {
console.log(`listening on port ${PORT}`);
});
Clone the [starter project template](https://github.com/sveltejs/sapper-template) with [degit](https://github.com/rich-harris/degit)...
```bash
npx degit sveltejs/sapper-template my-app
```
The middleware serves pages that match files in the `routes` directory, and assets generated by webpack. In development mode, the middleware once activated watches `routes` to keep the app up-to-date.
...then install dependencies and start the dev server...
## Routing
Like Next, routes are defined by the project directory structure, but with some crucial differences:
* 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`.
An example of a generic route:
```js
// routes/api/post/[id].js
export async function get(req, res) {
try {
const data = await getPostFromDatabase(req.params.id);
const json = JSON.stringify(data);
res.set({
'Content-Type': 'application/json',
'Content-Length': json.length
});
res.send(json);
} catch (err) {
res.status(500).send(err.message);
}
}
```bash
cd my-app
npm install
npm run dev
```
Or, if you omit the `res` argument, it can use the return value:
...and navigate to [localhost:3000](http://localhost:3000). To build and run in production mode:
```js
// routes/api/post/[id].js
export async function get(req) {
return await getPostFromDatabase(req.params.id);
}
```bash
npm run build
npm start
```
## Client-side app
## License
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
* An equivalent of `getInitialProps`
* Critical CSS
* `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.
[LIL](LICENSE)

View File

@@ -1,14 +1,15 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const mkdirp = require('mkdirp');
const rimraf = require('rimraf');
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 = () => {
mkdirp(dest);
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
// create main.js and server-routes.js
create_app();

View File

@@ -8,14 +8,14 @@ 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'
);
exports.dest = path.resolve(process.env.SAPPER_DEST || '.sapper');
if (exports.dev) {
mkdirp(exports.dest);
mkdirp.sync(exports.dest);
rimraf.sync(path.join(exports.dest, '**/*'));
}
exports.server_routes = path.resolve(exports.dest, 'server-routes.js');
exports.entry = {
client: path.resolve(exports.templates, '.main.rendered.js'),
server: path.resolve(exports.dest, 'server-entry.js')
};

View File

@@ -1,9 +1,6 @@
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 serialize = require('serialize-javascript');
const route_manager = require('./route_manager.js');
const templates = require('./templates.js');
const create_app = require('./utils/create_app.js');
@@ -11,27 +8,27 @@ 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');
const { 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([
const middleware = 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();
(req, res, next) => {
watcher.ready.then(cache => {
asset_cache = cache;
next();
});
},
set_req_pathname,
@@ -59,8 +56,15 @@ function connect_dev() {
get_route_handler(() => asset_cache),
not_found
get_not_found_handler(() => asset_cache)
]);
middleware.close = () => {
watcher.close();
// TODO shut down chokidar
};
return middleware;
}
function connect_prod() {
@@ -69,7 +73,7 @@ function connect_prod() {
read_json(path.join(dest, 'stats.server.json'))
);
return compose_handlers([
const middleware = compose_handlers([
set_req_pathname,
get_asset_handler({
@@ -95,8 +99,14 @@ function connect_prod() {
get_route_handler(() => asset_cache),
not_found
get_not_found_handler(() => asset_cache)
]);
// here for API consistency between dev, and prod, but
// doesn't actually need to do anything
middleware.close = () => {};
return middleware;
}
module.exports = dev ? connect_dev : connect_prod;
@@ -118,69 +128,114 @@ function get_asset_handler(opts) {
};
}
function get_route_handler(fn) {
return async function handle_route(req, res, next) {
const url = req.pathname;
const resolved = Promise.resolve();
const { client, server } = fn();
function get_route_handler(fn) {
function handle_route(route, req, res, next, { client, server }) {
req.params = route.exec(req.pathname);
const mod = require(server.entry)[route.id];
if (route.type === 'page') {
// preload main.js and current route
// TODO detect other stuff we can preload? images, CSS, fonts?
res.set('Link', `<${client.main_file}>;rel="preload";as="script", <${client.routes[route.id]}>;rel="preload";as="script"`);
const data = { params: req.params, query: req.query };
if (mod.preload) {
const promise = Promise.resolve(mod.preload(req)).then(preloaded => {
const serialized = try_serialize(preloaded);
Object.assign(data, preloaded);
return { rendered: mod.render(data), serialized };
});
return templates.stream(res, 200, {
scripts: promise.then(({ serialized }) => {
const main = `<script src='${client.main_file}'></script>`;
if (serialized) {
return `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${main}`;
}
return main;
}),
html: promise.then(({ rendered }) => rendered.html),
head: promise.then(({ rendered }) => `<noscript id='sapper-head-start'></noscript>${rendered.head}<noscript id='sapper-head-end'></noscript>`),
styles: promise.then(({ rendered }) => (rendered.css && rendered.css.code ? `<style>${rendered.css.code}</style>` : ''))
});
} else {
const { html, head, css } = mod.render(data);
const page = templates.render(200, {
scripts: `<script src='${client.main_file}'></script>`,
html,
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
styles: (css && css.code ? `<style>${css.code}</style>` : '')
});
res.end(page);
}
}
else {
const method = req.method.toLowerCase();
// 'delete' cannot be exported from a module because it is a keyword,
// so check for 'del' instead
const method_export = method === 'delete' ? 'del' : method;
const handler = mod[method_export];
if (handler) {
handler(req, res, next);
} else {
// no matching handler for method — 404
next();
}
}
}
return function find_route(req, res, next) {
const url = req.pathname;
// whatever happens, we're going to serve some HTML
res.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;
resolved
.then(() => {
for (const route of route_manager.routes) {
if (route.test(url)) return handle_route(route, req, res, next, fn());
}
}
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')
}));
}
}
// no matching route — 404
next();
})
.catch(err => {
res.status(500);
res.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 get_not_found_handler(fn) {
return function handle_not_found(req, res) {
const asset_cache = fn();
res.status(404);
res.end(templates.render(404, {
title: 'Not found',
status: 404,
method: req.method,
scripts: `<script src='${asset_cache.client.main_file}'></script>`,
url: req.url
}));
};
}
function compose_handlers(handlers) {
@@ -200,9 +255,17 @@ function compose_handlers(handlers) {
}
go();
}
};
}
function read_json(file) {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
}
function try_serialize(data) {
try {
return serialize(data);
} catch (err) {
return null;
}
}

View File

@@ -1,16 +1,31 @@
const glob = require('glob');
const chokidar = require('chokidar');
const create_routes = require('./utils/create_routes.js');
const { src } = require('./config.js');
const { src, dev } = require('./config.js');
const route_manager = {
routes: create_routes(
glob.sync('**/*.+(html|js|mjs)', { cwd: src })
),
const callbacks = [];
onchange(fn) {
// TODO in dev mode, keep this updated, and allow
// webpack compiler etc to hook into it
}
exports.onchange = fn => {
callbacks.push(fn);
};
module.exports = route_manager;
function update() {
exports.routes = create_routes(
glob.sync('**/*.+(html|js|mjs)', { cwd: src })
);
callbacks.forEach(fn => fn());
}
update();
if (dev) {
const watcher = chokidar.watch(`${src}/**/*.+(html|js|mjs)`, {
ignoreInitial: true,
persistent: false
});
watcher.on('add', update);
watcher.on('change', update);
watcher.on('unlink', update);
}

View File

@@ -1,34 +1,117 @@
const fs = require('fs');
const glob = require('glob');
const chalk = require('chalk');
const chokidar = require('chokidar');
const framer = require('code-frame');
const { locate } = require('locate-character');
const { dev } = require('./config.js');
const templates = glob.sync('*.html', { cwd: 'templates' })
.map(file => {
const template = fs.readFileSync(`templates/${file}`, 'utf-8');
const status = file.replace('.html', '').toLowerCase();
let templates;
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`);
}
function error(e) {
if (e.title) console.error(chalk.bold.red(e.title));
if (e.body) console.error(chalk.red(e.body));
if (e.url) console.error(chalk.cyan(e.url));
if (e.frame) console.error(chalk.grey(e.frame));
const specificity = (
(status[0] === 'x' ? 0 : 4) +
(status[1] === 'x' ? 0 : 2) +
(status[2] === 'x' ? 0 : 1)
);
process.exit(1);
}
const pattern = new RegExp(`^${status.split('').map(d => d === 'x' ? '\\d' : d).join('')}$`);
function create_templates() {
templates = glob.sync('*.html', { cwd: 'templates' })
.map(file => {
const template = fs.readFileSync(`templates/${file}`, 'utf-8');
const status = file.replace('.html', '').toLowerCase();
return {
test: status => pattern.test(status),
specificity,
render(data) {
return template.replace(/%sapper\.(\w+)%/g, (match, key) => {
return key in data ? data[key] : '';
if (!/^[0-9x]{3}$/.test(status)) {
error({
title: `templates/${file}`,
body: `Bad template — should be a valid status code like 404.html, or a wildcard like 2xx.html`
});
}
}
})
.sort((a, b) => b.specificity - a.specificity);
const index = template.indexOf('%sapper.main%');
if (index !== -1) {
// TODO remove this in a future version
const { line, column } = locate(template, index, { offsetLine: 1 });
const frame = framer(template, line, column);
error({
title: `templates/${file}`,
body: `<script src='%sapper.main%'> is unsupported — use %sapper.scripts% (without the <script> tag) instead`,
url: 'https://github.com/sveltejs/sapper/issues/86',
frame
});
}
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] : '';
});
},
stream: (res, data) => {
let i = 0;
function stream_inner() {
if (i >= template.length) {
return;
}
const start = template.indexOf('%sapper', i);
if (start === -1) {
res.end(template.slice(i));
return;
}
res.write(template.slice(i, start));
const end = template.indexOf('%', start + 1);
if (end === -1) {
throw new Error(`Bad template`); // TODO validate ahead of time
}
const tag = template.slice(start + 1, end);
const match = /sapper\.(\w+)/.exec(tag);
if (!match || !(match[1] in data)) throw new Error(`Bad template`); // TODO ditto
return Promise.resolve(data[match[1]]).then(datamatch => {
res.write(datamatch);
i = end + 1;
return stream_inner();
});
}
return Promise.resolve().then(stream_inner);
}
};
})
.sort((a, b) => b.specificity - a.specificity);
}
create_templates();
if (dev) {
const watcher = chokidar.watch('templates/**.html', {
ignoreInitial: true,
persistent: false
});
watcher.on('add', create_templates);
watcher.on('change', create_templates);
watcher.on('unlink', create_templates);
}
exports.render = (status, data) => {
const template = templates.find(template => template.test(status));
@@ -37,7 +120,9 @@ exports.render = (status, 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
exports.stream = (res, status, data) => {
const template = templates.find(template => template.test(status));
if (template) return template.stream(res, data);
return `Missing template for status code ${status}`;
};

View File

@@ -1,9 +1,14 @@
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const route_manager = require('../route_manager.js');
const { src, dest, server_routes, dev } = require('../config.js');
const { src, entry, dev } = require('../config.js');
module.exports = function create_app() {
function posixify(file) {
return file.replace(/[/\\]/g, '/');
}
function create_app() {
const { routes } = route_manager;
function create_client_main() {
@@ -17,48 +22,62 @@ module.exports = function create_app() {
'{}' :
`{ ${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}') }`
const file = posixify(`${src}/${route.file}`);
return `{ pattern: ${route.pattern}, params: match => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`
})
.join(', ')
}]`;
let main = template
.replace(/__app__/g, path.resolve(__dirname, '../../runtime/app.js'))
.replace(/__app__/g, posixify(path.resolve(__dirname, '../../runtime/app.js')))
.replace(/__routes__/g, code)
.replace(/__dev__/g, String(dev));
if (dev) {
const hmr_client = require.resolve(`webpack-hot-middleware/client`);
const hmr_client = posixify(require.resolve(`webpack-hot-middleware/client`));
main += `\n\nimport('${hmr_client}?path=/__webpack_hmr&timeout=20000'); if (module.hot) module.hot.accept();`
}
const file = path.resolve(dest, 'main.js');
fs.writeFileSync(file, main);
fs.writeFileSync(entry.client, 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));
const { atime, mtime } = fs.statSync(entry.client);
fs.utimesSync(entry.client, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999));
}
function create_server_routes() {
const imports = routes
.map(route => {
const file = posixify(`${src}/${route.file}`);
return route.type === 'page' ?
`import ${route.id} from '${src}/${route.file}';` :
`import * as ${route.id} from '${src}/${route.file}';`;
`import ${route.id} from '${file}';` :
`import * as ${route.id} from '${file}';`;
})
.join('\n');
const exports = `export { ${routes.map(route => route.id)} };`;
fs.writeFileSync(server_routes, `${imports}\n\n${exports}`);
fs.writeFileSync(entry.server, `${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));
const { atime, mtime } = fs.statSync(entry.server);
fs.utimesSync(entry.server, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999));
}
// TODO in dev mode, watch files
create_client_main();
create_server_routes();
};
}
if (dev) {
route_manager.onchange(create_app);
const watcher = chokidar.watch(`templates/main.js`, {
ignoreInitial: true,
persistent: false
});
watcher.on('add', create_app);
watcher.on('change', create_app);
watcher.on('unlink', create_app);
}
module.exports = create_app;

View File

@@ -1,11 +1,11 @@
const path = require('path');
module.exports = function create_matchers(files) {
return files
const routes = files
.map(file => {
if (/(^|\/|\\)_/.test(file)) return;
const parts = file.replace(/\.(html|js|mjs)$/, '').split(path.sep);
const parts = file.replace(/\.(html|js|mjs)$/, '').split('/'); // glob output is always posix-style
if (parts[parts.length - 1] === 'index') parts.pop();
const id = (
@@ -16,9 +16,22 @@ module.exports = function create_matchers(files) {
.filter(part => part[0] === '[')
.map(part => part.slice(1, -1));
const pattern = new RegExp(
`^\\/${parts.map(p => p[0] === '[' ? '([^/]+)' : p).join('\\/')}$`
);
let pattern_string = '';
let i = parts.length;
let nested = true;
while (i--) {
const part = parts[i];
const dynamic = part[0] === '[';
if (dynamic) {
pattern_string = nested ? `(?:\\/([^/]+)${pattern_string})?` : `\\/([^/]+)${pattern_string}`;
} else {
nested = false;
pattern_string = `\\/${part}${pattern_string}`;
}
}
const pattern = new RegExp(`^${pattern_string || '\\/'}$`);
const test = url => pattern.test(url);
@@ -47,9 +60,31 @@ module.exports = function create_matchers(files) {
})
.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
);
let same = true;
for (let i = 0; true; i += 1) {
const a_part = a.parts[i];
const b_part = b.parts[i];
if (!a_part && !b_part) {
if (same) throw new Error(`The ${a.file} and ${b.file} routes clash`);
return 0;
}
if (!a_part) return -1;
if (!b_part) return 1;
const a_is_dynamic = a_part[0] === '[';
const b_is_dynamic = b_part[0] === '[';
if (a_is_dynamic === b_is_dynamic) {
if (!a_is_dynamic) same = false;
continue;
}
return a_is_dynamic ? 1 : -1;
}
});
}
return routes;
};

View File

@@ -1,7 +1,9 @@
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const compilers = require('./compilers.js');
const generate_asset_cache = require('./generate_asset_cache.js');
const { dest } = require('../config.js');
function deferred() {
const d = {};
@@ -24,21 +26,23 @@ module.exports = function create_watcher() {
deferreds.client.promise,
deferreds.server.promise
]).then(([client_stats, server_stats]) => {
const client_info = client_stats.toJson();
fs.writeFileSync(path.join(dest, 'stats.client.json'), JSON.stringify(client_info, null, ' '));
const server_info = server_stats.toJson();
fs.writeFileSync(path.join(dest, 'stats.server.json'), JSON.stringify(server_info, null, ' '));
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)}`));
console.log(chalk.cyan(`${type} bundle invalidated, file changed: ${chalk.bold(filename)}`));
deferreds[type] = deferred();
watcher.ready = invalidate();
});
@@ -47,7 +51,7 @@ module.exports = function create_watcher() {
deferreds[type].reject(err);
});
compiler.watch({}, (err, stats) => {
return compiler.watch({}, (err, stats) => {
if (stats.hasErrors()) {
deferreds[type].reject(stats.toJson().errors[0]);
} else {
@@ -56,8 +60,16 @@ module.exports = function create_watcher() {
});
}
watch_compiler('client');
watch_compiler('server');
const watcher = {
ready: invalidate(),
client: watch_compiler('client'),
server: watch_compiler('server'),
close: () => {
watcher.client.close();
watcher.server.close();
}
};
return watcher;
};

View File

@@ -3,17 +3,24 @@ 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');
const { dest, dev } = require('../config.js');
function ensure_array(thing) {
return Array.isArray(thing) ? thing : [thing]; // omg webpack what the HELL are you doing
}
module.exports = function generate_asset_cache(clientInfo, serverInfo) {
const main_file = `/client/${clientInfo.assetsByChunkName.main}`;
const main_file = `/client/${ensure_array(clientInfo.assetsByChunkName.main)[0]}`;
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);
if (dev) {
fs.writeFileSync(path.join(dest, 'service-worker.js'), service_worker);
fs.writeFileSync(path.join(dest, 'index.html'), index);
}
return {
client: {
@@ -26,12 +33,17 @@ module.exports = function generate_asset_cache(clientInfo, serverInfo) {
return lookup;
}, {}),
routes: route_manager.routes.reduce((lookup, route) => {
lookup[route.id] = `/client/${ensure_array(clientInfo.assetsByChunkName[route.id])[0]}`;
return lookup;
}, {}),
index,
service_worker
},
server: {
entry: path.resolve(dest, 'server', serverInfo.assetsByChunkName.server_routes)
entry: path.resolve(dest, 'server', serverInfo.assetsByChunkName.main)
}
};
};
@@ -47,10 +59,10 @@ function generate_service_worker(chunk_files) {
}]`;
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);
.replace(/__timestamp__/g, Date.now())
.replace(/__assets__/g, JSON.stringify(assets))
.replace(/__shell__/g, JSON.stringify(chunk_files.concat('/index.html')))
.replace(/__routes__/g, route_code);
}
function generate_index(main_file) {

View File

@@ -1,2 +1,3 @@
--recursive
test/unit/**/*.js
test/unit/**/*.js
test/common/test.js

3646
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,60 @@
{
"name": "sapper",
"version": "0.2.2",
"version": "0.4.0",
"description": "Military-grade apps, engineered by Svelte",
"main": "lib/index.js",
"bin": {
"sapper": "cli/index.js"
},
"files": [
"cli",
"lib",
"runtime",
"runtime.js",
"webpack"
],
"directories": {
"test": "test"
},
"dependencies": {
"chalk": "^2.3.0",
"chokidar": "^1.7.0",
"code-frame": "^5.0.0",
"escape-html": "^1.0.3",
"locate-character": "^2.0.5",
"mkdirp": "^0.5.1",
"relative": "^3.0.2",
"require-relative": "^0.8.7",
"rimraf": "^2.6.2",
"serialize-javascript": "^1.4.0",
"webpack": "^3.10.0",
"webpack-hot-middleware": "^2.21.0"
},
"devDependencies": {
"cypress": "^1.3.0",
"css-loader": "^0.28.7",
"eslint": "^4.13.1",
"eslint-plugin-import": "^2.8.0",
"express": "^4.16.2",
"get-port": "^3.2.0",
"mocha": "^4.0.1",
"nightmare": "^2.10.0",
"node-fetch": "^1.7.3",
"npm-run-all": "^4.1.2",
"rollup": "^0.53.0",
"rollup-plugin-typescript": "^0.8.1",
"style-loader": "^0.19.1",
"svelte": "^1.49.1",
"svelte-loader": "^2.3.2",
"tslib": "^1.8.1",
"typescript": "^2.6.2",
"wait-on": "^2.0.2"
},
"scripts": {
"cy:open": "cypress open",
"test": "run-s test:unit test:dev test:prod",
"test:unit": "mocha --opts mocha.opts",
"test:dev": "run-p --race test:launch:dev cy:run:dev",
"test:launch:dev": "node test/launch.js --dev",
"cy:run:dev": "wait-on http://localhost:3000 && cypress run -s test/cypress/integration/dev.js",
"test:prod": "run-p --race test:launch:prod cy:run:prod",
"test:launch:prod": "node test/launch.js --prod",
"cy:run:prod": "wait-on http://localhost:3000 && cypress run -s test/cypress/integration/prod.js"
"test": "mocha --opts mocha.opts",
"pretest": "npm run build",
"build": "rollup -c",
"dev": "rollup -cw"
},
"repository": "https://github.com/sveltejs/sapper",
"keywords": [

17
rollup.config.js Normal file
View File

@@ -0,0 +1,17 @@
import typescript from 'rollup-plugin-typescript';
export default [
// runtime.js
{
input: 'src/runtime/index.ts',
output: {
file: 'runtime.js',
format: 'es'
},
plugins: [
typescript({
typescript: require('typescript')
})
]
}
];

View File

@@ -1,192 +1,2 @@
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
};
}
console.error('sapper/runtime/app.js has been deprecated in favour of sapper/runtime.js');
export * from '../runtime.js';

227
src/runtime/index.ts Normal file
View File

@@ -0,0 +1,227 @@
import { detach, findAnchor, scroll_state, which } from './utils';
import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition } from './interfaces';
export let component: Component;
let target: Node;
let routes: Route[];
const history = typeof window !== 'undefined' ? window.history : {
pushState: (state: any, title: string, href: string) => {},
replaceState: (state: any, title: string, href: string) => {},
scrollRestoration: ''
};
const scroll_history: Record<string, ScrollPosition> = {};
let uid = 1;
let cid: number;
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
function select_route(url: URL): { route: Route, data: RouteData } {
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: Record<string, string | true> = {};
for (const [key, value] of url.searchParams) query[key] = value || true;
return { route, data: { params, query } };
}
}
}
let current_token: {};
function render(Component: ComponentConstructor, data: any, scroll: ScrollPosition, token: {}) {
if (current_token !== token) return;
if (component) {
component.destroy();
} else {
// first load — remove SSR'd <head> contents
const start = document.querySelector('#sapper-head-start');
const 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: Promise<any>, route) => promise.then(route.load), Promise.resolve());
}
component = new Component({
target,
data,
hydrate: !!component
});
if (scroll) {
window.scrollTo(scroll.x, scroll.y);
}
}
function prepare_route(Component: ComponentConstructor, data: RouteData) {
if (!Component.preload) {
return { Component, data };
}
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) {
return { Component, data: Object.assign(data, window.__SAPPER__.preloaded) };
}
return Promise.resolve(Component.preload(data)).then(preloaded => {
Object.assign(data, preloaded)
return { Component, data };
});
}
function navigate(url: URL, id: number) {
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 };
}
const loaded = prefetching && prefetching.href === url.href ?
prefetching.promise :
selected.route.load().then(mod => prepare_route(mod.default, selected.data));
prefetching = null;
const token = current_token = {};
loaded.then(({ Component, data }) => {
render(Component, data, scroll_history[id], token);
});
cid = id;
return true;
}
}
function handle_click(event: MouseEvent) {
// 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: HTMLAnchorElement | SVGAElement = <HTMLAnchorElement | SVGAElement>findAnchor(<Node>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 = String(svg ? (<SVGAElement>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 ? (<SVGAElement>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();
history.pushState({ id: cid }, '', url.href);
}
}
function handle_popstate(event: PopStateEvent) {
scroll_history[cid] = scroll_state();
if (event.state) {
navigate(new URL(window.location.href), event.state.id);
} else {
// hashchange
cid = ++uid;
history.replaceState({ id: cid }, '', window.location.href);
}
}
let prefetching: {
href: string;
promise: Promise<{ Component: ComponentConstructor, data: any }>;
} = null;
export function prefetch(href: string) {
const selected = select_route(new URL(href));
if (selected) {
prefetching = {
href,
promise: selected.route.load().then(mod => prepare_route(mod.default, selected.data))
};
}
}
function handle_touchstart_mouseover(event: MouseEvent | TouchEvent) {
const a: HTMLAnchorElement = <HTMLAnchorElement>findAnchor(<Node>event.target);
if (!a || a.rel !== 'prefetch') return;
prefetch(a.href);
}
let inited: boolean;
export function init(_target: Node, _routes: Route[]) {
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', handle_touchstart_mouseover);
window.addEventListener('mouseover', handle_touchstart_mouseover);
inited = true;
}
setTimeout(() => {
const { hash, href } = window.location;
const deep_linked = hash && document.querySelector(hash);
scroll_history[uid] = deep_linked ?
{ x: 0, y: deep_linked.getBoundingClientRect().top } :
scroll_state();
history.replaceState({ id: uid }, '', href);
navigate(new URL(window.location.href), uid);
});
}
export function goto(href: string, opts = { replaceState: false }) {
if (navigate(new URL(href, window.location.href), null)) {
if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);
} else {
window.location.href = href;
}
}

23
src/runtime/interfaces.ts Normal file
View File

@@ -0,0 +1,23 @@
export type Params = Record<string, string>;
export type Query = Record<string, string | true>;
export type RouteData = { params: Params, query: Query };
export interface ComponentConstructor {
new (options: { target: Node, data: any, hydrate: boolean }): Component;
preload: (data: { params: Params, query: Query }) => Promise<any>;
};
export interface Component {
destroy: () => void;
}
export type Route = {
pattern: RegExp;
params: (match: RegExpExecArray) => Record<string, string>;
load: () => Promise<{ default: ComponentConstructor }>
};
export type ScrollPosition = {
x: number;
y: number;
};

19
src/runtime/utils.ts Normal file
View File

@@ -0,0 +1,19 @@
export function detach(node: Node) {
node.parentNode.removeChild(node);
}
export function findAnchor(node: Node) {
while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG <a> elements have a lowercase name
return node;
}
export function which(event: MouseEvent) {
return event.which === null ? event.button : event.which;
}
export function scroll_state() {
return {
x: window.scrollX,
y: window.scrollY
};
}

1
test/app/.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules
.sapper
yarn.lock
cypress/screenshots
templates/.*

View File

@@ -2722,9 +2722,9 @@
"integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ="
},
"marked": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.3.7.tgz",
"integrity": "sha512-zBEP4qO1YQp5aXHt8S5wTiOv9i2X74V/LQL0zhUNvVaklt6Ywa6lChxIvS+ibYlCGgADwKwZFhjC3+XfpsvQvQ=="
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.3.9.tgz",
"integrity": "sha512-nW5u0dxpXxHfkHzzrveY45gCbi+R4PaO4WRZYqZNl+vB0hVGeqlFn0aOg1c8AKL63TrNFn9Bm2UP4AdiZ9TPLw=="
},
"math-expression-evaluator": {
"version": "1.2.17",

View File

@@ -15,7 +15,7 @@
"express": "^4.16.2",
"extract-text-webpack-plugin": "^3.0.2",
"glob": "^7.1.2",
"marked": "^0.3.7",
"marked": "^0.3.9",
"node-fetch": "^1.7.3",
"npm-run-all": "^4.1.2",
"serve-static": "^1.13.1",

View File

@@ -1,10 +1,8 @@
<nav>
<ul>
<li><a class='{{page === "home" ? "selected" : ""}}' href='/'>home</a></li>
<li><a class='{{page === "about" ? "selected" : ""}}' href='/about'>about</a></li>
<!-- for the blog link, we're using rel=prefetch so that Sapper prefetches
the blog data when we hover over the link or tap it on a touchscreen -->
<li><a href='/'>home</a></li>
<li><a href='/about'>about</a></li>
<li><a href='/slow-preload'>slow preload</a></li>
<li><a rel=prefetch class='{{page === "blog" ? "selected" : ""}}' href='/blog'>blog</a></li>
</ul>
</nav>

View File

@@ -6,14 +6,23 @@
<h1>About this site</h1>
<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='prefetch' on:click='goto("/blog/why-the-name")'>Why the name?</button>
</Layout>
<script>
import Layout from './_components/Layout.html';
import { goto, prefetch } from '../../../runtime.js';
export default {
components: {
Layout
},
methods: {
goto,
prefetch
}
};
</script>

View File

@@ -13,7 +13,7 @@ export function get(req, res, next) {
if (slug in lookup) {
res.set({
'Content-Type': 'application/json',
'Cache-Control': `max-age=${30 * 60 * 1e3}` // cache for 30 minutes
'Cache-Control': `no-cache`
});
res.end(lookup[slug]);

View File

@@ -82,6 +82,30 @@ const posts = [
html: `
<p>We're so glad you asked! Come on over to the <a href='https://github.com/sveltejs/svelte'>Svelte</a> and <a href='https://github.com/sveltejs/sapper'>Sapper</a> repos, and join us in the <a href='https://gitter.im/sveltejs/svelte'>Gitter chatroom</a>. Everyone is welcome, especially you!</p>
`
},
{
title: 'A very long post with deep links',
slug: 'a-very-long-post',
html: `
<h2 id='one'>One</h2>
<p>I'll have a vodka rocks. (Mom, it's breakfast time.) And a piece of toast. Let me out that Queen. Fried cheese… with club sauce.</p>
<p>Her lawyers are claiming the seal is worth $250,000. And that's not even including Buster's Swatch. This was a big get for God. What, so the guy we are meeting with can't even grow his own hair? COME ON! She's always got to wedge herself in the middle of us so that she can control everything. Yeah. Mom's awesome. It's, like, Hey, you want to go down to the whirlpool? Yeah, I don't have a husband. I call it Swing City. The CIA should've just Googled for his hideout, evidently. There are dozens of us! DOZENS! Yeah, like I'm going to take a whiz through this $5,000 suit. COME ON.</p>
<h2 id='two'>Two</h2>
<p>Tobias Fünke costume. Heart attack never stopped old big bear.</p>
<p>Nellie is blowing them all AWAY. I will be a bigger and hairier mole than the one on your inner left thigh! I'll sacrifice anything for my children.</p>
<p>Up yours, granny! You couldn't handle it! Hey, Dad. Look at you. You're a year older…and a year closer to death. Buster: Oh yeah, I guess that's kind of funny. Bob Loblaw Law Blog. The guy runs a prison, he can have any piece of ass he wants.</p>
<h2 id='three'>Three</h2>
<p>I prematurely shot my wad on what was supposed to be a dry run, so now I'm afraid I have something of a mess on my hands. Dead Dove DO NOT EAT. Never once touched my per diem. I'd go to Craft Service, get some raw veggies, bacon, Cup-A-Soup…baby, I got a stew goin'. You're losing blood, aren't you? Gob: Probably, my socks are wet. Sure, let the little fruit do it. HUZZAH! Although George Michael had only got to second base, he'd gone in head first, like Pete Rose. I will pack your sweet pink mouth with so much ice cream you'll be the envy of every Jerry and Jane on the block!</p>
<p>Gosh Mom… after all these years, God's not going to take a call from you. Come on, this is a Bluth family celebration. It's no place for children.</p>
<p>And I wouldn't just lie there, if that's what you're thinking. That's not what I WAS thinking. Who? i just dont want him to point out my cracker ass in front of ann. When a man needs to prove to a woman that he's actually… When a man loves a woman… Heyyyyyy Uncle Father Oscar. [Stabbing Gob] White power! Gob: I'm white! Let me take off my assistant's skirt and put on my Barbra-Streisand-in-The-Prince-of-Tides ass-masking therapist pantsuit. In the mid '90s, Tobias formed a folk music band with Lindsay and Maebe which he called Dr. Funke's 100 Percent Natural Good Time Family Band Solution. The group was underwritten by the Natural Food Life Company, a division of Chem-Grow, an Allen Crayne acqusition, which was part of the Squimm Group. Their motto was simple: We keep you alive.</p>
<h2 id='four'>Four</h2>
<p>If you didn't have adult onset diabetes, I wouldn't mind giving you a little sugar. Everybody dance NOW. And the soup of the day is bread. Great, now I'm gonna smell to high heaven like a tuna melt!</p>
<p>That's how Tony Wonder lost a nut. She calls it a Mayonegg. Go ahead, touch the Cornballer. There's a new daddy in town. A discipline daddy.</p>
`
}
];

View File

@@ -0,0 +1,9 @@
export function del(req, res) {
res.set({
'Content-Type': 'application/json'
});
res.end(JSON.stringify({
id: req.params.id
}));
}

View File

@@ -0,0 +1,15 @@
<button class='del' on:click='del()'>delete</button>
<script>
export default {
methods: {
del() {
fetch(`/api/delete/42`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
window.deleted = data;
});
}
}
};
</script>

View File

@@ -0,0 +1,9 @@
<p>URL is {{url}}</p>
<script>
export default {
preload({ url }) {
if (url) return { url };
}
};
</script>

View File

@@ -0,0 +1,11 @@
<h1>This page should never render</h1>
<script>
export default {
preload() {
return new Promise(fulfil => {
window.fulfil = fulfil;
});
}
};
</script>

View File

@@ -10,9 +10,9 @@
<link rel='icon' type='image/png' href='/favicon.png'>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js');
}
// if ('serviceWorker' in navigator) {
// navigator.serviceWorker.register('/service-worker.js');
// }
</script>
<!-- Sapper generates a <style> tag containing critical CSS
@@ -32,6 +32,6 @@
<!-- Sapper creates a <script> tag containing `templates/main.js`
and anything else it needs to hydrate the app and
initialise the router -->
<script src='%sapper.main%'></script>
%sapper.scripts%
</body>
</html>

View File

@@ -1,10 +1,6 @@
import { init } from '__app__';
import { init } from '../../../runtime.js';
// `routes` is an array of route objects injected by Sapper
init(document.querySelector('#sapper'), __routes__);
// if (__dev__) {
// // Enable hot-module reloading
// import('sapper/webpack/hmr');
// if (module.hot) module.hot.accept();
// }
window.READY = true;

View File

@@ -1,7 +1,5 @@
const config = require('../../webpack/config.js');
const webpack = require('webpack');
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
entry: config.client.entry(),
@@ -24,27 +22,18 @@ module.exports = {
}
}
},
config.dev && {
{
test: /\.css$/,
use: [
{ loader: "style-loader" },
{ loader: "css-loader" }
]
},
!config.dev && {
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [{ loader: 'css-loader', options: { sourceMap: config.dev } }]
})
}
].filter(Boolean)
},
plugins: [
config.dev && new webpack.HotModuleReplacementPlugin(),
!config.dev && new ExtractTextPlugin('main.css'),
!config.dev && new webpack.optimize.ModuleConcatenationPlugin(),
!config.dev && new UglifyJSPlugin()
!config.dev && new webpack.optimize.ModuleConcatenationPlugin()
].filter(Boolean),
devtool: config.dev ? 'inline-source-map' : false
};

View File

@@ -1,7 +1,4 @@
const config = require('../../webpack/config.js');
const webpack = require('webpack');
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
entry: config.server.entry(),

331
test/common/test.js Normal file
View File

@@ -0,0 +1,331 @@
const path = require('path');
const assert = require('assert');
const express = require('express');
const serve = require('serve-static');
const Nightmare = require('nightmare');
const getPort = require('get-port');
const fetch = require('node-fetch');
run('production');
run('development');
Nightmare.action('page', {
title(done) {
this.evaluate_now(() => document.querySelector('h1').textContent, done);
}
});
function run(env) {
describe(`env=${env}`, function () {
this.timeout(20000);
let PORT;
let server;
let nightmare;
let middleware;
let capture;
let base;
function get(url) {
return new Promise(fulfil => {
const req = {
url,
method: 'GET'
};
const result = {
headers: {},
body: ''
};
const res = {
set: (headers, value) => {
if (typeof headers === 'string') {
return res.set({ [headers]: value });
}
Object.assign(result.headers, headers);
},
status: code => {
result.status = code;
},
write: data => {
result.body += data;
},
end: data => {
result.body += data;
fulfil(result);
}
};
middleware(req, res, () => {
fulfil(result);
});
});
}
before(() => {
process.chdir(path.resolve(__dirname, '../app'));
process.env.NODE_ENV = env;
let exec_promise = Promise.resolve();
let sapper;
if (env === 'production') {
const cli = path.resolve(__dirname, '../../cli/index.js');
exec_promise = exec(`${cli} build`);
}
return exec_promise.then(() => {
const resolved = require.resolve('../..');
delete require.cache[resolved];
sapper = require(resolved);
return getPort();
}).then(port => {
PORT = port;
base = `http://localhost:${PORT}`;
global.fetch = (url, opts) => {
if (url[0] === '/') url = `${base}${url}`;
return fetch(url, opts);
};
let captured;
capture = fn => {
const result = captured = [];
return fn().then(() => {
captured = null;
return result;
});
};
const app = express();
app.use(serve('assets'));
app.use((req, res, next) => {
if (captured) captured.push(req);
next();
});
middleware = sapper();
app.use(middleware);
return new Promise((fulfil, reject) => {
server = app.listen(PORT, err => {
if (err) reject(err);
else fulfil();
});
});
});
});
after(() => {
server.close();
middleware.close();
// give a chance to clean up
return new Promise(fulfil => setTimeout(fulfil, 500));
});
describe('basic functionality', () => {
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();
});
it('serves /', () => {
return nightmare.goto(base).page.title().then(title => {
assert.equal(title, 'Great success!');
});
});
it('serves static route', () => {
return nightmare.goto(`${base}/about`).page.title().then(title => {
assert.equal(title, 'About this site');
});
});
it('serves dynamic route', () => {
return nightmare.goto(`${base}/blog/what-is-sapper`).page.title().then(title => {
assert.equal(title, 'What is Sapper?');
});
});
it('navigates to a new page without reloading', () => {
return nightmare.goto(base).wait(() => window.READY).wait(200)
.then(() => {
return capture(() => nightmare.click('a[href="/about"]'));
})
.then(requests => {
assert.deepEqual(requests.map(r => r.url), []);
return nightmare.path();
})
.then(path => {
assert.equal(path, '/about');
return nightmare.title();
})
.then(title => {
assert.equal(title, 'About');
});
});
it('navigates programmatically', () => {
return nightmare
.goto(`${base}/about`)
.wait(() => window.READY)
.click('.goto')
.wait(() => window.location.pathname === '/blog/what-is-sapper')
.wait(100)
.title()
.then(title => {
assert.equal(title, 'What is Sapper?');
});
});
it('prefetches programmatically', () => {
return nightmare
.goto(`${base}/about`)
.wait(() => window.READY)
.then(() => {
return capture(() => {
return nightmare
.click('.prefetch')
.wait(100);
});
})
.then(requests => {
assert.ok(!!requests.find(r => r.url === '/api/blog/why-the-name'));
});
});
it('scrolls to active deeplink', () => {
return nightmare
.goto(`${base}/blog/a-very-long-post#four`)
.wait(() => window.READY)
.wait(100)
.evaluate(() => window.scrollY)
.then(scrollY => {
assert.ok(scrollY > 0, scrollY);
});
});
it('reuses prefetch promise', () => {
return nightmare
.goto(`${base}/blog`)
.wait(() => window.READY)
.wait(200)
.then(() => {
return capture(() => {
return nightmare
.mouseover('[href="/blog/what-is-sapper"]')
.wait(200);
});
})
.then(mouseover_requests => {
assert.deepEqual(mouseover_requests.map(r => r.url), [
'/api/blog/what-is-sapper'
]);
return capture(() => {
return nightmare
.click('[href="/blog/what-is-sapper"]')
.wait(200);
});
})
.then(click_requests => {
assert.deepEqual(click_requests.map(r => r.url), []);
});
});
it('cancels navigation if subsequent navigation occurs during preload', () => {
return nightmare
.goto(base)
.wait(() => window.READY)
.click('a[href="/slow-preload"]')
.wait(100)
.click('a[href="/about"]')
.wait(100)
.then(() => nightmare.path())
.then(path => {
assert.equal(path, '/about');
return nightmare.title();
})
.then(title => {
assert.equal(title, 'About');
return nightmare.evaluate(() => window.fulfil({})).wait(100);
})
.then(() => nightmare.path())
.then(path => {
assert.equal(path, '/about');
return nightmare.title();
})
.then(title => {
assert.equal(title, 'About');
});
});
it('passes entire request object to preload', () => {
return nightmare
.goto(`${base}/show-url`)
.wait(() => window.READY)
.evaluate(() => document.querySelector('p').innerHTML)
.end().then(html => {
assert.equal(html, `URL is /show-url`);
});
});
});
describe('headers', () => {
it('sets Content-Type and Link...preload headers', () => {
return get('/').then(({ headers }) => {
assert.equal(
headers['Content-Type'],
'text/html'
);
assert.ok(
/<\/client\/main.\w+\.js>;rel="preload";as="script", <\/client\/_.\d+.\w+.js>;rel="preload";as="script"/.test(headers['Link']),
headers['Link']
);
});
});
});
});
}
function exec(cmd) {
return new Promise((fulfil, reject) => {
require('child_process').exec(cmd, (err, stdout, stderr) => {
if (err) {
process.stdout.write(stdout);
process.stderr.write(stderr);
return reject(err);
}
fulfil();
});
});
}

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -1,19 +0,0 @@
describe('dev mode', () => {
beforeEach(() => {
cy.visit('/')
});
it('has the correct <h1>', () => {
cy.contains('h1', 'Great success!')
});
it('navigates to /about', () => {
cy.get('nav a').contains('about').click();
cy.url().should('include', '/about');
});
it('navigates to /blog', () => {
cy.get('nav a').contains('blog').click();
cy.url().should('include', '/blog');
});
});

View File

@@ -1,19 +0,0 @@
describe('prod mode', () => {
beforeEach(() => {
cy.visit('/')
});
it('has the correct <h1>', () => {
cy.contains('h1', 'Great success!')
});
it('navigates to /about', () => {
cy.get('nav a').contains('about').click();
cy.url().should('include', '/about');
});
it('navigates to /blog', () => {
cy.get('nav a').contains('blog').click();
cy.url().should('include', '/blog');
});
});

View File

@@ -1,17 +0,0 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

View File

@@ -1,25 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

View File

@@ -1,20 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@@ -1,75 +0,0 @@
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const child_process = require('child_process');
// ensure sapper doesn't exist in app/node_modules
rimraf.sync(
path.join(__dirname, 'app/node_modules/sapper')
);
rimraf.sync(
path.join(__dirname, 'app/node_modules/.bin/sapper')
);
// create symlinks
fs.symlinkSync(
path.join(__dirname, '..'),
path.join(__dirname, 'app/node_modules/sapper')
);
fs.symlinkSync(
path.join(__dirname, '../cli/index.js'),
path.join(__dirname, 'app/node_modules/.bin/sapper')
);
const app_dir = path.join(__dirname, 'app');
function start_server() {
const server = child_process.spawn(process.execPath, ['server.js'], {
cwd: app_dir,
env: {
NODE_ENV: 'development'
},
stdio: 'pipe'
});
server.stdout.on('data', (data) => {
process.stdout.write(data);
});
server.stderr.on('data', (data) => {
process.stderr.write(data);
});
}
function launch() {
if (process.argv[2] === '--dev') {
start_server();
} else {
child_process.exec(`npm run build`, {
cwd: app_dir
}, (err, stdout, stderr) => {
if (err) throw err;
process.stdout.write(stdout);
process.stderr.write(stderr);
start_server();
});
}
}
// this is a terrible hack
if (process.env.APPVEYOR) {
child_process.exec(`npm install`, {
cwd: app_dir
}, (err, stdout, stderr) => {
if (err) throw err;
process.stdout.write(stdout);
process.stderr.write(stderr);
launch();
});
} else {
launch();
}

View File

@@ -5,13 +5,15 @@ const create_routes = require('../../lib/utils/create_routes.js');
describe('create_routes', () => {
it('sorts routes correctly', () => {
const routes = create_routes(['index.html', 'about.html', '[wildcard].html', 'post/[id].html']);
const routes = create_routes(['index.html', 'about.html', '[wildcard].html', 'post/foo.html', 'post/[id].html', 'post/bar.html']);
assert.deepEqual(
routes.map(r => r.file),
[
'about.html',
'index.html',
'about.html',
'post/foo.html',
'post/bar.html',
'post/[id].html',
'[wildcard].html'
]
@@ -43,9 +45,70 @@ describe('create_routes', () => {
assert.deepEqual(
routes.map(r => r.file),
[
'e/f/g/h.html',
'index.html'
'index.html',
'e/f/g/h.html'
]
);
});
it('matches /foo/:bar before /:baz/qux', () => {
const a = create_routes(['foo/[bar].html', '[baz]/qux.html']);
const b = create_routes(['[baz]/qux.html', 'foo/[bar].html']);
assert.deepEqual(
a.map(r => r.file),
['foo/[bar].html', '[baz]/qux.html']
);
assert.deepEqual(
b.map(r => r.file),
['foo/[bar].html', '[baz]/qux.html']
);
});
it('fails if routes are indistinguishable', () => {
assert.throws(() => {
create_routes(['[foo].html', '[bar]/index.html']);
}, /The \[foo\].html and \[bar\]\/index.html routes clash/);
});
it('matches nested routes', () => {
const route = create_routes(['settings/[submenu].html'])[0];
assert.deepEqual(route.exec('/settings/foo'), {
submenu: 'foo'
});
assert.deepEqual(route.exec('/settings'), {
submenu: null
});
});
it('prefers index routes to nested routes', () => {
const routes = create_routes(['settings/[submenu].html', 'settings.html']);
assert.deepEqual(
routes.map(r => r.file),
['settings.html', 'settings/[submenu].html']
);
});
it('matches deeply nested routes', () => {
const route = create_routes(['settings/[a]/[b]/index.html'])[0];
assert.deepEqual(route.exec('/settings/foo/bar'), {
a: 'foo',
b: 'bar'
});
assert.deepEqual(route.exec('/settings/foo'), {
a: 'foo',
b: null
});
assert.deepEqual(route.exec('/settings'), {
a: null,
b: null
});
});
});

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"diagnostics": true,
"noImplicitThis": true,
"noImplicitAny": true,
"noEmitOnError": true,
"allowJs": true,
"lib": ["es5", "es6", "dom"],
"importHelpers": true
},
"target": "ES5",
"include": [
"src"
],
"exclude": [
"node_modules"
]
}

View File

@@ -1,6 +1,4 @@
const path = require('path');
const route_manager = require('../lib/route_manager.js');
const { src, dest, dev, server_routes } = require('../lib/config.js');
const { dest, dev, entry } = require('../lib/config.js');
module.exports = {
dev,
@@ -8,7 +6,12 @@ module.exports = {
client: {
entry: () => {
return {
main: `${dest}/main.js`
main: [
entry.client,
// workaround for https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/456
'style-loader/lib/addStyles',
'css-loader/lib/css-base'
]
};
},
@@ -25,8 +28,8 @@ module.exports = {
server: {
entry: () => {
return {
server_routes
}
main: entry.server
};
},
output: () => {
@@ -38,4 +41,4 @@ module.exports = {
};
}
}
};
};

2011
yarn.lock

File diff suppressed because it is too large Load Diff