Compare commits

...

62 Commits

Author SHA1 Message Date
Rich Harris
25f0d94595 -> v0.12.0 2018-05-05 10:00:58 -04:00
Rich Harris
8155df2e22 Merge branch 'gh-157' 2018-05-05 09:58:12 -04:00
Rich Harris
bb51470004 Merge pull request #259 from sveltejs/gh-157
switch to single App component model
2018-05-05 09:57:28 -04:00
Rich Harris
53446e2ec7 Merge branch 'master' into gh-157 2018-05-05 09:45:04 -04:00
Rich Harris
c4c09550eb Merge pull request #260 from sveltejs/another-sorting-bug
fix sorting
2018-05-05 09:43:16 -04:00
Rich Harris
da47fdec96 fix sorting 2018-05-05 09:38:24 -04:00
Rich Harris
971342ac7a set preloading: true when appropriate 2018-05-04 23:23:41 -04:00
Rich Harris
3becc1cbe2 error on incorrect init args 2018-05-04 23:06:10 -04:00
Rich Harris
8ee5346900 switch to single App component model (#157) 2018-05-04 22:46:41 -04:00
Rich Harris
9e4b79c6ff Merge pull request #258 from sveltejs/gh-208
exit with code 1 if build/export fails
2018-05-04 17:48:29 -04:00
Rich Harris
4ec1c65395 exit with code 1 if build/export fails - fixes #208 2018-05-04 17:42:37 -04:00
Rich Harris
c743d11b3b -> v0.11.1 2018-05-04 17:22:34 -04:00
Rich Harris
b525eb6480 get tests passing 2018-05-04 17:19:39 -04:00
Rich Harris
210d03fb06 Merge branch 'collision' of https://github.com/akihikodaki/sapper into akihikodaki-collision 2018-05-04 17:08:55 -04:00
Rich Harris
0685cc4cbe Merge branch 'master' of github.com:sveltejs/sapper 2018-05-04 17:08:47 -04:00
Rich Harris
9e2d0a7fbc Merge branch 'master' into collision 2018-05-04 17:05:18 -04:00
Rich Harris
a751a3b731 Merge pull request #254 from akihikodaki/dot_strict
Ignore files and directories with leading dots except .well-known
2018-05-04 17:04:27 -04:00
Rich Harris
bc7faeeab9 remove unused esm package 2018-05-04 16:55:57 -04:00
Rich Harris
a88c1de2f6 Merge pull request #256 from johnmuhl/esm
replace discontinued @std/esm package with esm
2018-05-04 16:53:35 -04:00
john muhl
a231795c4c replace discontinued @std/esm package with esm 2018-05-04 13:56:17 -05:00
Akihiko Odaki
ba7525c676 Ignore files and directories with leading dots except .well-known 2018-05-04 22:18:23 +09:00
Rich Harris
4843e9a40a -> v0.11.0 2018-05-03 23:51:04 -04:00
Rich Harris
ca4a1ca9b0 Merge pull request #251 from sveltejs/client-info
only save the bits of client_info we need
2018-05-03 23:49:30 -04:00
Rich Harris
ad7c872ee3 Merge pull request #250 from sveltejs/gh-240
implement --launcher
2018-05-03 23:49:06 -04:00
Rich Harris
4f98324a8a oops, missed one 2018-05-03 23:46:41 -04:00
Rich Harris
1fcf3f79ee only save the bits of client_info we need 2018-05-03 23:42:19 -04:00
Rich Harris
0b5741194a Merge pull request #205 from sveltejs/gh-140
prefetch on mouse stop
2018-05-03 23:31:08 -04:00
Rich Harris
9653d4c6ce Merge pull request #249 from sveltejs/gh-241
allow process.env.NODE_ENV to be overridden when building
2018-05-03 23:30:55 -04:00
Rich Harris
4fa5ed5e2c simplify 2018-05-03 23:30:09 -04:00
Rich Harris
f4eac2515f fix tests 2018-05-03 23:23:02 -04:00
Rich Harris
1a5364ae9d on second thoughts, default to build/index.js 2018-05-03 23:16:56 -04:00
Rich Harris
d7a9074c69 implement --launcher 2018-05-03 23:04:05 -04:00
Rich Harris
00adb53802 allow process.env.NODE_ENV to be overridden when building (#241) 2018-05-03 22:21:00 -04:00
Rich Harris
b10edddc96 cheat 2018-05-03 22:01:14 -04:00
Rich Harris
93b2d12438 Merge branch 'master' into gh-140 2018-05-03 21:54:52 -04:00
Rich Harris
7303e811be update tests, move test app to v2 2018-05-03 21:54:23 -04:00
Rich Harris
992d89027d Merge branch 'master' into collision 2018-05-03 21:44:28 -04:00
Rich Harris
3531cc587d -> v0.10.7 2018-05-03 21:42:50 -04:00
Rich Harris
562a91fa57 Merge pull request #245 from johnmuhl/patch-1
Include process.env in exporter server options
2018-05-03 21:40:50 -04:00
Rich Harris
93128a0156 Merge pull request #243 from akihikodaki/dot
Accept directory entries which starts with dot as routes
2018-05-03 21:39:46 -04:00
Rich Harris
d7a2132966 Merge pull request #234 from akihikodaki/master
Do not encode characters allowed in path when generating routes
2018-05-03 21:37:59 -04:00
John Muhl
56ac1aea9d match sapper start 2018-04-22 22:50:05 -05:00
John Muhl
37a9fb62e2 Include process.env in exporter server options 2018-04-22 20:29:47 -05:00
Rich Harris
a70e88b1f4 -> v0.10.6 2018-04-19 13:03:12 -04:00
Akihiko Odaki
6f9ce9ce85 Accept directory entries which starts with dot as routes
It allows to implement .well-known URIs.
2018-04-19 22:04:03 +09:00
Akihiko Odaki
917dd60cc3 Allow to have middleware for the path same with a HTML page
HTTP allows to change the type of the content to serve by Accept field in
the request. The middleware for the path same with a HTML page will
be inserted before the HTML renderer, and can take advantage of this
feature, using expressjs's "accepts" method, for example.
2018-04-15 23:11:08 +09:00
Akihiko Odaki
b13cc6f39a Do not encode characters allowed in path when generating routes
In RFC 3986, some characters not allowed in query, which encodeURIComponent
is designed for, is allowed in path.
A notable example is "@", which is commonly included in paths of social
profile pages. Such characters should not be encoded.

The new encoding function is conforming to the RFC.
2018-04-14 01:08:27 +09:00
Rich-Harris
2758382c68 -> v0.10.5 2018-04-06 18:32:44 -07:00
Rich Harris
dd7f1ff99c Merge pull request #231 from sveltejs/fix-missing-service-worker
fix missing service worker
2018-04-06 21:31:53 -04:00
Rich-Harris
45142cd037 fix missing service worker 2018-04-06 14:44:50 -07:00
Rich-Harris
ceb1caf1de -> v0.10.4 2018-04-03 21:43:30 -04:00
Rich Harris
7e263a3076 Merge pull request #227 from naturalethic/upgrade-chokidar-disable-globbing-issue-212
Upgrade chokidar disable globbing issue 212
2018-04-03 21:42:34 -04:00
Rich Harris
ec88d4a430 Remove unnecessary globbing pattern 2018-04-03 21:38:28 -04:00
Joshua Kifer
909ea72108 Update dev.ts 2018-04-03 14:04:08 -07:00
Joshua Kifer
cd09d75d99 Merge branch 'master' into upgrade-chokidar-disable-globbing-issue-212 2018-04-03 13:00:07 -07:00
Joshua Kifer
0e3abe489a Re-upgrade chokidar, disable globbing 2018-04-03 12:58:06 -07:00
Joshua Kifer
a5d141d2f1 Update (#1)
* Downgrade chokidar to 1.7.0

* -> v0.10.3
2018-04-03 12:49:55 -07:00
Rich-Harris
87eae6164b -> v0.10.3 2018-04-02 15:39:34 -04:00
Rich Harris
97e00f5a9c Merge pull request #226 from naturalethic/downgrade-chokidar
Downgrade chokidar to 1.7.0
2018-04-02 15:38:40 -04:00
Joshua Kifer
bd55558b5e Downgrade chokidar to 1.7.0 2018-04-02 12:24:57 -07:00
Rich Harris
a6b1527fd3 try using mousemove in tests 2018-03-18 21:53:13 -04:00
Rich Harris
c2f3a2aac0 prefetch on mouse stop (#140) 2018-03-18 21:41:47 -04:00
29 changed files with 636 additions and 372 deletions

View File

@@ -1,5 +1,43 @@
# sapper changelog # sapper changelog
## 0.12.0
* Each app has a single `<App>` component. See the [migration guide](https://sapper.svelte.technology/guide#0-11-to-0-12) for more information ([#157](https://github.com/sveltejs/sapper/issues/157))
* Process exits with error code 1 if build/export fails ([#208](https://github.com/sveltejs/sapper/issues/208))
## 0.11.1
* Limit routes with leading dots to `.well-known` URIs ([#252](https://github.com/sveltejs/sapper/issues/252))
* Allow server routes to sit in front of pages ([#236](https://github.com/sveltejs/sapper/pull/236))
## 0.11.0
* Create launcher file ([#240](https://github.com/sveltejs/sapper/issues/240))
* Only keep necessary parts of webpack stats ([#251](https://github.com/sveltejs/sapper/pull/251))
* Allow `NODE_ENV` to be overridden when building ([#241](https://github.com/sveltejs/sapper/issues/241))
## 0.10.7
* Allow routes to have a leading `.` ([#243](https://github.com/sveltejs/sapper/pull/243))
* Only encode necessary characters in routes ([#234](https://github.com/sveltejs/sapper/pull/234))
* Preserve existing `process.env` when exporting ([#245](https://github.com/sveltejs/sapper/pull/245))
## 0.10.6
* Fix error reporting in `sapper start`
## 0.10.5
* Fix missing service worker ([#231](https://github.com/sveltejs/sapper/pull/231))
## 0.10.4
* Upgrade chokidar, this time with a fix ([#227](https://github.com/sveltejs/sapper/pull/227))
## 0.10.3
* Downgrade chokidar ([#212](https://github.com/sveltejs/sapper/issues/212))
## 0.10.2 ## 0.10.2
* Attach `store` to error pages * Attach `store` to error pages

View File

@@ -1,6 +1,6 @@
{ {
"name": "sapper", "name": "sapper",
"version": "0.10.2", "version": "0.12.0",
"description": "Military-grade apps, engineered by Svelte", "description": "Military-grade apps, engineered by Svelte",
"main": "dist/middleware.ts.js", "main": "dist/middleware.ts.js",
"bin": { "bin": {
@@ -19,7 +19,7 @@
}, },
"dependencies": { "dependencies": {
"cheerio": "^1.0.0-rc.2", "cheerio": "^1.0.0-rc.2",
"chokidar": "^2.0.2", "chokidar": "^2.0.3",
"clorox": "^1.0.3", "clorox": "^1.0.3",
"cookie": "^0.3.1", "cookie": "^0.3.1",
"devalue": "^1.0.1", "devalue": "^1.0.1",
@@ -34,13 +34,12 @@
"rimraf": "^2.6.2", "rimraf": "^2.6.2",
"sade": "^1.4.0", "sade": "^1.4.0",
"sander": "^0.6.0", "sander": "^0.6.0",
"source-map-support": "^0.5.4", "source-map-support": "^0.5.5",
"tslib": "^1.9.0", "tslib": "^1.9.0",
"url-parse": "^1.2.0", "url-parse": "^1.2.0",
"webpack-format-messages": "^1.0.2" "webpack-format-messages": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@std/esm": "^0.25.3",
"@types/glob": "^5.0.34", "@types/glob": "^5.0.34",
"@types/mkdirp": "^0.5.2", "@types/mkdirp": "^0.5.2",
"@types/rimraf": "^2.0.2", "@types/rimraf": "^2.0.2",
@@ -48,21 +47,19 @@
"eslint": "^4.13.1", "eslint": "^4.13.1",
"eslint-plugin-import": "^2.8.0", "eslint-plugin-import": "^2.8.0",
"express": "^4.16.3", "express": "^4.16.3",
"get-port": "^3.2.0",
"mocha": "^5.0.4", "mocha": "^5.0.4",
"nightmare": "^3.0.0", "nightmare": "^3.0.0",
"npm-run-all": "^4.1.2", "npm-run-all": "^4.1.2",
"polka": "^0.3.4", "polka": "^0.3.4",
"rollup": "^0.57.0", "rollup": "^0.58.2",
"rollup-plugin-commonjs": "^9.1.0", "rollup-plugin-commonjs": "^9.1.3",
"rollup-plugin-json": "^2.3.0", "rollup-plugin-json": "^2.3.0",
"rollup-plugin-string": "^2.0.2", "rollup-plugin-string": "^2.0.2",
"rollup-plugin-typescript": "^0.8.1", "rollup-plugin-typescript": "^0.8.1",
"serve-static": "^1.13.2", "serve-static": "^1.13.2",
"svelte": "^1.57.4", "svelte": "^2.4.4",
"svelte-loader": "^2.5.1", "svelte-loader": "^2.9.0",
"ts-node": "^5.0.1", "typescript": "^2.8.3",
"typescript": "^2.6.2",
"walk-sync": "^0.3.2", "walk-sync": "^0.3.2",
"webpack": "^4.1.0" "webpack": "^4.1.0"
}, },

View File

@@ -21,10 +21,12 @@ prog.command('dev')
prog.command('build [dest]') prog.command('build [dest]')
.describe('Create a production-ready version of your app') .describe('Create a production-ready version of your app')
.action(async (dest = 'build') => { .option('-p, --port', 'Default of process.env.PORT', '3000')
.example(`build custom-dir -p 4567`)
.action(async (dest = 'build', opts: { port: string }) => {
console.log(`> Building...`); console.log(`> Building...`);
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = process.env.NODE_ENV || 'production';
process.env.SAPPER_DEST = dest; process.env.SAPPER_DEST = dest;
const start = Date.now(); const start = Date.now();
@@ -32,9 +34,23 @@ prog.command('build [dest]')
try { try {
const { build } = await import('./cli/build'); const { build } = await import('./cli/build');
await build(); await build();
console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(dest === 'build' ? 'npx sapper start' : `npx sapper start ${dest}`)} to run the app.`);
const launcher = path.resolve(dest, 'index.js');
fs.writeFileSync(launcher, `
// generated by sapper build at ${new Date().toISOString()}
process.env.NODE_ENV = process.env.NODE_ENV || 'production';
process.env.SAPPER_DEST = __dirname;
process.env.PORT = process.env.PORT || ${opts.port || 3000};
console.log('Starting server on port ' + process.env.PORT);
require('./server.js');
`.replace(/^\t+/gm, '').trim());
console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(`node ${dest}`)} to run the app.`);
} catch (err) { } catch (err) {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error'); console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
process.exit(1);
} }
}); });
@@ -68,6 +84,7 @@ prog.command('export [dest]')
console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(`npx serve ${dest}`)} to run the app.`); console.error(`\n> Finished in ${elapsed(start)}. Type ${clorox.bold.cyan(`npx serve ${dest}`)} to run the app.`);
} catch (err) { } catch (err) {
console.error(err ? err.details || err.stack || err.message || err : 'Unknown error'); console.error(err ? err.details || err.stack || err.message || err : 'Unknown error');
process.exit(1);
} }
}); });

View File

@@ -35,7 +35,9 @@ export async function build() {
const client_stats = await compile(client); const client_stats = await compile(client);
console.log(`${clorox.inverse(`\nbuilt client`)}`); console.log(`${clorox.inverse(`\nbuilt client`)}`);
console.log(client_stats.toString({ colors: true })); console.log(client_stats.toString({ colors: true }));
fs.writeFileSync(path.join(output, 'client_info.json'), JSON.stringify(client_stats.toJson())); fs.writeFileSync(path.join(output, 'client_info.json'), JSON.stringify({
assets: client_stats.toJson().assetsByChunkName
}));
const server_stats = await compile(server); const server_stats = await compile(server);
console.log(`${clorox.inverse(`\nbuilt server`)}`); console.log(`${clorox.inverse(`\nbuilt server`)}`);

View File

@@ -102,7 +102,7 @@ export async function dev(opts: { port: number, open: boolean }) {
const hot_update_server = create_hot_update_server(dev_port); const hot_update_server = create_hot_update_server(dev_port);
watch_files(`${locations.routes()}/**/*`, ['add', 'unlink'], () => { watch_files(locations.routes(), ['add', 'unlink'], () => {
const routes = create_routes(); const routes = create_routes();
create_main_manifests({ routes, dev_port }); create_main_manifests({ routes, dev_port });
}); });
@@ -263,7 +263,9 @@ export async function dev(opts: { port: number, open: boolean }) {
}, },
result: info => { result: info => {
fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify(info, null, ' ')); fs.writeFileSync(path.join(dir, 'client_info.json'), JSON.stringify({
assets: info.assetsByChunkName
}, null, ' '));
deferreds.client.fulfil(); deferreds.client.fulfil();
const client_files = info.assets.map((chunk: { name: string }) => `client/${chunk.name}`); const client_files = info.assets.map((chunk: { name: string }) => `client/${chunk.name}`);
@@ -311,7 +313,8 @@ function watch_files(pattern: string, events: string[], callback: () => void) {
const watcher = chokidar.watch(pattern, { const watcher = chokidar.watch(pattern, {
persistent: true, persistent: true,
ignoreInitial: true ignoreInitial: true,
disableGlobbing: true
}); });
events.forEach(event => { events.forEach(event => {

View File

@@ -35,12 +35,12 @@ export async function exporter(export_dir: string, { basepath = '' }) {
const proc = child_process.fork(path.resolve(`${build_dir}/server.js`), [], { const proc = child_process.fork(path.resolve(`${build_dir}/server.js`), [], {
cwd: process.cwd(), cwd: process.cwd(),
env: { env: Object.assign({
PORT: port, PORT: port,
NODE_ENV: 'production', NODE_ENV: 'production',
SAPPER_DEST: build_dir, SAPPER_DEST: build_dir,
SAPPER_EXPORT: 'true' SAPPER_EXPORT: 'true'
} }, process.env)
}); });
const seen = new Set(); const seen = new Set();

View File

@@ -11,13 +11,13 @@ export async function start(dir: string, opts: { port: number, open: boolean })
const server = path.resolve(dir, 'server.js'); const server = path.resolve(dir, 'server.js');
if (!fs.existsSync(server)) { if (!fs.existsSync(server)) {
console.log(clorox.bold.red(`> ${dir}/server.js does not exist — type ${clorox.bold.cyan(dir === 'build' ? `npx sapper build` : `npx sapper build ${dir}`)} to create it`)); console.log(`${clorox.bold.red(`> ${dir}/server.js does not exist — type ${clorox.bold.cyan(dir === 'build' ? `npx sapper build` : `npx sapper build ${dir}`)} to create it`)}`);
return; return;
} }
if (port) { if (port) {
if (!await ports.check(port)) { if (!await ports.check(port)) {
console.log(clorox.bold.red(`> Port ${port} is unavailable`)); console.log(`${clorox.bold.red(`> Port ${port} is unavailable`)}`);
return; return;
} }
} else { } else {

View File

@@ -45,11 +45,13 @@ function generate_client(routes: Route[], path_to_routes: string, dev_port?: num
export const routes = [ export const routes = [
${routes ${routes
.map(route => { .map(route => {
if (route.type !== 'page') { const page = route.handlers.find(({ type }) => type === 'page');
if (!page) {
return `{ pattern: ${route.pattern}, ignore: true }`; return `{ pattern: ${route.pattern}, ignore: true }`;
} }
const file = posixify(`${path_to_routes}/${route.file}`); const file = posixify(`${path_to_routes}/${page.file}`);
if (route.id === '_4xx' || route.id === '_5xx') { if (route.id === '_4xx' || route.id === '_5xx') {
return `{ error: '${route.id.slice(1)}', load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`; return `{ error: '${route.id.slice(1)}', load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`;
@@ -85,28 +87,36 @@ function generate_server(routes: Route[], path_to_routes: string) {
let code = ` let code = `
// This file is generated by Sapper — do not edit it! // This file is generated by Sapper — do not edit it!
${routes ${routes
.map(route => { .map(route =>
const file = posixify(`${path_to_routes}/${route.file}`); route.handlers
return route.type === 'page' .map(({ type, file }, index) => {
? `import ${route.id} from '${file}';` const module = posixify(`${path_to_routes}/${file}`);
: `import * as ${route.id} from '${file}';`;
}) return type === 'page'
? `import ${route.id}${index} from '${module}';`
: `import * as ${route.id}${index} from '${module}';`;
})
.join('\n')
)
.join('\n')} .join('\n')}
export const routes = [ export const routes = [
${routes ${routes
.map(route => { .map(route => {
const file = posixify(`../../${route.file}`); const handlers = route.handlers
.map(({ type }, index) =>
`{ type: '${type}', module: ${route.id}${index} }`)
.join(', ');
if (route.id === '_4xx' || route.id === '_5xx') { if (route.id === '_4xx' || route.id === '_5xx') {
return `{ error: '${route.id.slice(1)}', module: ${route.id} }`; return `{ error: '${route.id.slice(1)}', handlers: [${handlers}] }`;
} }
const params = route.params.length === 0 const params = route.params.length === 0
? '{}' ? '{}'
: `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; : `{ ${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} }`; return `{ id: '${route.id}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), handlers: [${handlers}] }`;
}) })
.join(',\n\t') .join(',\n\t')
} }

View File

@@ -3,10 +3,11 @@ import glob from 'glob';
import { locations } from '../config'; import { locations } from '../config';
import { Route } from '../interfaces'; import { Route } from '../interfaces';
export default function create_routes({ files } = { files: glob.sync('**/*.*', { cwd: locations.routes(), nodir: true }) }) { export default function create_routes({ files } = { files: glob.sync('**/*.*', { cwd: locations.routes(), dot: true, nodir: true }) }) {
const routes: Route[] = files const routes: Route[] = files
.filter((file: string) => !/(^|\/|\\)_/.test(file))
.map((file: string) => { .map((file: string) => {
if (/(^|\/|\\)_/.test(file)) return; if (/(^|\/|\\)(_|\.(?!well-known))/.test(file)) return;
if (/]\[/.test(file)) { if (/]\[/.test(file)) {
throw new Error(`Invalid route ${file} — parameters must be separated`); throw new Error(`Invalid route ${file} — parameters must be separated`);
@@ -16,66 +17,21 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', {
const parts = base.split('/'); // glob output is always posix-style const parts = base.split('/'); // glob output is always posix-style
if (parts[parts.length - 1] === 'index') parts.pop(); 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 = encodeURIComponent(parts[i].normalize()).replace(/%5B/g, '[').replace(/%5D/g, ']');
const dynamic = ~part.indexOf('[');
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 { return {
id, files: [file],
type: path.extname(file) === '.html' ? 'page' : 'route', base,
file, parts
pattern,
test,
exec,
parts,
params
}; };
}) })
.filter(Boolean) .filter(Boolean)
.sort((a: Route, b: Route) => { .filter((a, index, array) => {
if (a.file === '4xx.html' || a.file === '5xx.html') return -1; const found = array.slice(index + 1).find(b => a.base === b.base);
if (b.file === '4xx.html' || b.file === '5xx.html') return 1; if (found) found.files.push(...a.files);
return !found;
})
.sort((a, b) => {
if (a.parts[0] === '4xx' || a.parts[0] === '5xx') return -1;
if (b.parts[0] === '4xx' || b.parts[0] === '5xx') return 1;
const max = Math.max(a.parts.length, b.parts.length); const max = Math.max(a.parts.length, b.parts.length);
@@ -110,7 +66,76 @@ export default function create_routes({ files } = { files: glob.sync('**/*.*', {
} }
} }
throw new Error(`The ${a.file} and ${b.file} routes clash`); throw new Error(`The ${a.base} and ${b.base} routes clash`);
})
.map(({ files, base, parts }) => {
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 = encodeURI(parts[i].normalize()).replace(/\?/g, '%3F').replace(/#/g, '%23').replace(/%5B/g, '[').replace(/%5D/g, ']');
const dynamic = ~part.indexOf('[');
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,
handlers: files.map(file => ({
type: path.extname(file) === '.html' ? 'page' : 'route',
file
})).sort((a, b) => {
if (a.type === 'page' && b.type === 'route') {
return 1;
}
if (a.type === 'route' && b.type === 'page') {
return -1;
}
return 0;
}),
pattern,
test,
exec,
parts,
params
};
}); });
return routes; return routes;

View File

@@ -1,7 +1,9 @@
export type Route = { export type Route = {
id: string; id: string;
type: 'page' | 'route'; handlers: {
file: string; type: 'page' | 'route';
file: string;
}[];
pattern: RegExp; pattern: RegExp;
test: (url: string) => boolean; test: (url: string) => boolean;
exec: (url: string) => Record<string, string>; exec: (url: string) => Record<string, string>;

View File

@@ -20,14 +20,7 @@ type RouteObject = {
type: 'page' | 'route'; type: 'page' | 'route';
pattern: RegExp; pattern: RegExp;
params: (match: RegExpMatchArray) => Record<string, string>; params: (match: RegExpMatchArray) => Record<string, string>;
module: { module: Component;
render: (data: any, opts: { store: Store }) => {
head: string;
css: { code: string, map: any };
html: string
},
preload: (data: any) => any | Promise<any>
};
error?: string; error?: string;
} }
@@ -47,10 +40,24 @@ interface Req extends ClientRequest {
headers: Record<string, string>; headers: Record<string, string>;
} }
export default function middleware({ routes, store }: { interface Component {
render: (data: any, opts: { store: Store }) => {
head: string;
css: { code: string, map: any };
html: string
},
preload: (data: any) => any | Promise<any>
}
export default function middleware({ App, routes, store }: {
App: Component,
routes: RouteObject[], routes: RouteObject[],
store: (req: Req) => Store store: (req: Req) => Store
}) { }) {
if (!App) {
throw new Error(`As of 0.12, you must supply an App component to Sapper — see https://sapper.svelte.technology/guide#0-11-to-0-12 for more information`);
}
const output = locations.dest(); const output = locations.dest();
const client_info = JSON.parse(fs.readFileSync(path.join(output, 'client_info.json'), 'utf-8')); const client_info = JSON.parse(fs.readFileSync(path.join(output, 'client_info.json'), 'utf-8'));
@@ -90,7 +97,7 @@ export default function middleware({ routes, store }: {
cache_control: 'max-age=31536000' cache_control: 'max-age=31536000'
}), }),
get_route_handler(client_info.assetsByChunkName, routes, store) get_route_handler(client_info.assets, App, routes, store)
].filter(Boolean)); ].filter(Boolean));
return middleware; return middleware;
@@ -135,7 +142,7 @@ function serve({ prefix, pathname, cache_control }: {
const resolved = Promise.resolve(); const resolved = Promise.resolve();
function get_route_handler(chunks: Record<string, string>, routes: RouteObject[], store_getter: (req: Req) => Store) { function get_route_handler(chunks: Record<string, string>, App: Component, routes: RouteObject[], store_getter: (req: Req) => Store) {
const template = dev() const template = dev()
? () => fs.readFileSync(`${locations.app()}/template.html`, 'utf-8') ? () => fs.readFileSync(`${locations.app()}/template.html`, 'utf-8')
: (str => () => str)(fs.readFileSync(`${locations.dest()}/template.html`, 'utf-8')); : (str => () => str)(fs.readFileSync(`${locations.dest()}/template.html`, 'utf-8'));
@@ -143,195 +150,212 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
function handle_route(route: RouteObject, req: Req, res: ServerResponse) { function handle_route(route: RouteObject, req: Req, res: ServerResponse) {
req.params = route.params(route.pattern.exec(req.path)); req.params = route.params(route.pattern.exec(req.path));
const mod = route.module; const handlers = route.handlers[Symbol.iterator]();
if (route.type === 'page') { function next() {
res.setHeader('Content-Type', 'text/html'); try {
const { value: handler, done } = handlers.next();
// preload main.js and current route if (done) {
// TODO detect other stuff we can preload? images, CSS, fonts? handle_error(req, res, 404, 'Not found');
const link = [] return;
.concat(chunks.main, chunks[route.id]) }
.filter(file => !file.match(/\.map$/))
.map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`)
.join(', ');
res.setHeader('Link', link); const mod = handler.module;
const store = store_getter ? store_getter(req) : null; if (handler.type === 'page') {
const data = { params: req.params, query: req.query }; res.setHeader('Content-Type', 'text/html');
let redirect: { statusCode: number, location: string }; // preload main.js and current route
let error: { statusCode: number, message: Error | string }; // TODO detect other stuff we can preload? images, CSS, fonts?
const link = []
.concat(chunks.main, chunks[route.id])
.filter(file => !file.match(/\.map$/))
.map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`)
.join(', ');
Promise.resolve( res.setHeader('Link', link);
mod.preload ? mod.preload.call({
redirect: (statusCode: number, location: string) => {
redirect = { statusCode, location };
},
error: (statusCode: number, message: Error | string) => {
error = { statusCode, message };
},
fetch: (url: string, opts?: any) => {
const parsed = new URL(url, `http://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`);
if (opts) { const store = store_getter ? store_getter(req) : null;
opts = Object.assign({}, opts); const props = { params: req.params, query: req.query, path: req.path };
const include_cookies = ( let redirect: { statusCode: number, location: string };
opts.credentials === 'include' || let error: { statusCode: number, message: Error | string };
opts.credentials === 'same-origin' && parsed.origin === `http://127.0.0.1:${process.env.PORT}`
);
if (include_cookies) { Promise.resolve(
const cookies: Record<string, string> = {}; mod.preload ? mod.preload.call({
if (!opts.headers) opts.headers = {}; redirect: (statusCode: number, location: string) => {
redirect = { statusCode, location };
},
error: (statusCode: number, message: Error | string) => {
error = { statusCode, message };
},
fetch: (url: string, opts?: any) => {
const parsed = new URL(url, `http://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`);
const str = [] if (opts) {
.concat( opts = Object.assign({}, opts);
cookie.parse(req.headers.cookie || ''),
cookie.parse(opts.headers.cookie || ''),
cookie.parse(res.getHeader('Set-Cookie') || '')
)
.map(cookie => {
return Object.keys(cookie)
.map(name => `${name}=${encodeURIComponent(cookie[name])}`)
.join('; ');
})
.filter(Boolean)
.join(', ');
opts.headers.cookie = str; const include_cookies = (
} opts.credentials === 'include' ||
opts.credentials === 'same-origin' && parsed.origin === `http://127.0.0.1:${process.env.PORT}`
);
if (include_cookies) {
const cookies: Record<string, string> = {};
if (!opts.headers) opts.headers = {};
const str = []
.concat(
cookie.parse(req.headers.cookie || ''),
cookie.parse(opts.headers.cookie || ''),
cookie.parse(res.getHeader('Set-Cookie') || '')
)
.map(cookie => {
return Object.keys(cookie)
.map(name => `${name}=${encodeURIComponent(cookie[name])}`)
.join('; ');
})
.filter(Boolean)
.join(', ');
opts.headers.cookie = str;
}
}
return fetch(parsed.href, opts);
},
store
}, req) : {}
).catch(err => {
error = { statusCode: 500, message: err };
}).then(preloaded => {
if (redirect) {
res.statusCode = redirect.statusCode;
res.setHeader('Location', `${req.baseUrl}/${redirect.location}`);
res.end();
return;
} }
return fetch(parsed.href, opts); if (error) {
}, handle_error(req, res, error.statusCode, error.message);
store return;
}, req) : {} }
).catch(err => {
error = { statusCode: 500, message: err };
}).then(preloaded => {
if (redirect) {
res.statusCode = redirect.statusCode;
res.setHeader('Location', `${req.baseUrl}/${redirect.location}`);
res.end();
return; const serialized = {
} preloaded: mod.preload && try_serialize(preloaded),
store: store && try_serialize(store.get())
};
Object.assign(props, preloaded);
if (error) { const { html, head, css } = App.render({ Page: mod, props }, {
handle_error(req, res, error.statusCode, error.message); store
return; });
}
const serialized = { let scripts = []
preloaded: mod.preload && try_serialize(preloaded), .concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack
store: store && try_serialize(store.get()) .filter(file => !file.match(/\.map$/))
}; .map(file => `<script src='${req.baseUrl}/client/${file}'></script>`)
Object.assign(data, preloaded); .join('');
const { html, head, css } = mod.render(data, { let inline_script = `__SAPPER__={${[
store `baseUrl: "${req.baseUrl}"`,
}); serialized.preloaded && `preloaded: ${serialized.preloaded}`,
serialized.store && `store: ${serialized.store}`
].filter(Boolean).join(',')}};`;
let scripts = [] const has_service_worker = fs.existsSync(path.join(locations.dest(), 'service-worker.js'));
.concat(chunks.main) // chunks main might be an array. it might not! thanks, webpack if (has_service_worker) {
.filter(file => !file.match(/\.map$/)) inline_script += `if ('serviceWorker' in navigator) navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`;
.map(file => `<script src='${req.baseUrl}/client/${file}'></script>`) }
.join('');
let inline_script = `__SAPPER__={${[ const page = template()
`baseUrl: "${req.baseUrl}"`, .replace('%sapper.base%', `<base href="${req.baseUrl}/">`)
serialized.preloaded && `preloaded: ${serialized.preloaded}`, .replace('%sapper.scripts%', `<script>${inline_script}</script>${scripts}`)
serialized.store && `store: ${serialized.store}` .replace('%sapper.html%', html)
].filter(Boolean).join(',')}}` .replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
const has_service_worker = fs.existsSync(path.join(locations.dest(), 'service-worker.js')); res.end(page);
if (has_service_worker) {
`if ('serviceWorker' in navigator) navigator.serviceWorker.register('${req.baseUrl}/service-worker.js')`
}
const page = template() if (process.send) {
.replace('%sapper.base%', `<base href="${req.baseUrl}/">`) process.send({
.replace('%sapper.scripts%', `<script>${inline_script}</script>${scripts}`) __sapper__: true,
.replace('%sapper.html%', html) url: req.url,
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`) method: req.method,
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : '')); status: 200,
type: 'text/html',
res.end(page); body: page
});
if (process.send) { }
process.send({
__sapper__: true,
url: req.url,
method: req.method,
status: 200,
type: 'text/html',
body: page
}); });
} }
});
}
else { else {
const method = req.method.toLowerCase(); const method = req.method.toLowerCase();
// 'delete' cannot be exported from a module because it is a keyword, // 'delete' cannot be exported from a module because it is a keyword,
// so check for 'del' instead // so check for 'del' instead
const method_export = method === 'delete' ? 'del' : method; const method_export = method === 'delete' ? 'del' : method;
const handler = mod[method_export]; const handle_method = mod[method_export];
if (handler) { if (handle_method) {
if (process.env.SAPPER_EXPORT) { if (process.env.SAPPER_EXPORT) {
const { write, end, setHeader } = res; const { write, end, setHeader } = res;
const chunks: any[] = []; const chunks: any[] = [];
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
// intercept data so that it can be exported // intercept data so that it can be exported
res.write = function(chunk: any) { res.write = function(chunk: any) {
chunks.push(new Buffer(chunk)); chunks.push(new Buffer(chunk));
write.apply(res, arguments); write.apply(res, arguments);
}; };
res.setHeader = function(name: string, value: string) { res.setHeader = function(name: string, value: string) {
headers[name.toLowerCase()] = value; headers[name.toLowerCase()] = value;
setHeader.apply(res, arguments); setHeader.apply(res, arguments);
}; };
res.end = function(chunk?: any) { res.end = function(chunk?: any) {
if (chunk) chunks.push(new Buffer(chunk)); if (chunk) chunks.push(new Buffer(chunk));
end.apply(res, arguments); end.apply(res, arguments);
process.send({ process.send({
__sapper__: true, __sapper__: true,
url: req.url, url: req.url,
method: req.method, method: req.method,
status: res.statusCode, status: res.statusCode,
type: headers['content-type'], type: headers['content-type'],
body: Buffer.concat(chunks).toString() body: Buffer.concat(chunks).toString()
}); });
}; };
} }
const handle_bad_result = (err?: Error) => { const handle_bad_result = (err?: Error) => {
if (err) { if (err) {
console.error(err.stack); console.error(err.stack);
res.statusCode = 500; res.statusCode = 500;
res.end(err.message); res.end(err.message);
} else {
process.nextTick(next);
}
};
try {
handle_method(req, res, handle_bad_result);
} catch (err) {
handle_bad_result(err);
}
} else { } else {
handle_error(req, res, 404, 'Not found'); // no matching handler for method
process.nextTick(next);
} }
};
try {
handler(req, res, handle_bad_result);
} catch (err) {
handle_bad_result(err);
} }
} else { } catch (error) {
// no matching handler for method — 404 handle_error(req, res, 500, error);
handle_error(req, res, 404, 'Not found');
} }
} }
next();
} }
const not_found_route = routes.find((route: RouteObject) => route.error === '4xx'); const not_found_route = routes.find((route: RouteObject) => route.error === '4xx');
@@ -349,39 +373,62 @@ function get_route_handler(chunks: Record<string, string>, routes: RouteObject[]
? not_found_route ? not_found_route
: error_route; : error_route;
const title: string = not_found function render_page({ head, css, html }) {
? 'Not found' const page = template()
: `Internal server error: ${error.message}`; .replace('%sapper.base%', `<base href="${req.baseUrl}/">`)
.replace('%sapper.scripts%', `<script>__SAPPER__={baseUrl: "${req.baseUrl}"}</script><script src='${req.baseUrl}/client/${chunks.main}'></script>`)
.replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
const rendered = route ? route.module.render({ res.end(page);
status: statusCode, }
error
}, {
store: store_getter && store_getter(req)
}) : { head: '', css: null, html: title };
const { head, css, html } = rendered; function handle_notfound() {
const title: string = not_found
? 'Not found'
: `Internal server error: ${error.message}`;
const page = template() render_page({ head: '', css: null, html: title });
.replace('%sapper.base%', `<base href="${req.baseUrl}/">`) }
.replace('%sapper.scripts%', `<script>__SAPPER__={baseUrl: "${req.baseUrl}"}</script><script src='${req.baseUrl}/client/${chunks.main}'></script>`)
.replace('%sapper.html%', html)
.replace('%sapper.head%', `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`)
.replace('%sapper.styles%', (css && css.code ? `<style>${css.code}</style>` : ''));
res.end(page); if (route) {
const handlers = route.handlers[Symbol.iterator]();
function next() {
const { value: handler, done } = handlers.next();
if (done) {
handle_notfound();
} else if (handler.type === 'page') {
render_page(handler.module.render({
status: statusCode,
error
}, {
store: store_getter && store_getter(req)
}));
} else {
const handle_method = mod[method_export];
if (handle_method) {
handle_method(req, res, next);
} else {
next();
}
}
}
next();
} else {
handle_notfound();
}
} }
return function find_route(req: Req, res: ServerResponse) { return function find_route(req: Req, res: ServerResponse) {
try { for (const route of routes) {
for (const route of routes) { if (!route.error && route.pattern.test(req.path)) return handle_route(route, req, res);
if (!route.error && route.pattern.test(req.path)) return handle_route(route, req, res);
}
handle_error(req, res, 404, 'Not found');
} catch (error) {
handle_error(req, res, 500, error);
} }
handle_error(req, res, 404, 'Not found');
}; };
} }

View File

@@ -3,6 +3,7 @@ import { Component, ComponentConstructor, Params, Query, Route, RouteData, Scrol
const manifest = typeof window !== 'undefined' && window.__SAPPER__; const manifest = typeof window !== 'undefined' && window.__SAPPER__;
export let App: ComponentConstructor;
export let component: Component; export let component: Component;
let target: Node; let target: Node;
let store: Store; let store: Store;
@@ -27,10 +28,10 @@ function select_route(url: URL): Target {
if (url.origin !== window.location.origin) return null; if (url.origin !== window.location.origin) return null;
if (!url.pathname.startsWith(manifest.baseUrl)) return null; if (!url.pathname.startsWith(manifest.baseUrl)) return null;
const pathname = url.pathname.slice(manifest.baseUrl.length); const path = url.pathname.slice(manifest.baseUrl.length);
for (const route of routes) { for (const route of routes) {
const match = route.pattern.exec(pathname); const match = route.pattern.exec(path);
if (match) { if (match) {
if (route.ignore) return null; if (route.ignore) return null;
@@ -43,18 +44,24 @@ function select_route(url: URL): Target {
query[key] = value || true; query[key] = value || true;
}) })
} }
return { url, route, data: { params, query } }; return { url, route, props: { params, query, path } };
} }
} }
} }
let current_token: {}; let current_token: {};
function render(Component: ComponentConstructor, data: any, scroll: ScrollPosition, token: {}) { function render(Page: ComponentConstructor, props: any, scroll: ScrollPosition, token: {}) {
if (current_token !== token) return; if (current_token !== token) return;
const data = {
Page,
props,
preloading: false
};
if (component) { if (component) {
component.destroy(); component.set(data);
} else { } else {
// first load — remove SSR'd <head> contents // first load — remove SSR'd <head> contents
const start = document.querySelector('#sapper-head-start'); const start = document.querySelector('#sapper-head-start');
@@ -65,33 +72,39 @@ function render(Component: ComponentConstructor, data: any, scroll: ScrollPositi
detach(start); detach(start);
detach(end); detach(end);
} }
}
component = new Component({ component = new App({
target, target,
data, data,
store, store,
hydrate: !component hydrate: true
}); });
}
if (scroll) { if (scroll) {
window.scrollTo(scroll.x, scroll.y); window.scrollTo(scroll.x, scroll.y);
} }
} }
function prepare_route(Component: ComponentConstructor, data: RouteData) { function prepare_route(Page: ComponentConstructor, props: RouteData) {
let redirect: { statusCode: number, location: string } = null; let redirect: { statusCode: number, location: string } = null;
let error: { statusCode: number, message: Error | string } = null; let error: { statusCode: number, message: Error | string } = null;
if (!Component.preload) { if (!Page.preload) {
return { Component, data, redirect, error }; return { Page, props, redirect, error };
} }
if (!component && manifest.preloaded) { if (!component && manifest.preloaded) {
return { Component, data: Object.assign(data, manifest.preloaded), redirect, error }; return { Page, props: Object.assign(props, manifest.preloaded), redirect, error };
} }
return Promise.resolve(Component.preload.call({ if (component) {
component.set({
preloading: true
});
}
return Promise.resolve(Page.preload.call({
store, store,
fetch: (url: string, opts?: any) => window.fetch(url, opts), fetch: (url: string, opts?: any) => window.fetch(url, opts),
redirect: (statusCode: number, location: string) => { redirect: (statusCode: number, location: string) => {
@@ -100,7 +113,7 @@ function prepare_route(Component: ComponentConstructor, data: RouteData) {
error: (statusCode: number, message: Error | string) => { error: (statusCode: number, message: Error | string) => {
error = { statusCode, message }; error = { statusCode, message };
} }
}, data)).catch(err => { }, props)).catch(err => {
error = { statusCode: 500, message: err }; error = { statusCode: 500, message: err };
}).then(preloaded => { }).then(preloaded => {
if (error) { if (error) {
@@ -108,15 +121,15 @@ function prepare_route(Component: ComponentConstructor, data: RouteData) {
? errors['4xx'] ? errors['4xx']
: errors['5xx']; : errors['5xx'];
return route.load().then(({ default: Component }: { default: ComponentConstructor }) => { return route.load().then(({ default: Page }: { default: ComponentConstructor }) => {
const err = error.message instanceof Error ? error.message : new Error(error.message); const err = error.message instanceof Error ? error.message : new Error(error.message);
Object.assign(data, { status: error.statusCode, error: err }); Object.assign(props, { status: error.statusCode, error: err });
return { Component, data, redirect: null }; return { Page, props, redirect: null };
}); });
} }
Object.assign(data, preloaded) Object.assign(props, preloaded)
return { Component, data, redirect }; return { Page, props, redirect };
}); });
} }
@@ -136,18 +149,18 @@ function navigate(target: Target, id: number) {
const loaded = prefetching && prefetching.href === target.url.href ? const loaded = prefetching && prefetching.href === target.url.href ?
prefetching.promise : prefetching.promise :
target.route.load().then(mod => prepare_route(mod.default, target.data)); target.route.load().then(mod => prepare_route(mod.default, target.props));
prefetching = null; prefetching = null;
const token = current_token = {}; const token = current_token = {};
return loaded.then(({ Component, data, redirect }) => { return loaded.then(({ Page, props, redirect }) => {
if (redirect) { if (redirect) {
return goto(redirect.location, { replaceState: true }); return goto(redirect.location, { replaceState: true });
} }
render(Component, data, scroll_history[id], token); render(Page, props, scroll_history[id], token);
}); });
} }
@@ -208,21 +221,30 @@ function handle_popstate(event: PopStateEvent) {
let prefetching: { let prefetching: {
href: string; href: string;
promise: Promise<{ Component: ComponentConstructor, data: any }>; promise: Promise<{ Page: ComponentConstructor, props: any }>;
} = null; } = null;
export function prefetch(href: string) { export function prefetch(href: string) {
const selected = select_route(new URL(href, document.baseURI)); const selected = select_route(new URL(href, document.baseURI));
if (selected) { if (selected && (!prefetching || href !== prefetching.href)) {
prefetching = { prefetching = {
href, href,
promise: selected.route.load().then(mod => prepare_route(mod.default, selected.data)) promise: selected.route.load().then(mod => prepare_route(mod.default, selected.props))
}; };
} }
} }
function handle_touchstart_mouseover(event: MouseEvent | TouchEvent) { let mousemove_timeout: NodeJS.Timer;
function handle_mousemove(event: MouseEvent) {
clearTimeout(mousemove_timeout);
mousemove_timeout = setTimeout(() => {
trigger_prefetch(event);
}, 20);
}
function trigger_prefetch(event: MouseEvent | TouchEvent) {
const a: HTMLAnchorElement = <HTMLAnchorElement>findAnchor(<Node>event.target); const a: HTMLAnchorElement = <HTMLAnchorElement>findAnchor(<Node>event.target);
if (!a || a.rel !== 'prefetch') return; if (!a || a.rel !== 'prefetch') return;
@@ -231,12 +253,17 @@ function handle_touchstart_mouseover(event: MouseEvent | TouchEvent) {
let inited: boolean; let inited: boolean;
export function init(_target: Node, _routes: Route[], opts?: { store?: (data: any) => Store }) { export function init(opts: { App: ComponentConstructor, target: Node, routes: Route[], store?: (data: any) => Store }) {
target = _target; if (opts instanceof HTMLElement) {
routes = _routes.filter(r => !r.error); throw new Error(`The signature of init(...) has changed — see https://sapper.svelte.technology/guide#0-11-to-0-12 for more information`);
}
App = opts.App;
target = opts.target;
routes = opts.routes.filter(r => !r.error);
errors = { errors = {
'4xx': _routes.find(r => r.error === '4xx'), '4xx': opts.routes.find(r => r.error === '4xx'),
'5xx': _routes.find(r => r.error === '5xx') '5xx': opts.routes.find(r => r.error === '5xx')
}; };
if (opts && opts.store) { if (opts && opts.store) {
@@ -248,8 +275,8 @@ export function init(_target: Node, _routes: Route[], opts?: { store?: (data: an
window.addEventListener('popstate', handle_popstate); window.addEventListener('popstate', handle_popstate);
// prefetch // prefetch
window.addEventListener('touchstart', handle_touchstart_mouseover); window.addEventListener('touchstart', trigger_prefetch);
window.addEventListener('mouseover', handle_touchstart_mouseover); window.addEventListener('mousemove', handle_mousemove);
inited = true; inited = true;
} }

View File

@@ -3,11 +3,11 @@ import { Store } from '../interfaces';
export { Store }; export { Store };
export type Params = Record<string, string>; export type Params = Record<string, string>;
export type Query = Record<string, string | true>; export type Query = Record<string, string | true>;
export type RouteData = { params: Params, query: Query }; export type RouteData = { params: Params, query: Query, path: string };
export interface ComponentConstructor { export interface ComponentConstructor {
new (options: { target: Node, data: any, store: Store, hydrate: boolean }): Component; new (options: { target: Node, data: any, store: Store, hydrate: boolean }): Component;
preload: (data: { params: Params, query: Query }) => Promise<any>; preload: (props: { params: Params, query: Query }) => Promise<any>;
}; };
export interface Component { export interface Component {
@@ -30,5 +30,5 @@ export type ScrollPosition = {
export type Target = { export type Target = {
url: URL; url: URL;
route: Route; route: Route;
data: RouteData; props: RouteData;
}; };

6
test/app/app/App.html Normal file
View File

@@ -0,0 +1,6 @@
{#if preloading}
<progress class='preloading-progress' value=0.5/>
{/if}
<svelte:component this={Page} {...props}/>

View File

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

View File

@@ -5,6 +5,7 @@ import serve from 'serve-static';
import sapper from '../../../dist/middleware.ts.js'; import sapper from '../../../dist/middleware.ts.js';
import { Store } from 'svelte/store.js'; import { Store } from 'svelte/store.js';
import { routes } from './manifest/server.js'; import { routes } from './manifest/server.js';
import App from './App.html'
let pending; let pending;
let ended; let ended;
@@ -86,6 +87,7 @@ const middlewares = [
}, },
sapper({ sapper({
App,
routes, routes,
store: () => { store: () => {
return new Store({ return new Store({

View File

@@ -1,6 +1,6 @@
<:Head> <svelte:head>
<title>{{status}}</title> <title>{status}</title>
</:Head> </svelte:head>
<h1>Not found</h1> <h1>Not found</h1>
<p>{{error.message}}</p> <p>{error.message}</p>

View File

@@ -1,6 +1,6 @@
<:Head> <svelte:head>
<title>Internal server error</title> <title>Internal server error</title>
</:Head> </svelte:head>
<h1>Internal server error</h1> <h1>Internal server error</h1>
<p>{{error.message}}</p> <p>{error.message}</p>

View File

@@ -1,6 +1,6 @@
<:Head> <svelte:head>
<title>About</title> <title>About</title>
</:Head> </svelte:head>
<h1>About this site</h1> <h1>About this site</h1>

View File

@@ -1,11 +1,11 @@
<:Head> <svelte:head>
<title>{{post.title}}</title> <title>{post.title}</title>
</:Head> </svelte:head>
<h1>{{post.title}}</h1> <h1>{post.title}</h1>
<div class='content'> <div class='content'>
{{{post.html}}} {@html post.html}
</div> </div>
<script> <script>

View File

@@ -1,17 +1,17 @@
<:Head> <svelte:head>
<title>Blog</title> <title>Blog</title>
</:Head> </svelte:head>
<h1>Recent posts</h1> <h1>Recent posts</h1>
<ul> <ul>
{{#each posts as post}} {#each posts as post}
<!-- we're using the non-standard `rel=prefetch` attribute to <!-- we're using the non-standard `rel=prefetch` attribute to
tell Sapper to load the data for the page as soon as tell Sapper to load the data for the page as soon as
the user hovers over the link or taps it, instead of the user hovers over the link or taps it, instead of
waiting for the 'click' event --> waiting for the 'click' event -->
<li><a rel='prefetch' href='blog/{{post.slug}}'>{{post.title}}</a></li> <li><a rel='prefetch' href='blog/{post.slug}'>{post.title}</a></li>
{{/each}} {/each}
</ul> </ul>
<script> <script>

View File

@@ -1,4 +1,4 @@
<h1>{{message}}</h1> <h1>{message}</h1>
<script> <script>
export default { export default {

View File

@@ -1,6 +1,6 @@
<:Head> <svelte:head>
<title>Sapper project template</title> <title>Sapper project template</title>
</:Head> </svelte:head>
<h1>Great success!</h1> <h1>Great success!</h1>
@@ -11,7 +11,7 @@
<a href='blog/nope'>broken link</a> <a href='blog/nope'>broken link</a>
<a href='blog/throw-an-error'>error link</a> <a href='blog/throw-an-error'>error link</a>
<a href='credentials?creds=include'>credentials</a> <a href='credentials?creds=include'>credentials</a>
<a rel=prefetch class='{{page === "blog" ? "selected" : ""}}' href='blog'>blog</a> <a rel=prefetch class='{page === "blog" ? "selected" : ""}' href='blog'>blog</a>
<div class='hydrate-test'></div> <div class='hydrate-test'></div>

View File

@@ -1,4 +1,4 @@
<h1>{{foo.bar()}}</h1> <h1>{foo.bar()}</h1>
<script> <script>
export default { export default {

View File

@@ -1,4 +1,4 @@
<h1>{{set.has('x')}}</h1> <h1>{set.has('x')}</h1>
<script> <script>
export default { export default {

View File

@@ -1,4 +1,4 @@
<p>URL is {{url}}</p> <p>URL is {url}</p>
<script> <script>
export default { export default {

View File

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

View File

@@ -1,3 +1,4 @@
const fs = require('fs');
const path = require('path'); const path = require('path');
const assert = require('assert'); const assert = require('assert');
const Nightmare = require('nightmare'); const Nightmare = require('nightmare');
@@ -38,6 +39,7 @@ describe('sapper', function() {
rimraf.sync('export'); rimraf.sync('export');
rimraf.sync('build'); rimraf.sync('build');
rimraf.sync('.sapper'); rimraf.sync('.sapper');
rimraf.sync('start.js');
this.timeout(process.env.CI ? 30000 : 10000); this.timeout(process.env.CI ? 30000 : 10000);
@@ -148,7 +150,7 @@ function run({ mode, basepath = '' }) {
before(() => { before(() => {
const promise = mode === 'production' const promise = mode === 'production'
? exec(`node ${cli} build`).then(() => ports.find(3000)) ? exec(`node ${cli} build -l`).then(() => ports.find(3000))
: ports.find(3000).then(port => { : ports.find(3000).then(port => {
exec(`node ${cli} dev`); exec(`node ${cli} dev`);
return ports.wait(port).then(() => port); return ports.wait(port).then(() => port);
@@ -160,6 +162,10 @@ function run({ mode, basepath = '' }) {
const dir = mode === 'production' ? 'build' : '.sapper'; const dir = mode === 'production' ? 'build' : '.sapper';
if (mode === 'production') {
assert.ok(fs.existsSync('build/index.js'));
}
proc = require('child_process').fork(`${dir}/server.js`, { proc = require('child_process').fork(`${dir}/server.js`, {
cwd: process.cwd(), cwd: process.cwd(),
env: { env: {
@@ -301,14 +307,17 @@ function run({ mode, basepath = '' }) {
}); });
}); });
it('reuses prefetch promise', () => { it.skip('reuses prefetch promise', () => {
return nightmare return nightmare
.goto(`${base}/blog`) .goto(`${base}/blog`)
.init() .init()
.then(() => { .then(() => {
return capture(() => { return capture(() => {
return nightmare return nightmare
.mouseover('[href="blog/what-is-sapper"]') .evaluate(() => {
const a = document.querySelector('[href="blog/what-is-sapper"]');
a.dispatchEvent(new MouseEvent('mousemove'));
})
.wait(200); .wait(200);
}); });
}) })
@@ -559,6 +568,35 @@ function run({ mode, basepath = '' }) {
assert.equal(title, 'woohoo!'); assert.equal(title, 'woohoo!');
}); });
}); });
it('includes service worker', () => {
return nightmare.goto(base).page.html().then(html => {
assert.ok(html.indexOf('service-worker.js') !== -1);
});
});
it('sets preloading true when appropriate', () => {
return nightmare
.goto(base)
.init()
.click('a[href="slow-preload"]')
.wait(100)
.evaluate(() => {
const progress = document.querySelector('progress');
return !!progress;
})
.then(hasProgressIndicator => {
assert.ok(hasProgressIndicator);
})
.then(() => nightmare.evaluate(() => window.fulfil()))
.then(() => nightmare.evaluate(() => {
const progress = document.querySelector('progress');
return !!progress;
}))
.then(hasProgressIndicator => {
assert.ok(!hasProgressIndicator);
});
});
}); });
describe('headers', () => { describe('headers', () => {

View File

@@ -2,13 +2,54 @@ const assert = require('assert');
const { create_routes } = require('../../dist/core.ts.js'); const { create_routes } = require('../../dist/core.ts.js');
describe('create_routes', () => { describe('create_routes', () => {
it('sorts handlers correctly', () => {
const routes = create_routes({
files: ['foo.html', 'foo.js']
});
assert.deepEqual(
routes.map(r => r.handlers),
[
[
{
type: 'route',
file: 'foo.js'
},
{
type: 'page',
file: 'foo.html'
}
]
]
)
});
it('encodes characters not allowed in path', () => {
const routes = create_routes({
files: [
'"',
'#',
'?'
]
});
assert.deepEqual(
routes.map(r => r.pattern),
[
/^\/%22\/?$/,
/^\/%23\/?$/,
/^\/%3F\/?$/
]
);
});
it('sorts routes correctly', () => { it('sorts routes correctly', () => {
const routes = create_routes({ 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'] 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( assert.deepEqual(
routes.map(r => r.file), routes.map(r => r.handlers[0].file),
[ [
'index.html', 'index.html',
'about.html', 'about.html',
@@ -40,7 +81,7 @@ describe('create_routes', () => {
}); });
assert.deepEqual( assert.deepEqual(
routes.map(r => r.file), routes.map(r => r.handlers[0].file),
[ [
'4xx.html', '4xx.html',
'5xx.html', '5xx.html',
@@ -76,7 +117,7 @@ describe('create_routes', () => {
}); });
assert.deepEqual( assert.deepEqual(
routes.map(r => r.file), routes.map(r => r.handlers[0].file),
[ [
'4xx.html', '4xx.html',
'5xx.html', '5xx.html',
@@ -106,7 +147,7 @@ describe('create_routes', () => {
for (let i = 0; i < routes.length; i += 1) { for (let i = 0; i < routes.length; i += 1) {
const route = routes[i]; const route = routes[i];
if (params = route.exec('/post/123')) { if (params = route.exec('/post/123')) {
file = route.file; file = route.handlers[0].file;
break; break;
} }
} }
@@ -123,7 +164,7 @@ describe('create_routes', () => {
}); });
assert.deepEqual( assert.deepEqual(
routes.map(r => r.file), routes.map(r => r.handlers[0].file),
[ [
'index.html', 'index.html',
'e/f/g/h.html' 'e/f/g/h.html'
@@ -131,6 +172,17 @@ describe('create_routes', () => {
); );
}); });
it('ignores files and directories with leading dots except .well-known', () => {
const routes = create_routes({
files: ['.well-known', '.unknown']
});
assert.deepEqual(
routes.map(r => r.handlers[0].file),
['.well-known']
);
});
it('matches /foo/:bar before /:baz/qux', () => { it('matches /foo/:bar before /:baz/qux', () => {
const a = create_routes({ const a = create_routes({
files: ['foo/[bar].html', '[baz]/qux.html'] files: ['foo/[bar].html', '[baz]/qux.html']
@@ -140,12 +192,12 @@ describe('create_routes', () => {
}); });
assert.deepEqual( assert.deepEqual(
a.map(r => r.file), a.map(r => r.handlers[0].file),
['foo/[bar].html', '[baz]/qux.html'] ['foo/[bar].html', '[baz]/qux.html']
); );
assert.deepEqual( assert.deepEqual(
b.map(r => r.file), b.map(r => r.handlers[0].file),
['foo/[bar].html', '[baz]/qux.html'] ['foo/[bar].html', '[baz]/qux.html']
); );
}); });
@@ -155,13 +207,7 @@ describe('create_routes', () => {
create_routes({ create_routes({
files: ['[foo].html', '[bar]/index.html'] files: ['[foo].html', '[bar]/index.html']
}); });
}, /The \[foo\].html and \[bar\]\/index.html routes clash/); }, /The \[foo\] and \[bar\]\/index routes clash/);
assert.throws(() => {
create_routes({
files: ['foo.html', 'foo.js']
});
}, /The foo.html and foo.js routes clash/);
}); });
it('matches nested routes', () => { it('matches nested routes', () => {
@@ -184,7 +230,7 @@ describe('create_routes', () => {
}); });
assert.deepEqual( assert.deepEqual(
routes.map(r => r.file), routes.map(r => r.handlers[0].file),
['settings.html', 'settings/[submenu].html'] ['settings.html', 'settings/[submenu].html']
); );
}); });