Nested routes

Fixes #262
This commit is contained in:
Rich Harris
2018-07-22 21:00:37 -04:00
committed by GitHub
parent b75ae7ba96
commit 58de0f9c99
67 changed files with 1156 additions and 777 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
<span>z: {segment} {count}</span>
<a href="foo/bar/qux"></a>
<script>
import counts from '../_counts.js';
export default {
preload() {
return {
count: counts.z += 1
};
},
oncreate() {
this.set({
segment: this.get().params.z
});
}
};
</script>

View File

@@ -0,0 +1,20 @@
<span>y: {segment} {count}</span>
<svelte:component this={child.component} {...child.props}/>
<script>
import counts from '../_counts.js';
export default {
preload() {
return {
count: counts.y += 1
};
},
oncreate() {
this.set({
segment: this.get().params.y
});
}
};
</script>

View File

@@ -0,0 +1,5 @@
export default {
x: process.browser ? 1 : 0,
y: process.browser ? 1 : 0,
z: process.browser ? 1 : 0
};

View File

@@ -0,0 +1,20 @@
<span>x: {segment} {count}</span>
<svelte:component this={child.component} {...child.props}/>
<script>
import counts from './_counts.js';
export default {
preload() {
return {
count: counts.x += 1
};
},
oncreate() {
this.set({
segment: this.get().params.x
});
}
};
</script>

View File

@@ -0,0 +1,15 @@
{#if preloading}
<progress class='preloading-progress' value=0.5/>
{/if}
<svelte:component this={child.component} {rootPreloadFunctionRan} {...child.props}/>
<script>
export default {
preload() {
return {
rootPreloadFunctionRan: true
};
}
};
</script>

View File

@@ -0,0 +1 @@
<h1>it works</h1>

View File

@@ -0,0 +1 @@
<h1>root preload function ran: {rootPreloadFunctionRan}</h1>

View File

@@ -0,0 +1 @@
<svelte:component this={child.component} {...child.props}/>

View File

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

View File

@@ -32,6 +32,8 @@ Nightmare.action('prefetchRoutes', function(done) {
const cli = path.resolve(__dirname, '../../sapper');
const wait = ms => new Promise(f => setTimeout(f, ms));
describe('sapper', function() {
process.chdir(path.resolve(__dirname, '../app'));
@@ -41,7 +43,7 @@ describe('sapper', function() {
rimraf.sync('.sapper');
rimraf.sync('start.js');
this.timeout(process.env.CI ? 30000 : 10000);
this.timeout(process.env.CI ? 30000 : 15000);
// TODO reinstate dev tests
// run({
@@ -97,13 +99,12 @@ describe('sapper', function() {
];
// 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/,
/client\/[^/]+\/index(\.\d+)?\.js/,
/client\/[^/]+\/about(\.\d+)?\.js/,
/client\/[^/]+\/blog_\$slug(\.\d+)?\.js/,
/client\/[^/]+\/blog(\.\d+)?\.js/,
/client\/[^/]+\/slow\$45preload(\.\d+)?\.js/,
];
const allPages = walkSync(dest);
@@ -266,8 +267,9 @@ function run({ mode, basepath = '' }) {
})
.then(requests => {
assert.deepEqual(requests.map(r => r.url), []);
return nightmare.path();
})
.then(() => wait(100))
.then(() => nightmare.path())
.then(path => {
assert.equal(path, `${basepath}/about`);
return nightmare.title();
@@ -367,16 +369,6 @@ function run({ mode, basepath = '' }) {
});
});
it('passes entire request object to preload', () => {
return nightmare
.goto(`${base}/show-url`)
.init()
.evaluate(() => document.querySelector('p').innerHTML)
.then(html => {
assert.equal(html, `URL is /show-url`);
});
});
it('calls a delete handler', () => {
return nightmare
.goto(`${base}/delete-test`)
@@ -629,6 +621,49 @@ function run({ mode, basepath = '' }) {
assert.equal(html.indexOf('%sapper'), -1);
});
});
it('only recreates components when necessary', () => {
return nightmare
.goto(`${base}/foo/bar/baz`)
.init()
.evaluate(() => document.querySelector('#sapper').textContent)
.then(text => {
assert.deepEqual(text.split('\n').filter(Boolean), [
'x: foo 1',
'y: bar 1',
'z: baz 1'
]);
return nightmare.click(`a`)
.then(() => wait(100))
.then(() => {
return nightmare.evaluate(() => document.querySelector('#sapper').textContent);
});
})
.then(text => {
assert.deepEqual(text.split('\n').filter(Boolean), [
'x: foo 1',
'y: bar 1',
'z: qux 2'
]);
});
});
it('uses a fallback index component if none is provided', () => {
return nightmare.goto(`${base}/missing-index/ok`)
.page.title()
.then(title => {
assert.equal(title, 'it works');
});
});
it('runs preload in root component', () => {
return nightmare.goto(`${base}/preload-root`)
.page.title()
.then(title => {
assert.equal(title, 'root preload function ran: true');
});
});
});
describe('headers', () => {
@@ -641,7 +676,7 @@ function run({ mode, basepath = '' }) {
'text/html'
);
const str = ['main', '_\\.\\d+']
const str = ['main', '.+?\\.\\d+']
.map(file => {
return `<${basepath}/client/[^/]+/${file}\\.js>;rel="preload";as="script"`;
})

View File

@@ -1,366 +0,0 @@
const assert = require('assert');
const { create_routes } = require('../../dist/core.ts.js');
describe('create_routes', () => {
it('encodes characters not allowed in path', () => {
const { server_routes } = create_routes({
files: [
'"',
'#',
'?'
]
});
assert.deepEqual(
server_routes.map(r => r.pattern),
[
/^\/%22\/?$/,
/^\/%23\/?$/,
/^\/%3F\/?$/
]
);
});
it('sorts routes correctly', () => {
const { pages, server_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',
'post/[id([0-9-a-z]{3,})].html',
]
});
assert.deepEqual(
pages.map(r => r.file),
[
'index.html',
'about.html',
'post/bar.html',
'post/foo.html',
'post/f[xx].html',
'post/[id([0-9-a-z]{3,})].html', // RegExp is more specific
'post/[id].html',
'[wildcard].html'
]
);
assert.deepEqual(
server_routes.map(r => r.file),
[
'post/[id].json.js'
]
);
});
it('distinguishes and sorts regexp routes correctly', () => {
const { pages } = create_routes({
files: [
'[slug].html',
'[slug([a-z]{2})].html',
'[slug([0-9-a-z]{3,})].html',
]
});
assert.deepEqual(
pages.map(r => r.file),
[
'[slug([0-9-a-z]{3,})].html',
'[slug([a-z]{2})].html',
'[slug].html',
]
);
});
it('prefers index page to nested route', () => {
let { pages, server_routes } = create_routes({
files: [
'api/examples/[slug].js',
'api/examples/index.js',
'blog/[slug].html',
'api/gists/[id].js',
'api/gists/index.js',
'_error.html',
'blog/index.html',
'blog/rss.xml.js',
'guide/index.html',
'index.html'
]
});
assert.deepEqual(
pages.map(r => r.file),
[
'_error.html',
'index.html',
'guide/index.html',
'blog/index.html',
'blog/[slug].html'
]
);
assert.deepEqual(
server_routes.map(r => r.file),
[
'blog/rss.xml.js',
'api/examples/index.js',
'api/examples/[slug].js',
'api/gists/index.js',
'api/gists/[id].js',
]
);
({ pages, server_routes } = create_routes({
files: [
'_error.html',
'api/blog/[slug].js',
'api/blog/index.js',
'api/guide/contents.js',
'api/guide/index.js',
'blog/[slug].html',
'blog/index.html',
'blog/rss.xml.js',
'gist/[id].js',
'gist/create.js',
'guide/index.html',
'index.html',
'repl/index.html'
]
}));
assert.deepEqual(
pages.map(r => r.file),
[
'_error.html',
'index.html',
'guide/index.html',
'blog/index.html',
'blog/[slug].html',
'repl/index.html'
]
);
assert.deepEqual(
server_routes.map(r => r.file),
[
'blog/rss.xml.js',
'gist/create.js',
'gist/[id].js',
'api/guide/index.js',
'api/guide/contents.js',
'api/blog/index.js',
'api/blog/[slug].js',
]
);
// RegExp routes
({ pages } = create_routes({
files: [
'blog/[slug].html',
'blog/index.html',
'blog/[slug([^0-9]+)].html',
]
}));
assert.deepEqual(
pages.map(r => r.file),
[
'blog/index.html',
'blog/[slug([^0-9]+)].html',
'blog/[slug].html',
]
);
});
it('generates params', () => {
const { pages } = create_routes({
files: ['index.html', 'about.html', '[wildcard].html', 'post/[id].html']
});
let file;
let params;
for (let i = 0; i < pages.length; i += 1) {
const route = pages[i];
if (params = route.exec('/post/123')) {
file = route.file;
break;
}
}
assert.equal(file, 'post/[id].html');
assert.deepEqual(params, {
id: '123'
});
});
it('ignores files and directories with leading underscores', () => {
const { pages } = create_routes({
files: ['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html']
});
assert.deepEqual(
pages.map(r => r.file),
[
'index.html',
'e/f/g/h.html'
]
);
});
it('ignores files and directories with leading dots except .well-known', () => {
const { server_routes } = create_routes({
files: ['.well-known/dnt-policy.txt.js', '.unknown/foo.txt.js']
});
assert.deepEqual(
server_routes.map(r => r.file),
['.well-known/dnt-policy.txt.js']
);
});
it('matches /foo/:bar before /:baz/qux', () => {
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.pages.map(r => r.file),
['foo/[bar].html', '[baz]/qux.html']
);
assert.deepEqual(
b.pages.map(r => r.file),
['foo/[bar].html', '[baz]/qux.html']
);
});
it('fails if routes are indistinguishable', () => {
assert.throws(() => {
create_routes({
files: ['[foo].html', '[bar]/index.html']
});
}, /The \[foo\] and \[bar\]\/index routes clash/);
assert.throws(() => {
create_routes({
files: ['[foo([0-9-a-z]+)].html', '[bar([0-9-a-z]+)]/index.html']
});
}, /The \[foo\(\[0-9-a-z\]\+\)\] and \[bar\(\[0-9-a-z\]\+\)\]\/index routes clash/);
});
it('matches nested routes', () => {
const page = create_routes({
files: ['settings/[submenu].html']
}).pages[0];
assert.deepEqual(page.exec('/settings/foo'), {
submenu: 'foo'
});
assert.deepEqual(page.exec('/settings'), {
submenu: null
});
});
it('prefers index routes to nested routes', () => {
const { pages } = create_routes({
files: ['settings/[submenu].html', 'settings.html']
});
assert.deepEqual(
pages.map(r => r.file),
['settings.html', 'settings/[submenu].html']
);
});
it('matches deeply nested routes', () => {
const page = create_routes({
files: ['settings/[a]/[b]/index.html']
}).pages[0];
assert.deepEqual(page.exec('/settings/foo/bar'), {
a: 'foo',
b: 'bar'
});
assert.deepEqual(page.exec('/settings/foo'), {
a: 'foo',
b: null
});
assert.deepEqual(page.exec('/settings'), {
a: null,
b: null
});
});
it('matches a dynamic part within a part', () => {
const route = create_routes({
files: ['things/[slug].json.js']
}).server_routes[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']
}).server_routes[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/);
});
it('errors when trying to use reserved characters in route regexp', () => {
assert.throws(() => {
create_routes({
files: ['[lang([a-z]{2}(?:-[a-z]{2,4})?)]']
});
}, /Sapper does not allow \(, \), \? or \: in RegExp routes yet/);
});
it('errors on 4xx.html', () => {
assert.throws(() => {
create_routes({
files: ['4xx.html']
});
}, /As of Sapper 0.14, 4xx.html and 5xx.html should be replaced with _error.html/);
});
it('errors on 5xx.html', () => {
assert.throws(() => {
create_routes({
files: ['5xx.html']
});
}, /As of Sapper 0.14, 4xx.html and 5xx.html should be replaced with _error.html/);
});
it('treats foo/index.json.js the same as foo.json.js', () => {
const route = create_routes({
files: ['foo/index.json.js']
}).server_routes[0];
assert.ok(route.test('/foo.json'));
});
});

View File

@@ -0,0 +1,165 @@
const path = require('path');
const assert = require('assert');
const { create_routes } = require('../../../dist/core.ts.js');
const _default_layout = {
default: true,
name: '_default_layout',
file: null
};
describe('create_routes', () => {
it('creates routes', () => {
const { components, pages, server_routes } = create_routes(path.join(__dirname, 'samples/basic'));
const index = { name: 'index', file: 'index.html' };
const about = { name: 'about', file: 'about.html' };
const blog = { name: 'blog', file: 'blog/index.html' };
const blog_$slug = { name: 'blog_$slug', file: 'blog/[slug].html' };
assert.deepEqual(components, [
index,
about,
_default_layout,
blog,
blog_$slug
]);
assert.deepEqual(pages, [
{
pattern: /^\/?$/,
parts: [
{ component: index, params: [] }
]
},
{
pattern: /^\/about\/?$/,
parts: [
{ component: about, params: [] }
]
},
{
pattern: /^\/blog\/?$/,
parts: [
{ component: _default_layout, params: [] },
{ component: blog, params: [] }
]
},
{
pattern: /^\/blog\/([^\/]+?)\/?$/,
parts: [
{ component: _default_layout, params: [] },
{ component: blog_$slug, params: ['slug'] }
]
}
]);
assert.deepEqual(server_routes, [
{
name: 'route_blog_json',
pattern: /^\/blog.json\/?$/,
file: 'blog/index.json.js',
params: []
},
{
name: 'route_blog_$slug_json',
pattern: /^\/blog\/([^\/]+?).json\/?$/,
file: 'blog/[slug].json.js',
params: ['slug']
}
]);
});
it('encodes invalid characters', () => {
const { components, pages } = create_routes(path.join(__dirname, 'samples/encoding'));
// had to remove ? and " because windows
// const quote = { name: '$34', file: '".html' };
const hash = { name: '$35', file: '#.html' };
// const question_mark = { name: '$63', file: '?.html' };
assert.deepEqual(components, [
// quote,
hash,
// question_mark
]);
assert.deepEqual(pages.map(p => p.pattern), [
// /^\/%22\/?$/,
/^\/%23\/?$/,
// /^\/%3F\/?$/
]);
});
it('allows regex qualifiers', () => {
const { pages } = create_routes(path.join(__dirname, 'samples/qualifiers'));
assert.deepEqual(pages.map(p => p.pattern), [
/^\/([0-9-a-z]{3,})\/?$/,
/^\/([a-z]{2})\/?$/,
/^\/([^\/]+?)\/?$/
]);
});
it('sorts routes correctly', () => {
const { pages } = create_routes(path.join(__dirname, 'samples/sorting'));
assert.deepEqual(pages.map(p => p.parts.map(part => part.component.file)), [
['index.html'],
['about.html'],
[_default_layout.file, 'post/index.html'],
[_default_layout.file, 'post/bar.html'],
[_default_layout.file, 'post/foo.html'],
[_default_layout.file, 'post/f[xx].html'],
[_default_layout.file, 'post/[id([0-9-a-z]{3,})].html'],
[_default_layout.file, 'post/[id].html'],
['[wildcard].html']
]);
});
it('ignores files and directories with leading underscores', () => {
const { server_routes } = create_routes(path.join(__dirname, 'samples/hidden-underscore'));
assert.deepEqual(server_routes.map(r => r.file), [
'index.js',
'e/f/g/h.js'
]);
});
it('ignores files and directories with leading dots except .well-known', () => {
const { server_routes } = create_routes(path.join(__dirname, 'samples/hidden-dot'));
assert.deepEqual(server_routes.map(r => r.file), [
'.well-known/dnt-policy.txt.js'
]);
});
it('fails on clashes', () => {
assert.throws(() => {
const { pages } = create_routes(path.join(__dirname, 'samples/clash-pages'));
}, /The \[bar\]\/index\.html and \[foo\]\.html pages clash/);
assert.throws(() => {
const { server_routes } = create_routes(path.join(__dirname, 'samples/clash-routes'));
console.log(server_routes);
}, /The \[bar\]\/index\.js and \[foo\]\.js routes clash/);
});
it('fails if dynamic params are not separated', () => {
assert.throws(() => {
create_routes(path.join(__dirname, 'samples/invalid-params'));
}, /Invalid route \[foo\]\[bar\]\.js — parameters must be separated/);
});
it('errors when trying to use reserved characters in route regexp', () => {
assert.throws(() => {
create_routes(path.join(__dirname, 'samples/invalid-qualifier'));
}, /Invalid route \[foo\(\[a-z\]\(\[0-9\]\)\)\].js — cannot use \(, \), \? or \: in route qualifiers/);
});
});