mirror of
https://github.com/kevin-DL/sapper.git
synced 2026-01-13 11:35:28 +00:00
Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bebb56bd6 | ||
|
|
f475634d8d | ||
|
|
58c1eb9fa8 | ||
|
|
631afbbfe4 | ||
|
|
1cc9acb4f1 | ||
|
|
19005110f1 | ||
|
|
21ee8ad39d | ||
|
|
906b0c7ad5 | ||
|
|
896fd410d1 | ||
|
|
c0cc877456 | ||
|
|
3ed9ce27a1 | ||
|
|
edba45b809 | ||
|
|
43c1890235 | ||
|
|
605929053c | ||
|
|
2752c73ebb | ||
|
|
2547db39ac | ||
|
|
1285739cc5 | ||
|
|
14d64e854a | ||
|
|
c419c73550 | ||
|
|
835b94175d | ||
|
|
25bdcf9957 | ||
|
|
792ccf5c6a | ||
|
|
4ca8195037 | ||
|
|
cb12231053 | ||
|
|
d55401d45b | ||
|
|
99d4eafb0b | ||
|
|
bff6f550be | ||
|
|
f8ea9ebda1 | ||
|
|
181d7b4a61 | ||
|
|
beb415c65d | ||
|
|
5bbd7ead17 | ||
|
|
e11405d555 | ||
|
|
9fe0ca2c22 | ||
|
|
f2eb95d546 | ||
|
|
ab1ca60363 | ||
|
|
d95f52f8e9 | ||
|
|
b02183af53 | ||
|
|
f9828f9fd2 | ||
|
|
9a760c570f | ||
|
|
0f390920a8 | ||
|
|
9adb6ca7e6 | ||
|
|
24980651c0 | ||
|
|
7c6436a99c | ||
|
|
f6b26f1b07 | ||
|
|
55b60369f9 | ||
|
|
2be9dd1883 | ||
|
|
b29700f725 | ||
|
|
7188ce0d0d | ||
|
|
4f8ce19fe1 | ||
|
|
a85f2921e8 | ||
|
|
7a2ed16884 | ||
|
|
08e575fee0 | ||
|
|
7dbcab74d3 | ||
|
|
9b1b545194 | ||
|
|
7b01242f3e | ||
|
|
15b1fbf8a6 | ||
|
|
8f1d2e0a04 | ||
|
|
dfb8692d78 | ||
|
|
09d3c4d85e | ||
|
|
1e623dde29 | ||
|
|
5104abf329 | ||
|
|
6554fc8616 | ||
|
|
cd01b7e6db | ||
|
|
bfa3da6d3d | ||
|
|
6ee092f8d4 | ||
|
|
ac70004f77 | ||
|
|
3449f1eb37 | ||
|
|
16cb1fccc6 | ||
|
|
b20c1c029f | ||
|
|
7abfb1aab1 | ||
|
|
205c2defe4 | ||
|
|
09a6eec83e | ||
|
|
2cabf61ea7 | ||
|
|
71cfdd2907 | ||
|
|
297f4276de | ||
|
|
422e31e183 | ||
|
|
b53ee061c0 | ||
|
|
8bad37205d | ||
|
|
fd0dd4fe58 | ||
|
|
4940644ae3 | ||
|
|
fb8d952eeb | ||
|
|
fc631c4866 | ||
|
|
03ce2ea998 | ||
|
|
dd8deb2d8a | ||
|
|
7d721abb2a | ||
|
|
39b1fa89ce | ||
|
|
7a3506420f | ||
|
|
72ae4a1c64 | ||
|
|
a09c33d6a5 | ||
|
|
4590aa313c | ||
|
|
d11bd954e0 | ||
|
|
c15959710b | ||
|
|
bb8ff74f68 | ||
|
|
2cbbe91490 | ||
|
|
faeddd8add | ||
|
|
d77722c042 | ||
|
|
61daba7a64 | ||
|
|
54ff8cc2e6 | ||
|
|
e6fcafe09b | ||
|
|
a305d3cea1 | ||
|
|
75e70207b8 | ||
|
|
8a8526d9ed | ||
|
|
9a76229bb6 | ||
|
|
f4e46e6e6c | ||
|
|
90cd347112 | ||
|
|
5adfdd6fe0 | ||
|
|
a6dc61a182 | ||
|
|
96666d05ec | ||
|
|
6390ba692b | ||
|
|
0e131cc81e | ||
|
|
bd3d5713cb | ||
|
|
9ec23c47ad | ||
|
|
b7bb69925e | ||
|
|
25124f6ee7 | ||
|
|
73d491cd19 | ||
|
|
e25fceb4b8 | ||
|
|
3807147c57 | ||
|
|
a523ba58ff | ||
|
|
fe03fd3a52 | ||
|
|
89c430a0cb | ||
|
|
8ef312849c | ||
|
|
4200446684 | ||
|
|
681ed005b8 | ||
|
|
d457af8d51 | ||
|
|
0c158b9e1f | ||
|
|
50011e2077 | ||
|
|
f27b7973e3 | ||
|
|
2af2ab3cb9 | ||
|
|
6a4dc1901c | ||
|
|
fbbc0e9e19 | ||
|
|
1213c3da46 | ||
|
|
4cc2104088 | ||
|
|
d6dda371ca | ||
|
|
304c06085e | ||
|
|
33b6450e34 | ||
|
|
8faa98af6a | ||
|
|
14df138528 | ||
|
|
44285cdb2f | ||
|
|
bd656cfd5b | ||
|
|
c4b4bd587d | ||
|
|
2abfdb03d5 | ||
|
|
a80ac3a8b8 | ||
|
|
887cb09386 | ||
|
|
cfeeafded4 | ||
|
|
2cae674033 | ||
|
|
7c0f32662d | ||
|
|
b4fb1c3268 | ||
|
|
ecd0f673a9 | ||
|
|
40d16852f7 | ||
|
|
133be03791 | ||
|
|
727a76ebb5 | ||
|
|
e3c047831a | ||
|
|
81b5e0d764 | ||
|
|
98e904dcfc | ||
|
|
ca51372150 | ||
|
|
7cef1f1120 | ||
|
|
1b73baabce | ||
|
|
5aa01b922b | ||
|
|
f0bc68be88 | ||
|
|
be7c53becc | ||
|
|
9ea4137b87 | ||
|
|
7588911108 | ||
|
|
fc8280adea | ||
|
|
d08f9eb5a4 | ||
|
|
2b3472b1b1 | ||
|
|
30ddb3dd7e | ||
|
|
0c891ba79e | ||
|
|
ee94f355d5 | ||
|
|
bea9b7965a | ||
|
|
1312aede1f | ||
|
|
50e307e0c0 | ||
|
|
e87ac1f367 | ||
|
|
5da9d0926a | ||
|
|
9538499d51 | ||
|
|
ff1e632057 | ||
|
|
aeeb231477 | ||
|
|
d1940db8c0 | ||
|
|
98f9a64b64 | ||
|
|
b9bef802d3 | ||
|
|
a7024b3806 | ||
|
|
423e02aeae | ||
|
|
12b73ecebf | ||
|
|
e1bc38b5a7 | ||
|
|
b66f624f01 | ||
|
|
502dd547d1 | ||
|
|
4c343490d2 | ||
|
|
b3027c5816 | ||
|
|
c29e8022cc | ||
|
|
e4cd4c9cb0 | ||
|
|
feddad42b2 | ||
|
|
3c4ebcda30 |
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"rules": {
|
||||
"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": 8,
|
||||
"sourceType": "module"
|
||||
}
|
||||
}
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,4 +1,16 @@
|
||||
.DS_Store
|
||||
yarn.lock
|
||||
node_modules
|
||||
cypress/screenshots
|
||||
test/app/.sapper
|
||||
test/app/.sapper
|
||||
test/app/app/manifest
|
||||
runtime.js
|
||||
runtime.js.map
|
||||
cli.js
|
||||
cli.js.map
|
||||
middleware.js
|
||||
middleware.js.map
|
||||
core.js
|
||||
core.js.map
|
||||
webpack/config.js
|
||||
webpack/config.js.map
|
||||
@@ -3,6 +3,7 @@ sudo: false
|
||||
language: node_js
|
||||
|
||||
node_js:
|
||||
- "6"
|
||||
- "stable"
|
||||
|
||||
env:
|
||||
|
||||
91
CHANGELOG.md
91
CHANGELOG.md
@@ -1,5 +1,96 @@
|
||||
# sapper changelog
|
||||
|
||||
## 0.7.5
|
||||
|
||||
* Allow dynamic parameters inside route parts ([#139](https://github.com/sveltejs/sapper/issues/139))
|
||||
|
||||
## 0.7.4
|
||||
|
||||
* Force `NODE_ENV='production'` when running `build` or `export` ([#141](https://github.com/sveltejs/sapper/issues/141))
|
||||
* Use source-map-support ([#134](https://github.com/sveltejs/sapper/pull/134))
|
||||
|
||||
## 0.7.3
|
||||
|
||||
* Handle webpack assets that are arrays instead of strings ([#131](https://github.com/sveltejs/sapper/pull/131))
|
||||
* Wait for new server to start before broadcasting HMR update ([#129](https://github.com/sveltejs/sapper/pull/129))
|
||||
|
||||
## 0.7.2
|
||||
|
||||
* Add `hmr-client.js` to package
|
||||
* Wait until first successful client build before creating service-worker.js
|
||||
|
||||
## 0.7.1
|
||||
|
||||
* Add missing `tslib` dependency
|
||||
|
||||
## 0.7.0
|
||||
|
||||
* Restructure app layout (see [migration guide](https://sapper.svelte.technology/guide#0-6-to-0-7)) ([#126](https://github.com/sveltejs/sapper/pull/126))
|
||||
* Support `this.redirect(status, location)` and `this.error(status, error)` in `preload` functions ([#127](https://github.com/sveltejs/sapper/pull/127))
|
||||
* Add `sapper dev` command
|
||||
* Add `sapper --help` command
|
||||
|
||||
## 0.6.4
|
||||
|
||||
* Prevent phantom HMR requests in production mode ([#114](https://github.com/sveltejs/sapper/pull/114))
|
||||
|
||||
## 0.6.3
|
||||
|
||||
* Ignore non-HTML responses when crawling during `export`
|
||||
* Build in prod mode for `export`
|
||||
|
||||
## 0.6.2
|
||||
|
||||
* Handle unspecified type in `sapper export`
|
||||
|
||||
## 0.6.1
|
||||
|
||||
* Fix `pkg.files` and `pkg.bin`
|
||||
|
||||
## 0.6.0
|
||||
|
||||
* Hydrate on first load, and only on first load ([#93](https://github.com/sveltejs/sapper/pull/93))
|
||||
* Identify clashes between page and server routes ([#96](https://github.com/sveltejs/sapper/pull/96))
|
||||
* Remove Express-specific utilities, for compatbility with Polka et al ([#94](https://github.com/sveltejs/sapper/issues/94))
|
||||
* Return a promise from `init` when first page has rendered ([#99](https://github.com/sveltejs/sapper/issues/99))
|
||||
* Handle invalid hash links ([#104](https://github.com/sveltejs/sapper/pull/104))
|
||||
* Avoid `URLSearchParams` ([#107](https://github.com/sveltejs/sapper/pull/107))
|
||||
* Don't automatically set `Content-Type` for server routes ([#111](https://github.com/sveltejs/sapper/pull/111))
|
||||
* Handle empty query string routes, e.g. `/?` ([#105](https://github.com/sveltejs/sapper/pull/105))
|
||||
|
||||
## 0.5.1
|
||||
|
||||
* Only write service-worker.js to filesystem in dev mode ([#90](https://github.com/sveltejs/sapper/issues/90))
|
||||
|
||||
## 0.5.0
|
||||
|
||||
* Experimental support for `sapper export` ([#9](https://github.com/sveltejs/sapper/issues/9))
|
||||
* Lazily load chokidar, for faster startup ([#64](https://github.com/sveltejs/sapper/pull/64))
|
||||
|
||||
## 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))
|
||||
|
||||
9
LICENSE
Normal file
9
LICENSE
Normal 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
151
README.md
@@ -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)
|
||||
@@ -15,3 +15,7 @@ environment:
|
||||
install:
|
||||
- ps: Install-Product node $env:nodejs_version
|
||||
- npm install
|
||||
|
||||
test_script:
|
||||
- node --version && npm --version
|
||||
- npm test
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const cmd = process.argv[2];
|
||||
|
||||
if (cmd === 'build') {
|
||||
process.env.NODE_ENV = 'production';
|
||||
require('../lib/build.js')();
|
||||
}
|
||||
28
hmr-client.js
Normal file
28
hmr-client.js
Normal file
@@ -0,0 +1,28 @@
|
||||
let source;
|
||||
|
||||
function check() {
|
||||
if (module.hot.status() === 'idle') {
|
||||
module.hot.check(true).then(modules => {
|
||||
console.log(`HMR updated`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function connect(port) {
|
||||
if (source || !window.EventSource) return;
|
||||
|
||||
source = new EventSource(`http://localhost:${port}/hmr`);
|
||||
|
||||
source.onopen = function(event) {
|
||||
console.log(`HMR connected`);
|
||||
};
|
||||
|
||||
source.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (!data) return; // just a heartbeat
|
||||
|
||||
if (data.status === 'completed') {
|
||||
check();
|
||||
}
|
||||
};
|
||||
}
|
||||
42
lib/build.js
42
lib/build.js
@@ -1,42 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
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.sync(dest);
|
||||
rimraf.sync(path.join(dest, '**/*'));
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
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.SAPPER_DEST || '.sapper');
|
||||
|
||||
if (exports.dev) {
|
||||
mkdirp.sync(exports.dest);
|
||||
rimraf.sync(path.join(exports.dest, '**/*'));
|
||||
}
|
||||
|
||||
exports.entry = {
|
||||
client: path.resolve(exports.templates, '.main.rendered.js'),
|
||||
server: path.resolve(exports.dest, 'server-entry.js')
|
||||
};
|
||||
238
lib/index.js
238
lib/index.js
@@ -1,238 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
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 { dest, dev } = require('./config.js');
|
||||
|
||||
function connect_dev() {
|
||||
create_app();
|
||||
|
||||
const watcher = create_watcher();
|
||||
|
||||
let asset_cache;
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
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),
|
||||
|
||||
get_not_found_handler(() => asset_cache)
|
||||
]);
|
||||
|
||||
middleware.close = () => {
|
||||
watcher.close();
|
||||
// TODO shut down chokidar
|
||||
};
|
||||
|
||||
return middleware;
|
||||
}
|
||||
|
||||
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'))
|
||||
);
|
||||
|
||||
const middleware = 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),
|
||||
|
||||
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;
|
||||
|
||||
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') {
|
||||
// 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(data)).then(preloaded => {
|
||||
Object.assign(data, preloaded);
|
||||
return mod.render(data);
|
||||
});
|
||||
|
||||
await templates.stream(res, 200, {
|
||||
main: client.main_file,
|
||||
html: promise.then(rendered => rendered.html),
|
||||
head: promise.then(({ head }) => `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`),
|
||||
styles: promise.then(({ css }) => (css && css.code ? `<style>${css.code}</style>` : ''))
|
||||
});
|
||||
} else {
|
||||
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.end(page);
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
const handler = mod[req.method.toLowerCase()];
|
||||
if (handler) handler(req, res, next);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 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,
|
||||
main: asset_cache.client.main_file,
|
||||
url: req.url
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
function compose_handlers(handlers) {
|
||||
return (req, res, next) => {
|
||||
let i = 0;
|
||||
function go() {
|
||||
const handler = handlers[i];
|
||||
|
||||
if (handler) {
|
||||
handler(req, res, () => {
|
||||
i += 1;
|
||||
go();
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
go();
|
||||
};
|
||||
}
|
||||
|
||||
function read_json(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
const glob = require('glob');
|
||||
const chokidar = require('chokidar');
|
||||
const create_routes = require('./utils/create_routes.js');
|
||||
const { src, dev } = require('./config.js');
|
||||
|
||||
const callbacks = [];
|
||||
|
||||
exports.onchange = fn => {
|
||||
callbacks.push(fn);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
const chokidar = require('chokidar');
|
||||
const { dev } = require('./config.js');
|
||||
|
||||
let templates;
|
||||
|
||||
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();
|
||||
|
||||
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] : '';
|
||||
});
|
||||
},
|
||||
stream: async (res, data) => {
|
||||
let i = 0;
|
||||
|
||||
do {
|
||||
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
|
||||
|
||||
res.write(await data[match[1]]);
|
||||
i = end + 1;
|
||||
} while (i < template.length);
|
||||
}
|
||||
};
|
||||
})
|
||||
.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));
|
||||
if (template) return template.render(data);
|
||||
|
||||
return `Missing template for status code ${status}`;
|
||||
};
|
||||
|
||||
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}`;
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
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'))
|
||||
);
|
||||
@@ -1,83 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const chokidar = require('chokidar');
|
||||
const route_manager = require('../route_manager.js');
|
||||
const { src, entry, dev } = require('../config.js');
|
||||
|
||||
function posixify(file) {
|
||||
return file.replace(/[/\\]/g, '/');
|
||||
}
|
||||
|
||||
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(', ') } }`;
|
||||
|
||||
const file = posixify(`${src}/${route.file}`);
|
||||
return `{ pattern: ${route.pattern}, params: match => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`
|
||||
})
|
||||
.join(', ')
|
||||
}]`;
|
||||
|
||||
let main = template
|
||||
.replace(/__app__/g, posixify(path.resolve(__dirname, '../../runtime/app.js')))
|
||||
.replace(/__routes__/g, code)
|
||||
.replace(/__dev__/g, String(dev));
|
||||
|
||||
if (dev) {
|
||||
const hmr_client = posixify(require.resolve(`webpack-hot-middleware/client`));
|
||||
main += `\n\nimport('${hmr_client}?path=/__webpack_hmr&timeout=20000'); if (module.hot) module.hot.accept();`
|
||||
}
|
||||
|
||||
fs.writeFileSync(entry.client, main);
|
||||
|
||||
// need to fudge the mtime, because webpack is soft in the head
|
||||
const { atime, mtime } = fs.statSync(entry.client);
|
||||
fs.utimesSync(entry.client, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999));
|
||||
}
|
||||
|
||||
function create_server_routes() {
|
||||
const imports = routes
|
||||
.map(route => {
|
||||
const file = posixify(`${src}/${route.file}`);
|
||||
return route.type === 'page' ?
|
||||
`import ${route.id} from '${file}';` :
|
||||
`import * as ${route.id} from '${file}';`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const exports = `export { ${routes.map(route => route.id)} };`;
|
||||
|
||||
fs.writeFileSync(entry.server, `${imports}\n\n${exports}`);
|
||||
|
||||
const { atime, mtime } = fs.statSync(entry.server);
|
||||
fs.utimesSync(entry.server, new Date(atime.getTime() - 999999), new Date(mtime.getTime() - 999999));
|
||||
}
|
||||
|
||||
create_client_main();
|
||||
create_server_routes();
|
||||
}
|
||||
|
||||
if (dev) {
|
||||
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;
|
||||
@@ -1,77 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = function create_matchers(files) {
|
||||
const routes = files
|
||||
.map(file => {
|
||||
if (/(^|\/|\\)_/.test(file)) return;
|
||||
|
||||
const parts = file.replace(/\.(html|js|mjs)$/, '').split('/'); // glob output is always posix-style
|
||||
if (parts[parts.length - 1] === 'index') parts.pop();
|
||||
|
||||
const id = (
|
||||
parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_')
|
||||
) || '_';
|
||||
|
||||
const dynamic = parts
|
||||
.filter(part => part[0] === '[')
|
||||
.map(part => part.slice(1, -1));
|
||||
|
||||
const pattern = new RegExp(
|
||||
`^\\/${parts.map(p => p[0] === '[' ? '([^/]+)' : p).join('\\/')}$`
|
||||
);
|
||||
|
||||
const test = url => pattern.test(url);
|
||||
|
||||
const exec = url => {
|
||||
const match = pattern.exec(url);
|
||||
if (!match) return;
|
||||
|
||||
const params = {};
|
||||
dynamic.forEach((param, i) => {
|
||||
params[param] = match[i + 1];
|
||||
});
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
type: path.extname(file) === '.html' ? 'page' : 'route',
|
||||
file,
|
||||
pattern,
|
||||
test,
|
||||
exec,
|
||||
parts,
|
||||
dynamic
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
let same = true;
|
||||
|
||||
for (let i = 0; true; i += 1) {
|
||||
const a_part = a.parts[i];
|
||||
const b_part = b.parts[i];
|
||||
|
||||
if (!a_part && !b_part) {
|
||||
if (same) throw new Error(`The ${a.file} and ${b.file} routes clash`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!a_part) return 1;
|
||||
if (!b_part) return -1;
|
||||
|
||||
const a_is_dynamic = a_part[0] === '[';
|
||||
const b_is_dynamic = b_part[0] === '[';
|
||||
|
||||
if (a_is_dynamic === b_is_dynamic) {
|
||||
if (!a_is_dynamic) same = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
return a_is_dynamic ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
return routes;
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
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 = {};
|
||||
|
||||
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]) => {
|
||||
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()
|
||||
);
|
||||
});
|
||||
|
||||
function watch_compiler(type) {
|
||||
const compiler = compilers[type];
|
||||
|
||||
compiler.plugin('invalid', filename => {
|
||||
console.log(chalk.cyan(`${type} bundle invalidated, file changed: ${chalk.bold(filename)}`));
|
||||
deferreds[type] = deferred();
|
||||
watcher.ready = invalidate();
|
||||
});
|
||||
|
||||
compiler.plugin('failed', err => {
|
||||
deferreds[type].reject(err);
|
||||
});
|
||||
|
||||
return compiler.watch({}, (err, stats) => {
|
||||
if (stats.hasErrors()) {
|
||||
deferreds[type].reject(stats.toJson().errors[0]);
|
||||
} else {
|
||||
deferreds[type].fulfil(stats);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const watcher = {
|
||||
ready: invalidate(),
|
||||
client: watch_compiler('client'),
|
||||
server: watch_compiler('server'),
|
||||
|
||||
close: () => {
|
||||
watcher.client.close();
|
||||
watcher.server.close();
|
||||
}
|
||||
};
|
||||
|
||||
return watcher;
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
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, 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/${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);
|
||||
|
||||
if (dev) {
|
||||
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;
|
||||
}, {}),
|
||||
|
||||
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.main)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
--require source-map-support/register
|
||||
--recursive
|
||||
test/unit/**/*.js
|
||||
test/common/test.js
|
||||
6672
package-lock.json
generated
6672
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
56
package.json
56
package.json
@@ -1,43 +1,79 @@
|
||||
{
|
||||
"name": "sapper",
|
||||
"version": "0.2.10",
|
||||
"version": "0.7.5",
|
||||
"description": "Military-grade apps, engineered by Svelte",
|
||||
"main": "lib/index.js",
|
||||
"main": "middleware.js",
|
||||
"bin": {
|
||||
"sapper": "cli/index.js"
|
||||
"sapper": "cli.js"
|
||||
},
|
||||
"files": [
|
||||
"cli.js",
|
||||
"core.js",
|
||||
"middleware.js",
|
||||
"runtime",
|
||||
"runtime.js",
|
||||
"hmr-client.js",
|
||||
"webpack"
|
||||
],
|
||||
"directories": {
|
||||
"test": "test"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^2.3.0",
|
||||
"cheerio": "^1.0.0-rc.2",
|
||||
"chokidar": "^1.7.0",
|
||||
"code-frame": "^5.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"express": "^4.16.2",
|
||||
"get-port": "^3.2.0",
|
||||
"glob": "^7.1.2",
|
||||
"locate-character": "^2.0.5",
|
||||
"mime": "^2.2.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"mri": "^1.1.0",
|
||||
"node-fetch": "^1.7.3",
|
||||
"relative": "^3.0.2",
|
||||
"require-relative": "^0.8.7",
|
||||
"rimraf": "^2.6.2",
|
||||
"webpack": "^3.10.0",
|
||||
"webpack-hot-middleware": "^2.21.0"
|
||||
"sander": "^0.6.0",
|
||||
"serialize-javascript": "^1.4.0",
|
||||
"source-map-support": "^0.5.3",
|
||||
"tslib": "^1.8.1",
|
||||
"url-parse": "^1.2.0",
|
||||
"wait-port": "^0.2.2",
|
||||
"walk-sync": "^0.3.2",
|
||||
"webpack": "^3.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@std/esm": "^0.19.7",
|
||||
"@types/glob": "^5.0.34",
|
||||
"@types/mkdirp": "^0.5.2",
|
||||
"@types/rimraf": "^2.0.2",
|
||||
"compression": "^1.7.1",
|
||||
"css-loader": "^0.28.7",
|
||||
"electron": "^1.8.2",
|
||||
"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-json": "^2.3.0",
|
||||
"rollup-plugin-string": "^2.0.2",
|
||||
"rollup-plugin-typescript": "^0.8.1",
|
||||
"style-loader": "^0.19.1",
|
||||
"svelte": "^1.49.1",
|
||||
"svelte-loader": "^2.3.2",
|
||||
"wait-on": "^2.0.2"
|
||||
"ts-node": "^4.1.0",
|
||||
"typescript": "^2.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
"cy:open": "cypress open",
|
||||
"test": "mocha --opts mocha.opts"
|
||||
"test": "mocha --opts mocha.opts",
|
||||
"pretest": "npm run build",
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -cw",
|
||||
"prepublish": "npm test"
|
||||
},
|
||||
"repository": "https://github.com/sveltejs/sapper",
|
||||
"keywords": [
|
||||
|
||||
43
rollup.config.js
Normal file
43
rollup.config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import typescript from 'rollup-plugin-typescript';
|
||||
import string from 'rollup-plugin-string';
|
||||
import json from 'rollup-plugin-json';
|
||||
import pkg from './package.json';
|
||||
|
||||
const external = [].concat(
|
||||
Object.keys(pkg.dependencies),
|
||||
Object.keys(process.binding('natives')),
|
||||
'sapper/core.js'
|
||||
);
|
||||
|
||||
const paths = {
|
||||
'sapper/core.js': './core.js'
|
||||
};
|
||||
|
||||
const plugins = [
|
||||
string({
|
||||
include: '**/*.md'
|
||||
}),
|
||||
json(),
|
||||
typescript({
|
||||
typescript: require('typescript')
|
||||
})
|
||||
];
|
||||
|
||||
export default [
|
||||
{ name: 'cli', banner: true },
|
||||
{ name: 'core' },
|
||||
{ name: 'middleware' },
|
||||
{ name: 'runtime', format: 'es' },
|
||||
{ name: 'webpack', file: 'webpack/config' }
|
||||
].map(obj => ({
|
||||
input: `src/${obj.name}/index.ts`,
|
||||
output: {
|
||||
file: `${obj.file || obj.name}.js`,
|
||||
format: obj.format || 'cjs',
|
||||
banner: obj.banner && '#!/usr/bin/env node',
|
||||
paths,
|
||||
sourcemap: true
|
||||
},
|
||||
external,
|
||||
plugins
|
||||
}));
|
||||
1
runtime/README.md
Normal file
1
runtime/README.md
Normal file
@@ -0,0 +1 @@
|
||||
This directory exists for legacy reasons and should be deleted before releasing version 1.
|
||||
213
runtime/app.js
213
runtime/app.js
@@ -1,211 +1,2 @@
|
||||
const detach = node => {
|
||||
node.parentNode.removeChild(node);
|
||||
};
|
||||
|
||||
export let component;
|
||||
let target;
|
||||
let routes;
|
||||
|
||||
const history = typeof window !== 'undefined' ? window.history : {
|
||||
pushState: () => {},
|
||||
replaceState: () => {},
|
||||
};
|
||||
|
||||
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');
|
||||
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, 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 };
|
||||
}
|
||||
|
||||
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();
|
||||
history.pushState({ id: cid }, '', url.href);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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), 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
|
||||
};
|
||||
}
|
||||
|
||||
export function goto(href, opts = {}) {
|
||||
if (navigate(new URL(href, window.location.href))) {
|
||||
if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
console.error('sapper/runtime/app.js has been deprecated in favour of sapper/runtime.js');
|
||||
export * from '../runtime.js';
|
||||
57
src/cli/build.ts
Normal file
57
src/cli/build.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import mkdirp from 'mkdirp';
|
||||
import rimraf from 'rimraf';
|
||||
import { create_compilers, create_app, create_routes, create_serviceworker } from 'sapper/core.js';
|
||||
|
||||
export default async function build({ src, dest, dev, entry }: {
|
||||
src: string;
|
||||
dest: string;
|
||||
dev: boolean;
|
||||
entry: { client: string, server: string }
|
||||
}) {
|
||||
mkdirp.sync(dest);
|
||||
rimraf.sync(path.join(dest, '**/*'));
|
||||
|
||||
const routes = create_routes({ src });
|
||||
|
||||
// create app/manifest/client.js and app/manifest/server.js
|
||||
create_app({ routes, src, dev });
|
||||
|
||||
const { client, server, serviceworker } = create_compilers();
|
||||
|
||||
const client_stats = await compile(client);
|
||||
fs.writeFileSync(path.join(dest, 'client_info.json'), JSON.stringify(client_stats.toJson()));
|
||||
|
||||
await compile(server);
|
||||
|
||||
if (serviceworker) {
|
||||
create_serviceworker({
|
||||
routes,
|
||||
client_files: client_stats.toJson().assets.map((chunk: { name: string }) => `/client/${chunk.name}`),
|
||||
src
|
||||
});
|
||||
|
||||
await compile(serviceworker);
|
||||
}
|
||||
}
|
||||
|
||||
function compile(compiler: any) {
|
||||
return new Promise((fulfil, reject) => {
|
||||
compiler.run((err: Error, stats: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (stats.hasErrors()) {
|
||||
console.error(stats.toString({ colors: true }));
|
||||
reject(new Error(`Encountered errors while building app`));
|
||||
}
|
||||
|
||||
else {
|
||||
fulfil(stats);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
229
src/cli/dev.ts
Normal file
229
src/cli/dev.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as net from 'net';
|
||||
import * as chalk from 'chalk';
|
||||
import * as child_process from 'child_process';
|
||||
import * as http from 'http';
|
||||
import mkdirp from 'mkdirp';
|
||||
import rimraf from 'rimraf';
|
||||
import { wait_for_port } from './utils';
|
||||
import { create_compilers, create_app, create_routes, create_serviceworker, create_template } from 'sapper/core.js';
|
||||
|
||||
type Deferred = {
|
||||
promise?: Promise<any>;
|
||||
fulfil?: (value?: any) => void;
|
||||
reject?: (err: Error) => void;
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
const d: Deferred = {};
|
||||
|
||||
d.promise = new Promise((fulfil, reject) => {
|
||||
d.fulfil = fulfil;
|
||||
d.reject = reject;
|
||||
});
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
function create_hot_update_server(port: number, interval = 10000) {
|
||||
const clients = new Set();
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url !== '/hmr') return;
|
||||
|
||||
req.socket.setKeepAlive(true);
|
||||
res.writeHead(200, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Content-Type': 'text/event-stream;charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
// While behind nginx, event stream should not be buffered:
|
||||
// http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
res.write('\n');
|
||||
|
||||
clients.add(res);
|
||||
req.on('close', () => {
|
||||
clients.delete(res);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
|
||||
function send(data: any) {
|
||||
clients.forEach(client => {
|
||||
client.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
send(null)
|
||||
}, interval);
|
||||
|
||||
return { send };
|
||||
}
|
||||
|
||||
export default async function dev(src: string, dir: string) {
|
||||
rimraf.sync(dir);
|
||||
mkdirp.sync(dir);
|
||||
|
||||
const chokidar = require('chokidar');
|
||||
|
||||
// initial build
|
||||
const dev_port = await require('get-port')(10000);
|
||||
|
||||
const routes = create_routes({ src });
|
||||
create_app({ routes, src, dev: true, dev_port });
|
||||
|
||||
const hot_update_server = create_hot_update_server(dev_port);
|
||||
|
||||
// TODO watch the configs themselves?
|
||||
const compilers = create_compilers();
|
||||
|
||||
function watch_files(pattern: string, callback: () => void) {
|
||||
const watcher = chokidar.watch(pattern, {
|
||||
persistent: false
|
||||
});
|
||||
|
||||
watcher.on('add', callback);
|
||||
watcher.on('change', callback);
|
||||
watcher.on('unlink', callback);
|
||||
}
|
||||
|
||||
watch_files('routes/**/*.+(html|js|mjs)', () => {
|
||||
const routes = create_routes({ src });
|
||||
create_app({ routes, src, dev: true, dev_port });
|
||||
});
|
||||
|
||||
watch_files('app/template.html', () => {
|
||||
const template = create_template();
|
||||
// TODO reload current page?
|
||||
});
|
||||
|
||||
let proc: child_process.ChildProcess;
|
||||
|
||||
const deferreds = {
|
||||
server: deferred(),
|
||||
client: deferred()
|
||||
};
|
||||
|
||||
const times = {
|
||||
client_start: Date.now(),
|
||||
server_start: Date.now(),
|
||||
serviceworker_start: Date.now()
|
||||
};
|
||||
|
||||
compilers.server.plugin('invalid', () => {
|
||||
times.server_start = Date.now();
|
||||
// TODO print message
|
||||
deferreds.server = deferred();
|
||||
});
|
||||
|
||||
compilers.server.watch({}, (err: Error, stats: any) => {
|
||||
if (err) {
|
||||
console.error(chalk.red(err.message));
|
||||
} else if (stats.hasErrors()) {
|
||||
// print errors. TODO notify client
|
||||
stats.toJson().errors.forEach((error: Error) => {
|
||||
console.error(error); // TODO make this look nice
|
||||
});
|
||||
} else {
|
||||
console.log(`built server in ${Date.now() - times.server_start}ms`); // TODO prettify
|
||||
|
||||
const server_info = stats.toJson();
|
||||
fs.writeFileSync(path.join(dir, 'server_info.json'), JSON.stringify(server_info, null, ' '));
|
||||
|
||||
deferreds.client.promise.then(() => {
|
||||
function restart() {
|
||||
wait_for_port(3000, deferreds.server.fulfil); // TODO control port
|
||||
}
|
||||
|
||||
if (proc) {
|
||||
proc.kill();
|
||||
proc.on('exit', restart);
|
||||
} else {
|
||||
restart();
|
||||
}
|
||||
|
||||
proc = child_process.fork(`${dir}/server.js`, [], {
|
||||
cwd: process.cwd(),
|
||||
env: Object.assign({}, process.env)
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
compilers.client.plugin('invalid', (filename: string) => {
|
||||
times.client_start = Date.now();
|
||||
|
||||
deferreds.client = deferred();
|
||||
|
||||
// TODO we should delete old assets. due to a webpack bug
|
||||
// i don't even begin to comprehend, this is apparently
|
||||
// quite difficult
|
||||
});
|
||||
|
||||
compilers.client.watch({}, (err: Error, stats: any) => {
|
||||
if (err) {
|
||||
console.error(chalk.red(err.message));
|
||||
} else if (stats.hasErrors()) {
|
||||
// print errors. TODO notify client
|
||||
stats.toJson().errors.forEach((error: Error) => {
|
||||
console.error(error); // TODO make this look nice
|
||||
});
|
||||
} else {
|
||||
console.log(`built client in ${Date.now() - times.client_start}ms`); // TODO prettify
|
||||
|
||||
const client_info = stats.toJson();
|
||||
fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify(client_info, null, ' '));
|
||||
deferreds.client.fulfil();
|
||||
|
||||
const client_files = client_info.assets.map((chunk: { name: string }) => `/client/${chunk.name}`);
|
||||
|
||||
deferreds.server.promise.then(() => {
|
||||
hot_update_server.send({
|
||||
status: 'completed'
|
||||
});
|
||||
});
|
||||
|
||||
create_serviceworker({
|
||||
routes: create_routes({ src }),
|
||||
client_files,
|
||||
src
|
||||
});
|
||||
|
||||
watch_serviceworker();
|
||||
}
|
||||
});
|
||||
|
||||
let watch_serviceworker = compilers.serviceworker
|
||||
? function() {
|
||||
watch_serviceworker = noop;
|
||||
|
||||
compilers.serviceworker.plugin('invalid', (filename: string) => {
|
||||
times.serviceworker_start = Date.now();
|
||||
});
|
||||
|
||||
compilers.serviceworker.watch({}, (err: Error, stats: any) => {
|
||||
if (err) {
|
||||
// TODO notify client
|
||||
} else if (stats.hasErrors()) {
|
||||
// print errors. TODO notify client
|
||||
stats.toJson().errors.forEach((error: Error) => {
|
||||
console.error(error); // TODO make this look nice
|
||||
});
|
||||
} else {
|
||||
console.log(`built service worker in ${Date.now() - times.serviceworker_start}ms`); // TODO prettify
|
||||
|
||||
const serviceworker_info = stats.toJson();
|
||||
fs.writeFileSync(path.join(dir, 'serviceworker_info.json'), JSON.stringify(serviceworker_info, null, ' '));
|
||||
}
|
||||
});
|
||||
}
|
||||
: noop;
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
91
src/cli/export.ts
Normal file
91
src/cli/export.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as child_process from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as sander from 'sander';
|
||||
import express from 'express';
|
||||
import cheerio from 'cheerio';
|
||||
import URL from 'url-parse';
|
||||
import fetch from 'node-fetch';
|
||||
import { wait_for_port } from './utils';
|
||||
|
||||
const { OUTPUT_DIR = 'dist' } = process.env;
|
||||
|
||||
const app = express();
|
||||
|
||||
function read_json(file: string) {
|
||||
return JSON.parse(sander.readFileSync(file, { encoding: 'utf-8' }));
|
||||
}
|
||||
|
||||
export default async function exporter(dir: string) { // dir === '.sapper'
|
||||
// Prep output directory
|
||||
sander.rimrafSync(OUTPUT_DIR);
|
||||
|
||||
sander.copydirSync('assets').to(OUTPUT_DIR);
|
||||
sander.copydirSync(dir, 'client').to(OUTPUT_DIR, 'client');
|
||||
sander.copyFileSync(dir, 'service-worker.js').to(OUTPUT_DIR, 'service-worker.js');
|
||||
|
||||
const port = await require('get-port')(3000);
|
||||
|
||||
const origin = `http://localhost:${port}`;
|
||||
|
||||
const proc = child_process.fork(path.resolve(`${dir}/server.js`), [], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
PORT: port,
|
||||
NODE_ENV: 'production',
|
||||
SAPPER_EXPORT: 'true'
|
||||
}
|
||||
});
|
||||
|
||||
const seen = new Set();
|
||||
const saved = new Set();
|
||||
|
||||
proc.on('message', message => {
|
||||
if (!message.__sapper__) return;
|
||||
|
||||
const url = new URL(message.url, origin);
|
||||
|
||||
if (saved.has(url.pathname)) return;
|
||||
saved.add(url.pathname);
|
||||
|
||||
if (message.type === 'text/html') {
|
||||
const dest = `${OUTPUT_DIR}/${url.pathname}/index.html`;
|
||||
sander.writeFileSync(dest, message.body);
|
||||
} else {
|
||||
const dest = `${OUTPUT_DIR}/${url.pathname}`;
|
||||
sander.writeFileSync(dest, message.body);
|
||||
}
|
||||
});
|
||||
|
||||
function handle(url: URL) {
|
||||
if (url.origin !== origin) return;
|
||||
|
||||
if (seen.has(url.pathname)) return;
|
||||
seen.add(url.pathname);
|
||||
|
||||
return fetch(url.href)
|
||||
.then(r => {
|
||||
if (r.headers.get('Content-Type') === 'text/html') {
|
||||
return r.text().then((body: string) => {
|
||||
const $ = cheerio.load(body);
|
||||
const hrefs: string[] = [];
|
||||
|
||||
$('a[href]').each((i: number, $a) => {
|
||||
hrefs.push($a.attribs.href);
|
||||
});
|
||||
|
||||
return hrefs.reduce((promise, href) => {
|
||||
return promise.then(() => handle(new URL(href, url.href)));
|
||||
}, Promise.resolve());
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error(`Error rendering ${url.pathname}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
wait_for_port(port, () => {
|
||||
handle(new URL(origin)) // TODO all static routes
|
||||
.then(() => proc.kill())
|
||||
});
|
||||
}
|
||||
20
src/cli/help.md
Normal file
20
src/cli/help.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# sapper v<@version@>
|
||||
|
||||
https://sapper.svelte.technology
|
||||
|
||||
> sapper dev
|
||||
|
||||
Start a development server
|
||||
|
||||
> sapper build
|
||||
|
||||
Creates a production-ready version of your app
|
||||
|
||||
> sapper export
|
||||
|
||||
If possible, exports your app as static files, suitable for hosting on
|
||||
services like Netlify or Surge
|
||||
|
||||
> sapper --help
|
||||
|
||||
Shows this message
|
||||
61
src/cli/index.ts
Executable file
61
src/cli/index.ts
Executable file
@@ -0,0 +1,61 @@
|
||||
import mri from 'mri';
|
||||
import chalk from 'chalk';
|
||||
import help from './help.md';
|
||||
import build from './build';
|
||||
import exporter from './export';
|
||||
import dev from './dev';
|
||||
import upgrade from './upgrade';
|
||||
import { dest, entry, src } from '../config';
|
||||
import * as pkg from '../../package.json';
|
||||
|
||||
const opts = mri(process.argv.slice(2), {
|
||||
alias: {
|
||||
h: 'help'
|
||||
}
|
||||
});
|
||||
|
||||
if (opts.help) {
|
||||
const rendered = help
|
||||
.replace('<@version@>', pkg.version)
|
||||
.replace(/^(.+)/gm, (m: string, $1: string) => /[#>]/.test(m) ? $1 : ` ${$1}`)
|
||||
.replace(/^# (.+)/gm, (m: string, $1: string) => chalk.bold.underline($1))
|
||||
.replace(/^> (.+)/gm, (m: string, $1: string) => chalk.cyan($1));
|
||||
|
||||
console.log(`\n${rendered}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const [cmd] = opts._;
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
if (cmd === 'build') {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
build({ dest, dev: false, entry, src })
|
||||
.then(() => {
|
||||
const elapsed = Date.now() - start;
|
||||
console.error(`built in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds'
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
|
||||
});
|
||||
} else if (cmd === 'export') {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
build({ dest, dev: false, entry, src })
|
||||
.then(() => exporter(dest))
|
||||
.then(() => {
|
||||
const elapsed = Date.now() - start;
|
||||
console.error(`extracted in ${elapsed}ms`); // TODO beautify this, e.g. 'built in 4.7 seconds'
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
|
||||
});
|
||||
} else if (cmd === 'dev') {
|
||||
dev(src, dest);
|
||||
} else if (cmd === 'upgrade') {
|
||||
upgrade();
|
||||
} else {
|
||||
console.log(`unrecognized command ${cmd} — try \`sapper --help\` for more information`);
|
||||
}
|
||||
53
src/cli/upgrade.ts
Normal file
53
src/cli/upgrade.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as fs from 'fs';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export default async function upgrade() {
|
||||
const upgraded = [
|
||||
await upgrade_sapper_main()
|
||||
].filter(Boolean);
|
||||
|
||||
if (upgraded.length === 0) {
|
||||
console.log(`No changes!`);
|
||||
}
|
||||
}
|
||||
|
||||
async function upgrade_sapper_main() {
|
||||
const _2xx = read('templates/2xx.html');
|
||||
const _4xx = read('templates/4xx.html');
|
||||
const _5xx = read('templates/5xx.html');
|
||||
|
||||
const pattern = /<script src='\%sapper\.main\%'><\/script>/;
|
||||
|
||||
let replaced = false;
|
||||
|
||||
['2xx', '4xx', '5xx'].forEach(code => {
|
||||
const file = `templates/${code}.html`
|
||||
const template = read(file);
|
||||
if (!template) return;
|
||||
|
||||
if (/\%sapper\.main\%/.test(template)) {
|
||||
if (!pattern.test(template)) {
|
||||
console.log(chalk.red(`Could not replace %sapper.main% in ${file}`));
|
||||
} else {
|
||||
write(file, template.replace(pattern, `%sapper.scripts%`));
|
||||
console.log(chalk.green(`Replaced %sapper.main% in ${file}`));
|
||||
replaced = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return replaced;
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(file: string, data: string) {
|
||||
fs.writeFileSync(file, data);
|
||||
}
|
||||
24
src/cli/utils.ts
Normal file
24
src/cli/utils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import waitPort from 'wait-port';
|
||||
|
||||
export function wait_for_port(port: number, cb: () => void) {
|
||||
waitPort({ port }).then(cb);
|
||||
}
|
||||
|
||||
// import * as net from 'net';
|
||||
|
||||
// export function wait_for_port(port: number, cb: () => void) {
|
||||
// const socket = net.createConnection(port, 'localhost', () => {
|
||||
// cb();
|
||||
// socket.destroy();
|
||||
// });
|
||||
|
||||
// socket.on('error', err => {
|
||||
// setTimeout(() => {
|
||||
// wait_for_port(port, cb);
|
||||
// }, 100);
|
||||
// });
|
||||
|
||||
// setTimeout(() => {
|
||||
// socket.destroy();
|
||||
// }, 100);
|
||||
// }
|
||||
12
src/config.ts
Normal file
12
src/config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as path from 'path';
|
||||
|
||||
export const isDev = () => process.env.NODE_ENV !== 'production';
|
||||
|
||||
export const templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates');
|
||||
export const src = path.resolve(process.env.SAPPER_ROUTES || 'routes');
|
||||
export const dest = path.resolve(process.env.SAPPER_DEST || '.sapper');
|
||||
|
||||
export const entry = {
|
||||
client: path.resolve(templates, '.main.rendered.js'),
|
||||
server: path.resolve(dest, 'server-entry.js')
|
||||
};
|
||||
91
src/core/create_app.ts
Normal file
91
src/core/create_app.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import mkdirp from 'mkdirp';
|
||||
import create_routes from './create_routes';
|
||||
import { fudge_mtime, posixify, write } from './utils';
|
||||
import { Route } from '../interfaces';
|
||||
|
||||
export default function create_app({ routes, src, dev, dev_port }: {
|
||||
routes: Route[];
|
||||
src: string;
|
||||
dev: boolean;
|
||||
dev_port: number;
|
||||
}) {
|
||||
mkdirp.sync('app/manifest');
|
||||
|
||||
write('app/manifest/client.js', generate_client(routes, src, dev, dev_port));
|
||||
write('app/manifest/server.js', generate_server(routes, src));
|
||||
}
|
||||
|
||||
function generate_client(routes: Route[], src: string, dev: boolean, dev_port?: number) {
|
||||
let code = `
|
||||
// This file is generated by Sapper — do not edit it!
|
||||
export const routes = [
|
||||
${routes
|
||||
.filter(route => route.type === 'page')
|
||||
.map(route => {
|
||||
const file = posixify(`../../routes/${route.file}`);
|
||||
|
||||
if (route.id === '_4xx' || route.id === '_5xx') {
|
||||
return `{ error: '${route.id.slice(1)}', load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`;
|
||||
}
|
||||
|
||||
const params = route.params.length === 0
|
||||
? '{}'
|
||||
: `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
|
||||
|
||||
return `{ pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`;
|
||||
})
|
||||
.join(',\n\t')}
|
||||
];`.replace(/^\t\t/gm, '').trim();
|
||||
|
||||
if (dev) {
|
||||
const hmr_client = posixify(
|
||||
path.resolve(__dirname, 'hmr-client.js')
|
||||
);
|
||||
|
||||
code += `
|
||||
|
||||
if (module.hot) {
|
||||
import('${hmr_client}').then(client => {
|
||||
client.connect(${dev_port});
|
||||
});
|
||||
}`.replace(/^\t{3}/gm, '');
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
function generate_server(routes: Route[], src: string) {
|
||||
let code = `
|
||||
// This file is generated by Sapper — do not edit it!
|
||||
${routes
|
||||
.map(route => {
|
||||
const file = posixify(`../../routes/${route.file}`);
|
||||
return route.type === 'page'
|
||||
? `import ${route.id} from '${file}';`
|
||||
: `import * as ${route.id} from '${file}';`;
|
||||
})
|
||||
.join('\n')}
|
||||
|
||||
export const routes = [
|
||||
${routes
|
||||
.map(route => {
|
||||
const file = posixify(`${src}/${route.file}`);
|
||||
|
||||
if (route.id === '_4xx' || route.id === '_5xx') {
|
||||
return `{ error: '${route.id.slice(1)}', module: ${route.id} }`;
|
||||
}
|
||||
|
||||
const params = route.params.length === 0
|
||||
? '{}'
|
||||
: `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
|
||||
|
||||
return `{ id: '${route.id}', type: '${route.type}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), module: ${route.id} }`;
|
||||
})
|
||||
.join(',\n\t')
|
||||
}
|
||||
];`.replace(/^\t\t/gm, '').trim();
|
||||
|
||||
return code;
|
||||
}
|
||||
29
src/core/create_compilers.ts
Normal file
29
src/core/create_compilers.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as path from 'path';
|
||||
import relative from 'require-relative';
|
||||
|
||||
export default function create_compilers() {
|
||||
const webpack = relative('webpack', process.cwd());
|
||||
|
||||
const serviceworker_config = try_require(path.resolve('webpack/service-worker.config.js'));
|
||||
|
||||
return {
|
||||
client: webpack(
|
||||
require(path.resolve('webpack/client.config.js'))
|
||||
),
|
||||
|
||||
server: webpack(
|
||||
require(path.resolve('webpack/server.config.js'))
|
||||
),
|
||||
|
||||
serviceworker: serviceworker_config && webpack(serviceworker_config)
|
||||
};
|
||||
}
|
||||
|
||||
function try_require(specifier: string) {
|
||||
try {
|
||||
return require(specifier);
|
||||
} catch (err) {
|
||||
if (err.code === 'MODULE_NOT_FOUND') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
129
src/core/create_routes.ts
Normal file
129
src/core/create_routes.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import * as path from 'path';
|
||||
import glob from 'glob';
|
||||
import { Route } from '../interfaces';
|
||||
|
||||
export default function create_routes({ src, files = glob.sync('**/*.+(html|js|mjs)', { cwd: src }) }: {
|
||||
src: string;
|
||||
files?: string[];
|
||||
}) {
|
||||
const routes: Route[] = files
|
||||
.map((file: string) => {
|
||||
if (/(^|\/|\\)_/.test(file)) return;
|
||||
|
||||
if (/]\[/.test(file)) {
|
||||
throw new Error(`Invalid route ${file} — parameters must be separated`);
|
||||
}
|
||||
|
||||
const base = file.replace(/\.[^/.]+$/, '');
|
||||
const parts = base.split('/'); // glob output is always posix-style
|
||||
if (parts[parts.length - 1] === 'index') parts.pop();
|
||||
|
||||
const id = (
|
||||
parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_')
|
||||
) || '_';
|
||||
|
||||
const params: string[] = [];
|
||||
const param_pattern = /\[([^\]]+)\]/g;
|
||||
let match;
|
||||
while (match = param_pattern.exec(base)) {
|
||||
params.push(match[1]);
|
||||
}
|
||||
|
||||
// TODO can we do all this with sub-parts? or does
|
||||
// nesting make that impossible?
|
||||
let pattern_string = '';
|
||||
let i = parts.length;
|
||||
let nested = true;
|
||||
while (i--) {
|
||||
const part = parts[i];
|
||||
const dynamic = part[0] === '[';
|
||||
|
||||
if (dynamic) {
|
||||
const matcher = part.replace(param_pattern, `([^\/]+?)`);
|
||||
pattern_string = nested ? `(?:\\/${matcher}${pattern_string})?` : `\\/${matcher}${pattern_string}`;
|
||||
} else {
|
||||
nested = false;
|
||||
pattern_string = `\\/${part}${pattern_string}`;
|
||||
}
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${pattern_string}\\/?$`);
|
||||
|
||||
const test = (url: string) => pattern.test(url);
|
||||
|
||||
const exec = (url: string) => {
|
||||
const match = pattern.exec(url);
|
||||
if (!match) return;
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
params.forEach((param, i) => {
|
||||
result[param] = match[i + 1];
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
type: path.extname(file) === '.html' ? 'page' : 'route',
|
||||
file,
|
||||
pattern,
|
||||
test,
|
||||
exec,
|
||||
parts,
|
||||
params
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a: Route, b: Route) => {
|
||||
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_sub_parts = get_sub_parts(a_part);
|
||||
const b_sub_parts = get_sub_parts(b_part);
|
||||
|
||||
for (let i = 0; true; i += 1) {
|
||||
const a_sub_part = a_sub_parts[i];
|
||||
const b_sub_part = b_sub_parts[i];
|
||||
|
||||
if (!a_sub_part && !b_sub_part) break;
|
||||
|
||||
if (!a_sub_part) return 1; // note this is reversed from above — match [foo].json before [foo]
|
||||
if (!b_sub_part) return -1;
|
||||
|
||||
if (a_sub_part.dynamic !== b_sub_part.dynamic) {
|
||||
return a_sub_part.dynamic ? 1 : -1;
|
||||
}
|
||||
|
||||
if (!a_sub_part.dynamic && a_sub_part.content !== b_sub_part.content) {
|
||||
return b_sub_part.content.length - a_sub_part.content.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
function get_sub_parts(part: string) {
|
||||
return part.split(/[\[\]]/)
|
||||
.map((content, i) => {
|
||||
if (!content) return null;
|
||||
return {
|
||||
content,
|
||||
dynamic: i % 2 === 1
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
27
src/core/create_serviceworker.ts
Normal file
27
src/core/create_serviceworker.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import glob from 'glob';
|
||||
import create_routes from './create_routes';
|
||||
import { fudge_mtime, posixify, write } from './utils';
|
||||
import { Route } from '../interfaces';
|
||||
|
||||
export default function create_serviceworker({ routes, client_files, src }: {
|
||||
routes: Route[];
|
||||
client_files: string[];
|
||||
src: string;
|
||||
}) {
|
||||
const assets = glob.sync('**', { cwd: 'assets', nodir: true });
|
||||
|
||||
let code = `
|
||||
// This file is generated by Sapper — do not edit it!
|
||||
export const timestamp = ${Date.now()};
|
||||
|
||||
export const assets = [\n\t${assets.map((x: string) => `"${x}"`).join(',\n\t')}\n];
|
||||
|
||||
export const shell = [\n\t${client_files.map((x: string) => `"${x}"`).join(',\n\t')}\n];
|
||||
|
||||
export const routes = [\n\t${routes.filter((r: Route) => r.type === 'page' && !/^_[45]xx$/.test(r.id)).map((r: Route) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n];
|
||||
`.replace(/^\t\t/gm, '').trim();
|
||||
|
||||
write('app/manifest/service-worker.js', code);
|
||||
}
|
||||
39
src/core/create_template.ts
Normal file
39
src/core/create_template.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as fs from 'fs';
|
||||
import chalk from 'chalk';
|
||||
import framer from 'code-frame';
|
||||
import { locate } from 'locate-character';
|
||||
|
||||
function error(e: any) {
|
||||
if (e.title) console.error(chalk.bold.red(e.title));
|
||||
if (e.body) console.error(chalk.red(e.body));
|
||||
if (e.url) console.error(chalk.cyan(e.url));
|
||||
if (e.frame) console.error(chalk.grey(e.frame));
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export default function create_templates() {
|
||||
const template = fs.readFileSync(`app/template.html`, 'utf-8');
|
||||
|
||||
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: `app/template.html`,
|
||||
body: `<script src='%sapper.main%'> is unsupported — use %sapper.scripts% (without the <script> tag) instead`,
|
||||
url: 'https://github.com/sveltejs/sapper/issues/86',
|
||||
frame
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
render: (data: Record<string, string>) => {
|
||||
return template.replace(/%sapper\.(\w+)%/g, (match, key) => {
|
||||
return key in data ? data[key] : '';
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
5
src/core/index.ts
Normal file
5
src/core/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as create_app } from './create_app';
|
||||
export { default as create_serviceworker } from './create_serviceworker';
|
||||
export { default as create_compilers } from './create_compilers';
|
||||
export { default as create_routes } from './create_routes';
|
||||
export { default as create_template } from './create_template';
|
||||
20
src/core/utils.ts
Normal file
20
src/core/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function write(file: string, code: string) {
|
||||
fs.writeFileSync(file, code);
|
||||
fudge_mtime(file);
|
||||
}
|
||||
|
||||
export function posixify(file: string) {
|
||||
return file.replace(/[/\\]/g, '/');
|
||||
}
|
||||
|
||||
export function fudge_mtime(file: string) {
|
||||
// need to fudge the mtime so that webpack doesn't go doolally
|
||||
const { atime, mtime } = fs.statSync(file);
|
||||
fs.utimesSync(
|
||||
file,
|
||||
new Date(atime.getTime() - 999999),
|
||||
new Date(mtime.getTime() - 999999)
|
||||
);
|
||||
}
|
||||
15
src/interfaces.ts
Normal file
15
src/interfaces.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type Route = {
|
||||
id: string;
|
||||
type: 'page' | 'route';
|
||||
file: string;
|
||||
pattern: RegExp;
|
||||
test: (url: string) => boolean;
|
||||
exec: (url: string) => Record<string, string>;
|
||||
parts: string[];
|
||||
params: string[];
|
||||
};
|
||||
|
||||
export type Template = {
|
||||
render: (data: Record<string, string>) => string;
|
||||
stream: (req, res, data: Record<string, string | Promise<string>>) => void;
|
||||
};
|
||||
350
src/middleware/index.ts
Normal file
350
src/middleware/index.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ClientRequest, ServerResponse } from 'http';
|
||||
// import * as mime from 'mime';
|
||||
import mkdirp from 'mkdirp';
|
||||
import rimraf from 'rimraf';
|
||||
import serialize from 'serialize-javascript';
|
||||
import escape_html from 'escape-html';
|
||||
import { create_routes, templates, create_compilers, create_template } from 'sapper/core.js';
|
||||
import { dest, entry, isDev, src } from '../config';
|
||||
import { Route, Template } from '../interfaces';
|
||||
import sourceMapSupport from 'source-map-support';
|
||||
|
||||
sourceMapSupport.install();
|
||||
|
||||
const dev = isDev();
|
||||
|
||||
type RouteObject = {
|
||||
id: string;
|
||||
type: 'page' | 'route';
|
||||
pattern: RegExp;
|
||||
params: (match: RegExpMatchArray) => Record<string, string>;
|
||||
module: {
|
||||
render: (data: any) => {
|
||||
head: string;
|
||||
css: { code: string, map: any };
|
||||
html: string
|
||||
},
|
||||
preload: (data: any) => any | Promise<any>
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type Handler = (req: Req, res: ServerResponse, next: () => void) => void;
|
||||
|
||||
interface Req extends ClientRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
pathname: string;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function middleware({ routes }: {
|
||||
routes: RouteObject[]
|
||||
}) {
|
||||
const client_info = JSON.parse(fs.readFileSync(path.join(dest, 'client_info.json'), 'utf-8'));
|
||||
|
||||
const template = create_template();
|
||||
|
||||
const shell = try_read(path.join(dest, 'index.html'));
|
||||
const serviceworker = try_read(path.join(dest, 'service-worker.js'));
|
||||
|
||||
const middleware = compose_handlers([
|
||||
(req: Req, res: ServerResponse, next: () => void) => {
|
||||
req.pathname = req.url.replace(/\?.*/, '');
|
||||
next();
|
||||
},
|
||||
|
||||
shell && get_asset_handler({
|
||||
pathname: '/index.html',
|
||||
type: 'text/html',
|
||||
cache: 'max-age=600',
|
||||
body: shell
|
||||
}),
|
||||
|
||||
serviceworker && get_asset_handler({
|
||||
pathname: '/service-worker.js',
|
||||
type: 'application/javascript',
|
||||
cache: 'max-age=600',
|
||||
body: serviceworker
|
||||
}),
|
||||
|
||||
(req: Req, res: ServerResponse, next: () => void) => {
|
||||
if (req.pathname.startsWith('/client/')) {
|
||||
// const type = mime.getType(req.pathname);
|
||||
const type = 'application/javascript'; // TODO might not be, if using e.g. CSS plugin
|
||||
|
||||
// TODO cache?
|
||||
const rs = fs.createReadStream(path.join(dest, req.pathname.slice(1)));
|
||||
|
||||
rs.on('error', error => {
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', type);
|
||||
res.setHeader('Cache-Control', 'max-age=31536000');
|
||||
rs.pipe(res);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
|
||||
get_route_handler(client_info.assetsByChunkName, routes, template)
|
||||
].filter(Boolean));
|
||||
|
||||
return middleware;
|
||||
}
|
||||
|
||||
function get_asset_handler({ pathname, type, cache, body }: {
|
||||
pathname: string;
|
||||
type: string;
|
||||
cache: string;
|
||||
body: string;
|
||||
}) {
|
||||
return (req: Req, res: ServerResponse, next: () => void) => {
|
||||
if (req.pathname !== pathname) return next();
|
||||
|
||||
res.setHeader('Content-Type', type);
|
||||
res.setHeader('Cache-Control', cache);
|
||||
res.end(body);
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = Promise.resolve();
|
||||
|
||||
function get_route_handler(chunks: Record<string, string>, routes: RouteObject[], template: Template) {
|
||||
function handle_route(route: RouteObject, req: Req, res: ServerResponse) {
|
||||
req.params = route.params(route.pattern.exec(req.pathname));
|
||||
|
||||
const mod = route.module;
|
||||
|
||||
if (route.type === 'page') {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
|
||||
// preload main.js and current route
|
||||
// TODO detect other stuff we can preload? images, CSS, fonts?
|
||||
const link = []
|
||||
.concat(chunks.main, chunks[route.id])
|
||||
.map(file => `</client/${file}>;rel="preload";as="script"`)
|
||||
.join(', ');
|
||||
|
||||
res.setHeader('Link', link);
|
||||
|
||||
const data = { params: req.params, query: req.query };
|
||||
|
||||
let redirect: { statusCode: number, location: string };
|
||||
let error: { statusCode: number, message: Error | string };
|
||||
|
||||
Promise.resolve(
|
||||
mod.preload ? mod.preload.call({
|
||||
redirect: (statusCode: number, location: string) => {
|
||||
redirect = { statusCode, location };
|
||||
},
|
||||
error: (statusCode: number, message: Error | string) => {
|
||||
error = { statusCode, message };
|
||||
}
|
||||
}, req) : {}
|
||||
).catch(err => {
|
||||
error = { statusCode: 500, message: err };
|
||||
}).then(preloaded => {
|
||||
if (redirect) {
|
||||
res.statusCode = redirect.statusCode;
|
||||
res.setHeader('Location', redirect.location);
|
||||
res.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
handle_error(req, res, error.statusCode, error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = try_serialize(preloaded); // TODO bail on non-POJOs
|
||||
Object.assign(data, preloaded);
|
||||
|
||||
const { html, head, css } = mod.render(data);
|
||||
|
||||
let scripts = []
|
||||
.concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack
|
||||
.map(file => `<script src='/client/${file}'></script>`)
|
||||
.join('');
|
||||
|
||||
scripts = `<script>__SAPPER__ = { preloaded: ${serialized} };</script>${scripts}`;
|
||||
|
||||
const page = template.render({
|
||||
scripts,
|
||||
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);
|
||||
|
||||
if (process.send) {
|
||||
process.send({
|
||||
__sapper__: true,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
status: 200,
|
||||
type: 'text/html',
|
||||
body: 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) {
|
||||
if (process.env.SAPPER_EXPORT) {
|
||||
const { write, end, setHeader } = res;
|
||||
const chunks: any[] = [];
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// intercept data so that it can be exported
|
||||
res.write = function(chunk: any) {
|
||||
chunks.push(new Buffer(chunk));
|
||||
write.apply(res, arguments);
|
||||
};
|
||||
|
||||
res.setHeader = function(name: string, value: string) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
setHeader.apply(res, arguments);
|
||||
};
|
||||
|
||||
res.end = function(chunk?: any) {
|
||||
if (chunk) chunks.push(new Buffer(chunk));
|
||||
end.apply(res, arguments);
|
||||
|
||||
process.send({
|
||||
__sapper__: true,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
status: res.statusCode,
|
||||
type: headers['content-type'],
|
||||
body: Buffer.concat(chunks).toString()
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
handler(req, res, () => {
|
||||
handle_not_found(req, res, 404, 'Not found');
|
||||
});
|
||||
} else {
|
||||
// no matching handler for method — 404
|
||||
handle_not_found(req, res, 404, 'Not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const not_found_route = routes.find((route: RouteObject) => route.error === '4xx');
|
||||
|
||||
function handle_not_found(req: Req, res: ServerResponse, statusCode: number, message: Error | string) {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
|
||||
const error = message instanceof Error ? message : new Error(message);
|
||||
|
||||
const rendered = not_found_route ? not_found_route.module.render({
|
||||
status: 404,
|
||||
error
|
||||
}) : { head: '', css: null, html: error.message };
|
||||
|
||||
const { head, css, html } = rendered;
|
||||
|
||||
res.end(template.render({
|
||||
scripts: `<script src='/client/${chunks.main}'></script>`,
|
||||
html,
|
||||
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
|
||||
styles: (css && css.code ? `<style>${css.code}</style>` : '')
|
||||
}));
|
||||
}
|
||||
|
||||
const error_route = routes.find((route: RouteObject) => route.error === '5xx');
|
||||
|
||||
function handle_error(req: Req, res: ServerResponse, statusCode: number, message: Error | string) {
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
return handle_not_found(req, res, statusCode, message);
|
||||
}
|
||||
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
|
||||
const error = message instanceof Error ? message : new Error(message);
|
||||
|
||||
const rendered = error_route ? error_route.module.render({
|
||||
status: 500,
|
||||
error
|
||||
}) : { head: '', css: null, html: `Internal server error: ${error.message}` };
|
||||
|
||||
const { head, css, html } = rendered;
|
||||
|
||||
res.end(template.render({
|
||||
scripts: `<script src='/client/${chunks.main}'></script>`,
|
||||
html,
|
||||
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
|
||||
styles: (css && css.code ? `<style>${css.code}</style>` : '')
|
||||
}));
|
||||
}
|
||||
|
||||
return function find_route(req: Req, res: ServerResponse, next: () => void) {
|
||||
const url = req.pathname;
|
||||
|
||||
try {
|
||||
for (const route of routes) {
|
||||
if (!route.error && route.pattern.test(url)) return handle_route(route, req, res);
|
||||
}
|
||||
|
||||
handle_not_found(req, res, 404, 'Not found');
|
||||
} catch (error) {
|
||||
handle_error(req, res, 500, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function compose_handlers(handlers: Handler[]) {
|
||||
return (req: Req, res: ServerResponse, next: () => void) => {
|
||||
let i = 0;
|
||||
function go() {
|
||||
const handler = handlers[i];
|
||||
|
||||
if (handler) {
|
||||
handler(req, res, () => {
|
||||
i += 1;
|
||||
go();
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
go();
|
||||
};
|
||||
}
|
||||
|
||||
function read_json(file: string) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
}
|
||||
|
||||
function try_serialize(data: any) {
|
||||
try {
|
||||
return serialize(data);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function try_read(file: string) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8');
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
268
src/runtime/index.ts
Normal file
268
src/runtime/index.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { detach, findAnchor, scroll_state, which } from './utils';
|
||||
import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition, Target } from './interfaces';
|
||||
|
||||
export let component: Component;
|
||||
let target: Node;
|
||||
let routes: Route[];
|
||||
let errors: { '4xx': Route, '5xx': 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): Target {
|
||||
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> = {};
|
||||
if (url.search.length > 0) {
|
||||
url.search.slice(1).split('&').forEach(searchParam => {
|
||||
const [, key, value] = /([^=]+)=(.*)/.exec(searchParam);
|
||||
query[key] = value || true;
|
||||
})
|
||||
}
|
||||
return { url, 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) {
|
||||
let redirect: { statusCode: number, location: string } = null;
|
||||
let error: { statusCode: number, message: Error | string } = null;
|
||||
|
||||
if (!Component.preload) {
|
||||
return { Component, data, redirect, error };
|
||||
}
|
||||
|
||||
if (!component && window.__SAPPER__ && window.__SAPPER__.preloaded) {
|
||||
return { Component, data: Object.assign(data, window.__SAPPER__.preloaded), redirect, error };
|
||||
}
|
||||
|
||||
return Promise.resolve(Component.preload.call({
|
||||
redirect: (statusCode: number, location: string) => {
|
||||
redirect = { statusCode, location };
|
||||
},
|
||||
error: (statusCode: number, message: Error | string) => {
|
||||
error = { statusCode, message };
|
||||
}
|
||||
}, data)).catch(err => {
|
||||
error = { statusCode: 500, message: err };
|
||||
}).then(preloaded => {
|
||||
if (error) {
|
||||
const route = error.statusCode >= 400 && error.statusCode < 500
|
||||
? errors['4xx']
|
||||
: errors['5xx'];
|
||||
|
||||
return route.load().then(({ default: Component }: { default: ComponentConstructor }) => {
|
||||
const err = error.message instanceof Error ? error.message : new Error(error.message);
|
||||
Object.assign(data, { status: error.statusCode, error: err });
|
||||
return { Component, data, redirect: null };
|
||||
});
|
||||
}
|
||||
|
||||
Object.assign(data, preloaded)
|
||||
return { Component, data, redirect };
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(target: Target, id: number) {
|
||||
if (id) {
|
||||
// popstate or initial navigation
|
||||
cid = id;
|
||||
} else {
|
||||
// clicked on a link. preserve scroll state
|
||||
scroll_history[cid] = scroll_state();
|
||||
|
||||
id = cid = ++uid;
|
||||
scroll_history[cid] = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
cid = id;
|
||||
|
||||
const loaded = prefetching && prefetching.href === target.url.href ?
|
||||
prefetching.promise :
|
||||
target.route.load().then(mod => prepare_route(mod.default, target.data));
|
||||
|
||||
prefetching = null;
|
||||
|
||||
const token = current_token = {};
|
||||
|
||||
return loaded.then(({ Component, data, redirect }) => {
|
||||
if (redirect) {
|
||||
return goto(redirect.location, { replaceState: true });
|
||||
}
|
||||
|
||||
render(Component, data, scroll_history[id], token);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const target = select_route(url);
|
||||
if (target) {
|
||||
navigate(target, null);
|
||||
event.preventDefault();
|
||||
history.pushState({ id: cid }, '', url.href);
|
||||
}
|
||||
}
|
||||
|
||||
function handle_popstate(event: PopStateEvent) {
|
||||
scroll_history[cid] = scroll_state();
|
||||
|
||||
if (event.state) {
|
||||
const url = new URL(window.location.href);
|
||||
const target = select_route(url);
|
||||
navigate(target, 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.filter(r => !r.error);
|
||||
errors = {
|
||||
'4xx': _routes.find(r => r.error === '4xx'),
|
||||
'5xx': _routes.find(r => r.error === '5xx')
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return Promise.resolve().then(() => {
|
||||
const { hash, href } = window.location;
|
||||
|
||||
const deep_linked = hash && document.getElementById(hash.slice(1));
|
||||
scroll_history[uid] = deep_linked ?
|
||||
{ x: 0, y: deep_linked.getBoundingClientRect().top } :
|
||||
scroll_state();
|
||||
|
||||
history.replaceState({ id: uid }, '', href);
|
||||
|
||||
const target = select_route(new URL(window.location.href));
|
||||
return navigate(target, uid);
|
||||
});
|
||||
}
|
||||
|
||||
export function goto(href: string, opts = { replaceState: false }) {
|
||||
const target = select_route(new URL(href, window.location.href));
|
||||
if (target) {
|
||||
navigate(target, null);
|
||||
if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
29
src/runtime/interfaces.ts
Normal file
29
src/runtime/interfaces.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export type Target = {
|
||||
url: URL;
|
||||
route: Route;
|
||||
data: RouteData;
|
||||
};
|
||||
19
src/runtime/utils.ts
Normal file
19
src/runtime/utils.ts
Normal 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
|
||||
};
|
||||
}
|
||||
44
src/webpack/index.ts
Normal file
44
src/webpack/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { dest, isDev, entry } from '../config';
|
||||
|
||||
export default {
|
||||
dev: isDev(),
|
||||
|
||||
client: {
|
||||
entry: () => {
|
||||
return {
|
||||
main: [
|
||||
'./app/client.js',
|
||||
// workaround for https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/456
|
||||
'style-loader/lib/addStyles',
|
||||
'css-loader/lib/css-base'
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
output: () => {
|
||||
return {
|
||||
path: `${dest}/client`,
|
||||
filename: '[hash]/[name].js',
|
||||
chunkFilename: '[hash]/[name].[id].js',
|
||||
publicPath: '/client/'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
server: {
|
||||
entry: () => {
|
||||
return {
|
||||
server: './app/server.js'
|
||||
};
|
||||
},
|
||||
|
||||
output: () => {
|
||||
return {
|
||||
path: `${dest}`,
|
||||
filename: '[name].js',
|
||||
chunkFilename: '[hash]/[name].[id].js',
|
||||
libraryTarget: 'commonjs2'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
3
test/app/.gitignore
vendored
3
test/app/.gitignore
vendored
@@ -3,4 +3,5 @@ node_modules
|
||||
.sapper
|
||||
yarn.lock
|
||||
cypress/screenshots
|
||||
templates/.*
|
||||
templates/.*
|
||||
dist
|
||||
|
||||
6
test/app/app/client.js
Normal file
6
test/app/app/client.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { init } from '../../../runtime.js';
|
||||
import { routes } from './manifest/client.js';
|
||||
|
||||
window.init = () => {
|
||||
return init(document.querySelector('#sapper'), routes);
|
||||
};
|
||||
78
test/app/app/server.js
Normal file
78
test/app/app/server.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import fs from 'fs';
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import serve from 'serve-static';
|
||||
import sapper from '../../../middleware';
|
||||
import { routes } from './manifest/server.js';
|
||||
|
||||
let count;
|
||||
let ended;
|
||||
|
||||
process.on('message', message => {
|
||||
if (message.action === 'start') {
|
||||
count = 0;
|
||||
ended = false;
|
||||
process.send({ type: 'ready' });
|
||||
}
|
||||
|
||||
if (message.action === 'end') {
|
||||
ended = true;
|
||||
if (count === 0) process.send({ type: 'done' });
|
||||
}
|
||||
});
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use((req, res, next) => {
|
||||
count += 1;
|
||||
|
||||
const { write, end } = res;
|
||||
const chunks = [];
|
||||
|
||||
res.write = function(chunk) {
|
||||
chunks.push(new Buffer(chunk));
|
||||
write.apply(res, arguments);
|
||||
};
|
||||
|
||||
res.end = function(chunk) {
|
||||
if (chunk) chunks.push(new Buffer(chunk));
|
||||
end.apply(res, arguments);
|
||||
|
||||
count -= 1;
|
||||
|
||||
process.send({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.statusCode,
|
||||
headers: res._headers,
|
||||
body: Buffer.concat(chunks).toString()
|
||||
});
|
||||
|
||||
if (count === 0 && ended) {
|
||||
process.send({ type: 'done' });
|
||||
}
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
const { PORT = 3000 } = process.env;
|
||||
|
||||
// this allows us to do e.g. `fetch('/api/blog')` on the server
|
||||
const fetch = require('node-fetch');
|
||||
global.fetch = (url, opts) => {
|
||||
if (url[0] === '/') url = `http://localhost:${PORT}${url}`;
|
||||
return fetch(url, opts);
|
||||
};
|
||||
|
||||
app.use(compression({ threshold: 0 }));
|
||||
|
||||
app.use(serve('assets'));
|
||||
|
||||
app.use(sapper({
|
||||
routes
|
||||
}));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`listening on port ${PORT}`);
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
const ASSETS = `cache__timestamp__`;
|
||||
import { assets, shell, timestamp, routes } from './manifest/service-worker.js';
|
||||
|
||||
const ASSETS = `cachetimestamp`;
|
||||
|
||||
// `shell` is an array of all the files generated by webpack,
|
||||
// `assets` is an array of everything in the `assets` directory
|
||||
const to_cache = __shell__.concat(__assets__);
|
||||
const to_cache = shell.concat(assets);
|
||||
const cached = new Set(to_cache);
|
||||
|
||||
// `routes` is an array of `{ pattern: RegExp }` objects that
|
||||
// match the pages in your app
|
||||
const routes = __routes__;
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
@@ -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>
|
||||
6
test/app/package-lock.json
generated
6
test/app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
18
test/app/routes/4xx.html
Normal file
18
test/app/routes/4xx.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<:Head>
|
||||
<title>{{status}}</title>
|
||||
</:Head>
|
||||
|
||||
<Layout page='home'>
|
||||
<h1>Not found</h1>
|
||||
<p>{{error.message}}</p>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import Layout from './_components/Layout.html';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Layout
|
||||
}
|
||||
};
|
||||
</script>
|
||||
18
test/app/routes/5xx.html
Normal file
18
test/app/routes/5xx.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<:Head>
|
||||
<title>Internal server error</title>
|
||||
</:Head>
|
||||
|
||||
<Layout page='home'>
|
||||
<h1>Internal server error</h1>
|
||||
<p>{{error.message}}</p>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import Layout from './_components/Layout.html';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Layout
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,10 +1,11 @@
|
||||
<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 href='/redirect-from'>redirect</a></li>
|
||||
<li><a href='/blog/nope'>broken link</a></li>
|
||||
<li><a href='/blog/throw-an-error'>error link</a></li>
|
||||
<li><a rel=prefetch class='{{page === "blog" ? "selected" : ""}}' href='/blog'>blog</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
|
||||
<p>This is the 'about' page. There's not much here.</p>
|
||||
|
||||
<button on:click='goto("/blog/what-is-sapper")'>What is Sapper?</button>
|
||||
<button class='goto' on:click='goto("/blog/what-is-sapper")'>What is Sapper?</button>
|
||||
<button class='prefetch' on:click='goto("/blog/why-the-name")'>Why the name?</button>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import Layout from './_components/Layout.html';
|
||||
import { goto } from '../../../runtime/app.js';
|
||||
import { goto, prefetch } from '../../../runtime.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -20,7 +21,8 @@
|
||||
},
|
||||
|
||||
methods: {
|
||||
goto
|
||||
goto,
|
||||
prefetch
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -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]);
|
||||
|
||||
9
test/app/routes/api/delete/[id].js
Normal file
9
test/app/routes/api/delete/[id].js
Normal file
@@ -0,0 +1,9 @@
|
||||
export function del(req, res) {
|
||||
res.set({
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
res.end(JSON.stringify({
|
||||
id: req.params.id
|
||||
}));
|
||||
}
|
||||
@@ -59,8 +59,21 @@
|
||||
// is called [slug].html
|
||||
const { slug } = params;
|
||||
|
||||
return fetch(`/api/blog/${slug}`).then(r => r.json()).then(post => {
|
||||
return { post };
|
||||
if (slug === 'throw-an-error') {
|
||||
return this.error(500, 'something went wrong');
|
||||
}
|
||||
|
||||
return fetch(`/api/blog/${slug}`).then(r => {
|
||||
if (r.status === 200) {
|
||||
return r.json().then(post => ({ post }));
|
||||
this.error(r.status, '')
|
||||
}
|
||||
|
||||
if (r.status === 404) {
|
||||
this.error(404, 'Not found');
|
||||
} else {
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
|
||||
preload({ params, query }) {
|
||||
return fetch(`/api/blog`).then(r => r.json()).then(posts => {
|
||||
return fetch(`/api/blog/contents`).then(r => r.json()).then(posts => {
|
||||
return { posts };
|
||||
});
|
||||
}
|
||||
|
||||
15
test/app/routes/delete-test.html
Normal file
15
test/app/routes/delete-test.html
Normal 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>
|
||||
@@ -6,13 +6,15 @@
|
||||
<h1>Great success!</h1>
|
||||
|
||||
<figure>
|
||||
<img src='/great-success.png'>
|
||||
<img alt='borat' src='/great-success.png'>
|
||||
<figcaption>HIGH FIVE!</figcaption>
|
||||
</figure>
|
||||
|
||||
<p><strong>Try editing this file (routes/index.html) to test hot module reloading.</strong></p>
|
||||
</Layout>
|
||||
|
||||
<div class='hydrate-test'></div>
|
||||
|
||||
<style>
|
||||
h1, figure, p {
|
||||
text-align: center;
|
||||
|
||||
7
test/app/routes/redirect-from.html
Normal file
7
test/app/routes/redirect-from.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
export default {
|
||||
preload() {
|
||||
this.redirect(301, '/redirect-to');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
1
test/app/routes/redirect-to.html
Normal file
1
test/app/routes/redirect-to.html
Normal file
@@ -0,0 +1 @@
|
||||
<h1>redirected</h1>
|
||||
9
test/app/routes/show-url.html
Normal file
9
test/app/routes/show-url.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<p>URL is {{url}}</p>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
preload({ url }) {
|
||||
if (url) return { url };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
15
test/app/routes/slow-preload.html
Normal file
15
test/app/routes/slow-preload.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<h1>This page should never render</h1>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
preload() {
|
||||
return new Promise(fulfil => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.fulfil = fulfil;
|
||||
} else {
|
||||
fulfil({});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,24 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const app = require('express')();
|
||||
const compression = require('compression');
|
||||
const sapper = require('sapper');
|
||||
const static = require('serve-static');
|
||||
|
||||
const { PORT = 3000 } = process.env;
|
||||
|
||||
// this allows us to do e.g. `fetch('/api/blog')` on the server
|
||||
const fetch = require('node-fetch');
|
||||
global.fetch = (url, opts) => {
|
||||
if (url[0] === '/') url = `http://localhost:${PORT}${url}`;
|
||||
return fetch(url, opts);
|
||||
};
|
||||
|
||||
app.use(compression({ threshold: 0 }));
|
||||
|
||||
app.use(static('assets'));
|
||||
|
||||
app.use(sapper());
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`listening on port ${PORT}`);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
<!doctype>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width'>
|
||||
<meta name='theme-color' content='#aa1e1e'>
|
||||
|
||||
<link rel='manifest' href='/manifest.json'>
|
||||
<link rel='icon' type='image/png' href='/favicon.png'>
|
||||
|
||||
<!-- %sapper.status% is the HTTP status code, e.g. 404 -->
|
||||
<title>%sapper.status%</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
max-width: 800px;
|
||||
padding: 1em;
|
||||
margin: 0 auto;
|
||||
font-family: Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: rgb(170,30,30);
|
||||
border-bottom: 1px solid #aaa;
|
||||
padding: 0 0 0.5em 0;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: Menlo, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>%sapper.title%</h1>
|
||||
<p>Could not %sapper.method% %sapper.url%</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,47 +0,0 @@
|
||||
<!doctype>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width'>
|
||||
<meta name='theme-color' content='#aa1e1e'>
|
||||
|
||||
<link rel='manifest' href='/manifest.json'>
|
||||
<link rel='icon' type='image/png' href='/favicon.png'>
|
||||
|
||||
<title>%sapper.status%</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
max-width: 800px;
|
||||
padding: 1em;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: rgb(170,30,30);
|
||||
border-bottom: 1px solid #aaa;
|
||||
padding: 0 0 0.5em 0;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: Menlo, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.stack {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>%sapper.title%</h1>
|
||||
<pre>%sapper.error%</pre>
|
||||
<pre class='stack'>%sapper.stack%</pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +0,0 @@
|
||||
import { init } from '../../../runtime/app.js';
|
||||
|
||||
// `routes` is an array of route objects injected by Sapper
|
||||
init(document.querySelector('#sapper'), __routes__);
|
||||
|
||||
window.READY = true;
|
||||
@@ -1,8 +1,8 @@
|
||||
const config = require('../../webpack/config.js');
|
||||
const config = require('../../../webpack/config.js');
|
||||
const webpack = require('webpack');
|
||||
|
||||
module.exports = {
|
||||
entry: config.client.entry(),
|
||||
entry: './app/client.js',
|
||||
output: config.client.output(),
|
||||
resolve: {
|
||||
extensions: ['.js', '.html']
|
||||
@@ -1,12 +1,17 @@
|
||||
const config = require('../../webpack/config.js');
|
||||
const config = require('../../../webpack/config.js');
|
||||
const pkg = require('../package.json');
|
||||
const sapper_pkg = require('../../../package.json');
|
||||
|
||||
module.exports = {
|
||||
entry: config.server.entry(),
|
||||
entry: {
|
||||
'server': './app/server.js'
|
||||
},
|
||||
output: config.server.output(),
|
||||
target: 'node',
|
||||
resolve: {
|
||||
extensions: ['.js', '.html']
|
||||
},
|
||||
externals: Object.keys(pkg.dependencies).concat(Object.keys(sapper_pkg.dependencies)),
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
17
test/app/webpack/service-worker.config.js
Normal file
17
test/app/webpack/service-worker.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const path = require('path');
|
||||
const config = require('../../../webpack/config.js');
|
||||
const webpack = require('webpack');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
'service-worker': './app/service-worker.js'
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(`.sapper`),
|
||||
filename: '[name].js',
|
||||
chunkFilename: '[name].[id].[hash].js'
|
||||
},
|
||||
plugins: [
|
||||
!config.dev && new webpack.optimize.ModuleConcatenationPlugin()
|
||||
].filter(Boolean)
|
||||
};
|
||||
@@ -1,244 +1,485 @@
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
const Nightmare = require('nightmare');
|
||||
const express = require('express');
|
||||
const serve = require('serve-static');
|
||||
const Nightmare = require('nightmare');
|
||||
const getPort = require('get-port');
|
||||
const walkSync = require('walk-sync');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
run('production');
|
||||
run('development');
|
||||
|
||||
Nightmare.action('page', {
|
||||
title(done) {
|
||||
this.evaluate_now(() => document.querySelector('h1').textContent, done);
|
||||
}
|
||||
});
|
||||
|
||||
Nightmare.action('init', function(done) {
|
||||
this.evaluate_now(() => window.init(), done);
|
||||
});
|
||||
|
||||
function run(env) {
|
||||
describe(`env=${env}`, function () {
|
||||
this.timeout(5000);
|
||||
this.timeout(20000);
|
||||
|
||||
let PORT;
|
||||
let server;
|
||||
let proc;
|
||||
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(async () => {
|
||||
before(() => {
|
||||
process.chdir(path.resolve(__dirname, '../app'));
|
||||
|
||||
process.env.NODE_ENV = env;
|
||||
let exec_promise = Promise.resolve();
|
||||
|
||||
if (env === 'production') {
|
||||
const cli = path.resolve(__dirname, '../../cli/index.js');
|
||||
await exec(`${cli} build`);
|
||||
const cli = path.resolve(__dirname, '../../cli.js');
|
||||
exec_promise = exec(`node ${cli} export`);
|
||||
}
|
||||
|
||||
const resolved = require.resolve('../..');
|
||||
delete require.cache[resolved];
|
||||
const sapper = require(resolved);
|
||||
return exec_promise.then(() => {
|
||||
const resolved = require.resolve('../../middleware.js');
|
||||
delete require.cache[resolved];
|
||||
delete require.cache[require.resolve('../../core.js')]; // TODO remove this
|
||||
|
||||
PORT = await getPort();
|
||||
base = `http://localhost:${PORT}`;
|
||||
return require('get-port')();
|
||||
}).then(port => {
|
||||
base = `http://localhost:${port}`;
|
||||
|
||||
global.fetch = (url, opts) => {
|
||||
if (url[0] === '/') url = `${base}${url}`;
|
||||
return fetch(url, opts);
|
||||
};
|
||||
|
||||
let captured;
|
||||
capture = async fn => {
|
||||
const result = captured = [];
|
||||
await fn();
|
||||
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();
|
||||
proc = require('child_process').fork('.sapper/server.js', {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
NODE_ENV: env,
|
||||
PORT: port
|
||||
}
|
||||
});
|
||||
|
||||
let handler;
|
||||
|
||||
proc.on('message', message => {
|
||||
if (message.__sapper__) return;
|
||||
if (handler) handler(message);
|
||||
});
|
||||
|
||||
capture = fn => {
|
||||
return new Promise((fulfil, reject) => {
|
||||
const captured = [];
|
||||
|
||||
let start = Date.now();
|
||||
|
||||
handler = message => {
|
||||
if (message.type === 'ready') {
|
||||
fn().then(() => {
|
||||
proc.send({
|
||||
action: 'end'
|
||||
});
|
||||
}, reject);
|
||||
}
|
||||
|
||||
else if (message.type === 'done') {
|
||||
fulfil(captured);
|
||||
handler = null;
|
||||
}
|
||||
|
||||
else {
|
||||
captured.push(message);
|
||||
}
|
||||
};
|
||||
|
||||
proc.send({
|
||||
action: 'start'
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.close();
|
||||
middleware.close();
|
||||
proc.kill();
|
||||
|
||||
// give a chance to clean up
|
||||
return new Promise(fulfil => setTimeout(fulfil, 500));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
nightmare = new Nightmare();
|
||||
|
||||
nightmare.on('console', (type, ...args) => {
|
||||
console[type](...args);
|
||||
});
|
||||
|
||||
nightmare.on('page', (type, ...args) => {
|
||||
if (type === 'error') {
|
||||
console.error(args[1]);
|
||||
} else {
|
||||
console.warn(type, args);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
return nightmare.end();
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
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);
|
||||
}
|
||||
it('serves /', () => {
|
||||
return nightmare.goto(base).page.title().then(title => {
|
||||
assert.equal(title, 'Great success!');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await nightmare.end();
|
||||
it('serves /?', () => {
|
||||
return nightmare.goto(`${base}?`).page.title().then(title => {
|
||||
assert.equal(title, 'Great success!');
|
||||
});
|
||||
});
|
||||
|
||||
it('serves /', async () => {
|
||||
const title = await nightmare
|
||||
.goto(base)
|
||||
.evaluate(() => document.querySelector('h1').textContent);
|
||||
|
||||
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 static route', async () => {
|
||||
const title = await nightmare
|
||||
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 capture(() => nightmare.goto(base).init().wait(400))
|
||||
.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`)
|
||||
.evaluate(() => document.querySelector('h1').textContent);
|
||||
|
||||
assert.equal(title, 'About this site');
|
||||
});
|
||||
|
||||
it('serves dynamic route', async () => {
|
||||
const title = await nightmare
|
||||
.goto(`${base}/blog/what-is-sapper`)
|
||||
.evaluate(() => document.querySelector('h1').textContent);
|
||||
|
||||
assert.equal(title, 'What is Sapper?');
|
||||
});
|
||||
|
||||
it('navigates to a new page without reloading', async () => {
|
||||
await nightmare.goto(base).wait(() => window.READY).wait(100);
|
||||
|
||||
const requests = await capture(async () => {
|
||||
await nightmare.click('a[href="/about"]');
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await nightmare.path(),
|
||||
'/about'
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await nightmare.evaluate(() => document.title),
|
||||
'About'
|
||||
);
|
||||
|
||||
assert.deepEqual(requests.map(r => r.url), []);
|
||||
});
|
||||
|
||||
it('navigates programmatically', async () => {
|
||||
await nightmare
|
||||
.goto(`${base}/about`)
|
||||
.wait(() => window.READY)
|
||||
.click('button')
|
||||
.init()
|
||||
.click('.goto')
|
||||
.wait(() => window.location.pathname === '/blog/what-is-sapper')
|
||||
.wait(100);
|
||||
|
||||
assert.equal(
|
||||
await nightmare.evaluate(() => document.title),
|
||||
'What is Sapper?'
|
||||
);
|
||||
.wait(100)
|
||||
.title()
|
||||
.then(title => {
|
||||
assert.equal(title, 'What is Sapper?');
|
||||
});
|
||||
});
|
||||
|
||||
it('scrolls to active deeplink', async () => {
|
||||
const scrollY = await nightmare
|
||||
.goto(`${base}/blog/a-very-long-post#four`)
|
||||
.wait(() => window.READY)
|
||||
.wait(100)
|
||||
.evaluate(() => window.scrollY);
|
||||
it('prefetches programmatically', () => {
|
||||
return nightmare
|
||||
.goto(`${base}/about`)
|
||||
.init()
|
||||
.wait(200)
|
||||
.then(() => {
|
||||
return capture(() => {
|
||||
return nightmare
|
||||
.click('.prefetch')
|
||||
.wait(200);
|
||||
});
|
||||
})
|
||||
.then(requests => {
|
||||
assert.ok(!!requests.find(r => r.url === '/api/blog/why-the-name'));
|
||||
});
|
||||
});
|
||||
|
||||
assert.ok(scrollY > 0, scrollY);
|
||||
it('scrolls to active deeplink', () => {
|
||||
return nightmare
|
||||
.goto(`${base}/blog/a-very-long-post#four`)
|
||||
.init()
|
||||
.evaluate(() => window.scrollY)
|
||||
.then(scrollY => {
|
||||
assert.ok(scrollY > 0, scrollY);
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses prefetch promise', () => {
|
||||
return nightmare
|
||||
.goto(`${base}/blog`)
|
||||
.init().wait(300)
|
||||
.then(() => {
|
||||
return capture(() => {
|
||||
return nightmare
|
||||
.mouseover('[href="/blog/what-is-sapper"]')
|
||||
.wait(200);
|
||||
});
|
||||
})
|
||||
.then(mouseover_requests => {
|
||||
assert.ok(mouseover_requests.findIndex(r => r.url === '/api/blog/what-is-sapper') !== -1);
|
||||
|
||||
return capture(() => {
|
||||
return nightmare
|
||||
.click('[href="/blog/what-is-sapper"]')
|
||||
.wait(200);
|
||||
});
|
||||
})
|
||||
.then(click_requests => {
|
||||
assert.ok(click_requests.findIndex(r => r.url === '/api/blog/what-is-sapper') === -1);
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels navigation if subsequent navigation occurs during preload', () => {
|
||||
return nightmare
|
||||
.goto(base)
|
||||
.init()
|
||||
.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`)
|
||||
.init()
|
||||
.evaluate(() => document.querySelector('p').innerHTML)
|
||||
.end().then(html => {
|
||||
assert.equal(html, `URL is /show-url`);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls a delete handler', () => {
|
||||
return nightmare
|
||||
.goto(`${base}/delete-test`)
|
||||
.init()
|
||||
.click('.del')
|
||||
.wait(() => window.deleted)
|
||||
.evaluate(() => window.deleted.id)
|
||||
.then(id => {
|
||||
assert.equal(id, 42);
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates initial route', () => {
|
||||
return nightmare.goto(base)
|
||||
.wait('.hydrate-test')
|
||||
.evaluate(() => {
|
||||
window.el = document.querySelector('.hydrate-test');
|
||||
})
|
||||
.init()
|
||||
.evaluate(() => {
|
||||
return document.querySelector('.hydrate-test') === window.el;
|
||||
})
|
||||
.then(matches => {
|
||||
assert.ok(matches);
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects on server', () => {
|
||||
return nightmare.goto(`${base}/redirect-from`)
|
||||
.path()
|
||||
.then(path => {
|
||||
assert.equal(path, '/redirect-to');
|
||||
})
|
||||
.then(() => nightmare.page.title())
|
||||
.then(title => {
|
||||
assert.equal(title, 'redirected');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects in client', () => {
|
||||
return nightmare.goto(base)
|
||||
.wait('[href="/redirect-from"]')
|
||||
.click('[href="/redirect-from"]')
|
||||
.wait(200)
|
||||
.path()
|
||||
.then(path => {
|
||||
assert.equal(path, '/redirect-to');
|
||||
})
|
||||
.then(() => nightmare.page.title())
|
||||
.then(title => {
|
||||
assert.equal(title, 'redirected');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles 4xx error on server', () => {
|
||||
return nightmare.goto(`${base}/blog/nope`)
|
||||
.path()
|
||||
.then(path => {
|
||||
assert.equal(path, '/blog/nope');
|
||||
})
|
||||
.then(() => nightmare.page.title())
|
||||
.then(title => {
|
||||
assert.equal(title, 'Not found')
|
||||
});
|
||||
});
|
||||
|
||||
it('handles 4xx error in client', () => {
|
||||
return nightmare.goto(base)
|
||||
.init()
|
||||
.click('[href="/blog/nope"]')
|
||||
.wait(200)
|
||||
.path()
|
||||
.then(path => {
|
||||
assert.equal(path, '/blog/nope');
|
||||
})
|
||||
.then(() => nightmare.page.title())
|
||||
.then(title => {
|
||||
assert.equal(title, 'Not found');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-4xx error on server', () => {
|
||||
return nightmare.goto(`${base}/blog/throw-an-error`)
|
||||
.path()
|
||||
.then(path => {
|
||||
assert.equal(path, '/blog/throw-an-error');
|
||||
})
|
||||
.then(() => nightmare.page.title())
|
||||
.then(title => {
|
||||
assert.equal(title, 'Internal server error')
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-4xx error in client', () => {
|
||||
return nightmare.goto(base)
|
||||
.init()
|
||||
.click('[href="/blog/throw-an-error"]')
|
||||
.wait(200)
|
||||
.path()
|
||||
.then(path => {
|
||||
assert.equal(path, '/blog/throw-an-error');
|
||||
})
|
||||
.then(() => nightmare.page.title())
|
||||
.then(title => {
|
||||
assert.equal(title, 'Internal server error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('headers', () => {
|
||||
it('sets Content-Type and Link...preload headers', async () => {
|
||||
const { headers } = await get('/');
|
||||
it('sets Content-Type and Link...preload headers', () => {
|
||||
return capture(() => nightmare.goto(base).end()).then(requests => {
|
||||
const { headers } = requests[0];
|
||||
|
||||
assert.equal(
|
||||
headers['Content-Type'],
|
||||
'text/html'
|
||||
);
|
||||
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']
|
||||
);
|
||||
assert.ok(
|
||||
/<\/client\/[^/]+\/main\.js>;rel="preload";as="script", <\/client\/[^/]+\/_\.\d+\.js>;rel="preload";as="script"/.test(headers['link']),
|
||||
headers['link']
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (env === 'production') {
|
||||
describe('export', () => {
|
||||
it('export all pages', () => {
|
||||
const dest = path.resolve(__dirname, '../app/dist');
|
||||
|
||||
// Pages that should show up in the extraction directory.
|
||||
const expectedPages = [
|
||||
'index.html',
|
||||
'about/index.html',
|
||||
'slow-preload/index.html',
|
||||
|
||||
'blog/index.html',
|
||||
'blog/a-very-long-post/index.html',
|
||||
'blog/how-can-i-get-involved/index.html',
|
||||
'blog/how-is-sapper-different-from-next/index.html',
|
||||
'blog/how-to-use-sapper/index.html',
|
||||
'blog/what-is-sapper/index.html',
|
||||
'blog/why-the-name/index.html',
|
||||
|
||||
'api/blog/contents',
|
||||
'api/blog/a-very-long-post',
|
||||
'api/blog/how-can-i-get-involved',
|
||||
'api/blog/how-is-sapper-different-from-next',
|
||||
'api/blog/how-to-use-sapper',
|
||||
'api/blog/what-is-sapper',
|
||||
'api/blog/why-the-name',
|
||||
|
||||
'favicon.png',
|
||||
'global.css',
|
||||
'great-success.png',
|
||||
'manifest.json',
|
||||
'service-worker.js',
|
||||
'svelte-logo-192.png',
|
||||
'svelte-logo-512.png',
|
||||
];
|
||||
// Client scripts that should show up in the extraction directory.
|
||||
const expectedClientRegexes = [
|
||||
/client\/[^/]+\/_(\.\d+)?\.js/,
|
||||
/client\/[^/]+\/about(\.\d+)?\.js/,
|
||||
/client\/[^/]+\/blog_\$slug\$(\.\d+)?\.js/,
|
||||
/client\/[^/]+\/blog(\.\d+)?\.js/,
|
||||
/client\/[^/]+\/main(\.\d+)?\.js/,
|
||||
/client\/[^/]+\/show_url(\.\d+)?\.js/,
|
||||
/client\/[^/]+\/slow_preload(\.\d+)?\.js/,
|
||||
];
|
||||
const allPages = walkSync(dest);
|
||||
|
||||
expectedPages.forEach((expectedPage) => {
|
||||
assert.ok(allPages.includes(expectedPage),`Could not find page matching ${expectedPage}`);
|
||||
});
|
||||
|
||||
expectedClientRegexes.forEach((expectedRegex) => {
|
||||
// Ensure each client page regular expression matches at least one
|
||||
// generated page.
|
||||
let matched = false;
|
||||
for (const page of allPages) {
|
||||
if (expectedRegex.test(page)) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(matched, `Could not find client page matching ${expectedRegex}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const parts = cmd.split(' ');
|
||||
const proc = require('child_process').spawn(parts.shift(), parts);
|
||||
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
fulfil();
|
||||
proc.stdout.on('data', data => {
|
||||
process.stdout.write(data);
|
||||
});
|
||||
|
||||
proc.stderr.on('data', data => {
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
|
||||
proc.on('close', () => fulfil());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const create_routes = require('../../lib/utils/create_routes.js');
|
||||
const { create_routes } = require('../../core.js');
|
||||
|
||||
describe('create_routes', () => {
|
||||
it('sorts routes correctly', () => {
|
||||
const routes = create_routes(['index.html', 'about.html', '[wildcard].html', 'post/foo.html', 'post/[id].html', 'post/bar.html']);
|
||||
const routes = create_routes({
|
||||
files: ['index.html', 'about.html', 'post/f[xx].html', '[wildcard].html', 'post/foo.html', 'post/[id].html', 'post/bar.html', 'post/[id].json.js']
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
routes.map(r => r.file),
|
||||
[
|
||||
'index.html',
|
||||
'about.html',
|
||||
'post/foo.html',
|
||||
'post/bar.html',
|
||||
'post/f[xx].html',
|
||||
'post/[id].json.js',
|
||||
'post/[id].html',
|
||||
'about.html',
|
||||
'[wildcard].html',
|
||||
'index.html'
|
||||
'[wildcard].html'
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('generates params', () => {
|
||||
const routes = create_routes(['index.html', 'about.html', '[wildcard].html', 'post/[id].html']);
|
||||
const routes = create_routes({
|
||||
files: ['index.html', 'about.html', '[wildcard].html', 'post/[id].html']
|
||||
});
|
||||
|
||||
let file;
|
||||
let params;
|
||||
@@ -40,20 +44,26 @@ describe('create_routes', () => {
|
||||
});
|
||||
|
||||
it('ignores files and directories with leading underscores', () => {
|
||||
const routes = create_routes(['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html']);
|
||||
const routes = create_routes({
|
||||
files: ['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html']
|
||||
});
|
||||
|
||||
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']);
|
||||
const a = create_routes({
|
||||
files: ['foo/[bar].html', '[baz]/qux.html']
|
||||
});
|
||||
const b = create_routes({
|
||||
files: ['[baz]/qux.html', 'foo/[bar].html']
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
a.map(r => r.file),
|
||||
@@ -68,7 +78,90 @@ describe('create_routes', () => {
|
||||
|
||||
it('fails if routes are indistinguishable', () => {
|
||||
assert.throws(() => {
|
||||
create_routes(['[foo].html', '[bar]/index.html']);
|
||||
create_routes({
|
||||
files: ['[foo].html', '[bar]/index.html']
|
||||
});
|
||||
}, /The \[foo\].html and \[bar\]\/index.html routes clash/);
|
||||
|
||||
assert.throws(() => {
|
||||
create_routes({
|
||||
files: ['foo.html', 'foo.js']
|
||||
});
|
||||
}, /The foo.html and foo.js routes clash/);
|
||||
});
|
||||
|
||||
it('matches nested routes', () => {
|
||||
const route = create_routes({
|
||||
files: ['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({
|
||||
files: ['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({
|
||||
files: ['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
|
||||
});
|
||||
});
|
||||
|
||||
it('matches a dynamic part within a part', () => {
|
||||
const route = create_routes({
|
||||
files: ['things/[slug].json.js']
|
||||
})[0];
|
||||
|
||||
assert.deepEqual(route.exec('/things/foo.json'), {
|
||||
slug: 'foo'
|
||||
});
|
||||
});
|
||||
|
||||
it('matches multiple dynamic parts within a part', () => {
|
||||
const route = create_routes({
|
||||
files: ['things/[id]_[slug].json.js']
|
||||
})[0];
|
||||
|
||||
assert.deepEqual(route.exec('/things/someid_someslug.json'), {
|
||||
id: 'someid',
|
||||
slug: 'someslug'
|
||||
});
|
||||
});
|
||||
|
||||
it('fails if dynamic params are not separated', () => {
|
||||
assert.throws(() => {
|
||||
create_routes({
|
||||
files: ['[foo][bar].js']
|
||||
});
|
||||
}, /Invalid route \[foo\]\[bar\]\.js — parameters must be separated/);
|
||||
});
|
||||
});
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
const path = require('path');
|
||||
const { src, dest, dev, entry } = require('../lib/config.js');
|
||||
|
||||
module.exports = {
|
||||
dev,
|
||||
|
||||
client: {
|
||||
entry: () => {
|
||||
return {
|
||||
main: entry.client
|
||||
};
|
||||
},
|
||||
|
||||
output: () => {
|
||||
return {
|
||||
path: `${dest}/client`,
|
||||
filename: '[name].[hash].js',
|
||||
chunkFilename: '[name].[id].[hash].js',
|
||||
publicPath: '/client/'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
server: {
|
||||
entry: () => {
|
||||
return {
|
||||
main: entry.server
|
||||
}
|
||||
},
|
||||
|
||||
output: () => {
|
||||
return {
|
||||
path: `${dest}/server`,
|
||||
filename: '[name].[hash].js',
|
||||
chunkFilename: '[name].[id].[hash].js',
|
||||
libraryTarget: 'commonjs2'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
import 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000';
|
||||
Reference in New Issue
Block a user