Merge branch 'master' into site

This commit is contained in:
Richard Harris
2019-05-01 09:01:39 -04:00
43 changed files with 278 additions and 104 deletions

View File

@@ -1,4 +1,5 @@
--require source-map-support/register
--require sucrase/register
--recursive
test/unit/*/test.ts
test/apps/*/test.ts

5
package-lock.json generated
View File

@@ -5530,9 +5530,8 @@
}
},
"yootils": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/yootils/-/yootils-0.0.14.tgz",
"integrity": "sha512-yWoA/a/4aVUp5nqfqdjbTdyXcR8d0OAbRQ8Ktu3ZsfQnArwLpS81oqZl3adIszX3p8NEhT0aNHARPsaTwBH/Qw==",
"version": "0.0.15",
"resolved": "github:bwbroersma/yootils#77a0949b90387af0bff8081cf596a752a1a3e08e",
"dev": true
}
}

View File

@@ -58,7 +58,7 @@
"svelte-loader": "^2.13.3",
"webpack": "^4.29.0",
"webpack-format-messages": "^2.0.5",
"yootils": "0.0.14"
"yootils": "0.0.15"
},
"peerDependencies": {
"svelte": "^3.0.0"

View File

@@ -1,12 +0,0 @@
<script>
import { setContext } from 'svelte';
import { CONTEXT_KEY } from './shared';
export let Root;
export let props;
export let session;
setContext(CONTEXT_KEY, session);
</script>
<Root {...props}/>

View File

@@ -1,10 +1,5 @@
import { writable } from 'svelte/store';
export const stores = {
preloading: writable(false),
page: writable(null)
};
export const CONTEXT_KEY = {};
export const preload = () => ({});

View File

@@ -1,7 +1,6 @@
import { writable } from 'svelte/store.mjs';
import App from '@sapper/internal/App.svelte';
import { stores } from '@sapper/internal/shared';
import { Root, root_preload, ErrorComponent, ignore, components, routes } from '@sapper/internal/manifest-client';
import { root_preload, ErrorComponent, ignore, components, routes } from '@sapper/internal/manifest-client';
import {
Target,
ScrollPosition,
@@ -24,12 +23,16 @@ let current_token: {};
let root_preloaded: Promise<any>;
let current_branch = [];
const session = writable(initial_data && initial_data.session);
const stores = {
page: writable({}),
preloading: writable(null),
session: writable(initial_data && initial_data.session)
};
let $session;
let session_dirty: boolean;
session.subscribe(async value => {
stores.session.subscribe(async value => {
$session = value;
if (!ready) return;
@@ -85,8 +88,7 @@ export function extract_query(search: string) {
const query = Object.create(null);
if (search.length > 0) {
search.slice(1).split('&').forEach(searchParam => {
let [, key, value] = /([^=]*)(?:=(.*))?/.exec(decodeURIComponent(searchParam));
value = (value || '').replace(/\+/g, ' ');
let [, key, value = ''] = /([^=]*)(?:=(.*))?/.exec(decodeURIComponent(searchParam.replace(/\+/g, ' ')));
if (typeof query[key] === 'string') query[key] = [<string>query[key]];
if (typeof query[key] === 'object') (query[key] as string[]).push(value);
else query[key] = value;
@@ -217,7 +219,11 @@ async function render(redirect: Redirect, branch: any[], props: any, page: Page)
if (root_component) {
root_component.$set(props);
} else {
props.session = session;
props.stores = {
page: { subscribe: stores.page.subscribe },
preloading: { subscribe: stores.preloading.subscribe },
session: stores.session
};
props.level0 = {
props: await root_preloaded
};

View File

@@ -1,10 +1,7 @@
import { getContext } from 'svelte';
import { CONTEXT_KEY, stores } from '@sapper/internal/shared';
import { CONTEXT_KEY } from '@sapper/internal/shared';
export const preloading = { subscribe: stores.preloading.subscribe };
export const page = { subscribe: stores.page.subscribe };
export const getSession = () => getContext(CONTEXT_KEY);
export const stores = () => getContext(CONTEXT_KEY);
export { default as start } from './start/index';
export { default as goto } from './goto/index';

View File

@@ -177,7 +177,7 @@ export function get_page_handler(
try {
if (redirect) {
const location = URL.resolve(req.baseUrl || '/', redirect.location);
const location = URL.resolve((req.baseUrl || '') + '/', redirect.location);
res.statusCode = redirect.statusCode;
res.setHeader('Location', location);
@@ -204,10 +204,22 @@ export function get_page_handler(
});
const props = {
stores: {
page: {
subscribe: writable({
path: req.path,
query: req.query,
params
}).subscribe
},
preloading: {
subscribe: writable(null).subscribe
},
session: writable(session)
},
segments: layout_segments,
status: error ? status : 200,
error: error ? error instanceof Error ? error : { message: error } : null,
session: writable(session),
level0: {
props: preloaded[0]
},
@@ -231,12 +243,6 @@ export function get_page_handler(
}
}
stores.page.set({
path: req.path,
query: req.query,
params: params
});
const { html, head, css } = App.render(props);
const serialized = {

View File

@@ -19,6 +19,7 @@ type Opts = {
static?: string,
basepath?: string,
timeout?: number | false,
concurrent?: number,
oninfo?: ({ message }: { message: string }) => void;
onfile?: ({ file, size, status }: { file: string, size: number, status: number }) => void;
};
@@ -44,6 +45,7 @@ async function _export({
export_dir = '__sapper__/export',
basepath = '',
timeout = 5000,
concurrent = 8,
oninfo = noop,
onfile = noop
}: Opts = {}) {
@@ -87,6 +89,7 @@ async function _export({
const seen = new Set();
const saved = new Set();
const q = yootils.queue(concurrent);
function save(url: string, status: number, type: string, body: string) {
const { pathname } = resolve(origin, url);
@@ -135,9 +138,9 @@ async function _export({
}, timeout);
const r = await Promise.race([
fetch(url.href, {
q.add(() => fetch(url.href, {
redirect: 'manual'
}),
})),
timeout_deferred.promise
]);
@@ -159,11 +162,10 @@ async function _export({
`<link rel="preload" as=${JSON.stringify(ref.as)} href=${JSON.stringify(ref.uri)}></head>`)
}
});
if (pathname !== '/service-worker-index.html') {
const cleaned = clean_html(body);
const q = yootils.queue(8);
const base_match = /<base ([\s\S]+?)>/m.exec(cleaned);
const base_href = base_match && get_href(base_match[1]);
const base = resolve(url.href, base_href);
@@ -171,6 +173,8 @@ async function _export({
let match;
let pattern = /<a ([\s\S]+?)>/gm;
let promise;
while (match = pattern.exec(cleaned)) {
const attrs = match[1];
const href = get_href(attrs);
@@ -179,12 +183,12 @@ async function _export({
const url = resolve(base.href, href);
if (url.protocol === protocol && url.host === host) {
q.add(() => handle(url));
promise = handle(url);
}
}
}
await q.close();
await promise;
}
}
}
@@ -201,14 +205,17 @@ async function _export({
save(pathname, r.status, type, body);
}
return ports.wait(port)
.then(() => handle(root))
.then(() => handle(resolve(root.href, 'service-worker-index.html')))
.then(() => proc.kill())
.catch(err => {
try {
await ports.wait(port);
await handle(root);
await handle(resolve(root.href, 'service-worker-index.html'));
await q.close();
proc.kill()
} catch (err) {
proc.kill();
throw err;
});
}
}
function get_href(attrs: string) {

View File

@@ -189,6 +189,7 @@ prog.command('export [dest]')
.describe('Export your app as static files (if possible)')
.option('--build', '(Re)build app before exporting', true)
.option('--basepath', 'Specify a base path')
.option('--concurrent', 'Concurrent requests', 8)
.option('--timeout', 'Milliseconds to wait for a page (--no-timeout to disable)', 5000)
.option('--legacy', 'Create separate legacy build')
.option('--bundler', 'Specify a bundler (rollup or webpack, blank for auto)')
@@ -203,6 +204,7 @@ prog.command('export [dest]')
legacy: boolean,
bundler?: 'rollup' | 'webpack',
basepath?: string,
concurrent: number,
timeout: number | false,
cwd: string,
src: string,
@@ -228,6 +230,7 @@ prog.command('export [dest]')
export_dir: dest,
basepath: opts.basepath,
timeout: opts.timeout,
concurrent: opts.concurrent,
oninfo: event => {
console.log(colors.bold().cyan(`> ${event.message}`));

View File

@@ -70,6 +70,12 @@ export function create_serviceworker_manifest({ manifest_data, output, client_fi
write_if_changed(`${output}/service-worker.js`, code);
}
function create_param_match(param: string, i: number) {
return /^\.{3}.+$/.test(param)
? `${param.replace(/.{3}/, '')}: d(match[${i + 1}]).split('/')`
: `${param}: d(match[${i + 1}])`
}
function generate_client_manifest(
manifest_data: ManifestData,
path_to_routes: string,
@@ -114,7 +120,7 @@ function generate_client_manifest(
if (part.params.length > 0) {
needs_decode = true;
const props = part.params.map((param, i) => `${param}: d(match[${i + 1}])`);
const props = part.params.map(create_param_match);
return `{ i: ${component_indexes[part.component.name]}, params: match => ({ ${props.join(', ')} }) }`;
}
@@ -189,7 +195,7 @@ function generate_server_manifest(
pattern: ${route.pattern},
handlers: route_${i},
params: ${route.params.length > 0
? `match => ({ ${route.params.map((param, i) => `${param}: d(match[${i + 1}])`).join(', ')} })`
? `match => ({ ${route.params.map(create_param_match).join(', ')} })`
: `() => ({})`}
}`).join(',\n\n\t\t\t\t')}
],
@@ -210,7 +216,7 @@ function generate_server_manifest(
].filter(Boolean);
if (part.params.length > 0) {
const params = part.params.map((param, i) => `${param}: d(match[${i + 1}])`);
const params = part.params.map(create_param_match);
props.push(`params: match => ({ ${params.join(', ')} })`);
}
@@ -265,14 +271,14 @@ function generate_app(manifest_data: ManifestData, path_to_routes: string) {
import Layout from '${get_file(path_to_routes, manifest_data.root)}';
import Error from '${get_file(path_to_routes, manifest_data.error)}';
export let session;
export let stores;
export let error;
export let status;
export let segments;
export let level0;
${levels.map(l => `export let level${l} = null;`).join('\n\t\t\t')}
setContext(CONTEXT_KEY, session);
setContext(CONTEXT_KEY, stores);
</script>
<Layout segment={segments[0]} {...level0.props}>

View File

@@ -246,13 +246,23 @@ type Part = {
content: string;
dynamic: boolean;
qualifier?: string;
spread?: boolean;
};
function is_spead(path: string) {
const spread_pattern = /\[\.{3}/g;
return spread_pattern.test(path)
}
function comparator(
a: { basename: string, parts: Part[], file: string, is_index: boolean },
b: { basename: string, parts: Part[], file: string, is_index: boolean }
) {
if (a.is_index !== b.is_index) return a.is_index ? -1 : 1;
if (a.is_index !== b.is_index) {
if (a.is_index) return is_spead(a.file) ? 1 : -1;
return is_spead(b.file) ? -1 : 1;
}
const max = Math.max(a.parts.length, b.parts.length);
@@ -263,6 +273,14 @@ function comparator(
if (!a_sub_part) return 1; // b is more specific, so goes first
if (!b_sub_part) return -1;
// if spread && index, order later
if (a_sub_part.spread && b_sub_part.spread) {
return a.is_index ? 1 : -1
}
// If one is ...spread order it later
if (a_sub_part.spread !== b_sub_part.spread) return a_sub_part.spread ? 1 : -1;
if (a_sub_part.dynamic !== b_sub_part.dynamic) {
return a_sub_part.dynamic ? 1 : -1;
}
@@ -306,6 +324,7 @@ function get_parts(part: string): Part[] {
return {
content,
dynamic,
spread: /^\.{3}.+$/.test(content),
qualifier
};
})
@@ -333,7 +352,7 @@ function get_pattern(segments: Part[][], add_trailing_slash: boolean) {
segments.map(segment => {
return '\\/' + segment.map(part => {
return part.dynamic
? part.qualifier || '([^\\/]+?)'
? part.qualifier || part.spread ? '(.+)' : '([^\\/]+?)'
: encodeURI(part.content.normalize())
.replace(/\?/g, '%3F')
.replace(/#/g, '%23')

View File

@@ -0,0 +1,3 @@
export function get(req, res) {
res.end(req.params.rest.join(','));
}

View File

@@ -0,0 +1,8 @@
<script>
import { stores } from '@sapper/app';
const { page } = stores();
</script>
<h1>{$page.params.rest.join(',')}</h1>
<a href="xyz/abc/qwe/deep.json">deep</a>

View File

@@ -0,0 +1,8 @@
<script>
import { stores } from '@sapper/app';
const { page } = stores();
</script>
<h1>{$page.params.rest.join(',')}</h1>
<a href="xyz/abc/deep">deep</a>

View File

@@ -1,5 +1,6 @@
<script>
import { page } from '@sapper/app';
import { stores } from '@sapper/app';
const { page } = stores();
</script>
<h1>{$page.params.slug.toUpperCase()}</h1>

View File

@@ -1,5 +1,6 @@
<script>
import { page } from '@sapper/app';
import { stores } from '@sapper/app';
const { page } = stores();
</script>
<h1>{JSON.stringify($page.query)}</h1>

View File

@@ -282,6 +282,24 @@ describe('basics', function() {
assert.equal(await title(), 'bar');
});
it('navigates to ...rest', async () => {
await page.goto(`${base}/abc/xyz`);
await start();
assert.equal(await title(), 'abc,xyz');
await page.click('[href="xyz/abc/deep"]');
await wait(50);
assert.equal(await title(), 'xyz,abc');
await page.click(`[href="xyz/abc/qwe/deep.json"]`);
await wait(50);
assert.equal(
await page.evaluate(() => document.body.textContent),
'xyz,abc,qwe'
);
});
it('navigates between dynamic routes with same segments', async () => {
await page.goto(`${base}/dirs/bar/xyz`);
await start();

View File

@@ -7,7 +7,10 @@
</script>
<script>
import { page } from '@sapper/app';
import { stores } from '@sapper/app';
const { page } = stores();
export let slug;
</script>

View File

@@ -1,3 +1,3 @@
<h1>Great success!</h1>
<a href="echo/page/encöded?message=hëllö+wörld&föo=bar&=baz">link</a>
<a href="echo/page/encöded?message=hëllö+wörld&föo=bar&=baz&tel=%2B123456789">link</a>

View File

@@ -35,11 +35,11 @@ describe('encoding', function() {
});
it('encodes req.params and req.query for server-rendered pages', async () => {
await page.goto(`${base}/echo/page/encöded?message=hëllö+wörld&föo=bar&=baz`);
await page.goto(`${base}/echo/page/encöded?message=hëllö+wörld&föo=bar&=baz&tel=%2B123456789`);
assert.equal(
await page.$eval('h1', node => node.textContent),
'encöded {"message":"hëllö wörld","föo":"bar","":"baz"}'
'encöded {"message":"hëllö wörld","föo":"bar","":"baz","tel":"+123456789"}'
);
});
@@ -48,12 +48,12 @@ describe('encoding', function() {
await start();
await prefetchRoutes();
await page.click('a[href="echo/page/encöded?message=hëllö+wörld&föo=bar&=baz"]');
await page.click('a');
await wait(50);
assert.equal(
await page.$eval('h1', node => node.textContent),
'encöded {"message":"hëllö wörld","föo":"bar","":"baz"}'
'encöded {"message":"hëllö wörld","föo":"bar","":"baz","tel":"+123456789"}'
);
});

View File

@@ -0,0 +1,12 @@
<script context="module">
export function preload({ params }) {
return params;
}
</script>
<script>
export let a;
export let b;
</script>
<p>{a}/{b}</p>

View File

@@ -0,0 +1,15 @@
<script context="module">
export function preload({ params }) {
return params;
}
</script>
<script>
export let a;
const list = Array(20).fill().map((_, i) => i + 1);
</script>
{#each list as b}
<a href="boom/{a}/{b}">{a}/{b}</a>
{/each}

View File

@@ -0,0 +1,7 @@
<script>
const list = Array(20).fill().map((_, i) => i + 1);
</script>
{#each list as a}
<a href="boom/{a}">{a}</a>
{/each}

View File

@@ -7,3 +7,4 @@
<a href= >empty anchor #4</a>
<a href>empty anchor #5</a>
<a>empty anchor #6</a>
<a href="boom">boom</a>

View File

@@ -19,7 +19,15 @@ describe('export', function() {
assert.ok(client_assets.length > 0);
assert.deepEqual(non_client_assets, [
const boom = ['boom/index.html'];
for (let a = 1; a <= 20; a += 1) {
boom.push(`boom/${a}/index.html`);
for (let b = 1; b <= 20; b += 1) {
boom.push(`boom/${a}/${b}/index.html`);
}
}
assert.deepEqual(non_client_assets.sort(), [
'blog.json',
'blog/bar.json',
'blog/bar/index.html',
@@ -31,8 +39,9 @@ describe('export', function() {
'global.css',
'index.html',
'service-worker-index.html',
'service-worker.js'
]);
'service-worker.js',
...boom
].sort());
});
// TODO test timeout, basepath

View File

@@ -9,7 +9,8 @@
</script>
<script>
import { page } from '@sapper/app';
import { stores } from '@sapper/app';
const { page } = stores();
export let count;
</script>

View File

@@ -9,7 +9,8 @@
</script>
<script>
import { page } from '@sapper/app';
import { stores } from '@sapper/app';
const { page } = stores();
export let count;
export let segment;

View File

@@ -7,12 +7,14 @@
</script>
<script>
import { preloading } from '@sapper/app';
import { stores } from '@sapper/app';
import { setContext } from 'svelte';
export let child;
export let rootPreloadFunctionRan;
const { preloading } = stores();
setContext('x', { rootPreloadFunctionRan });
</script>

View File

@@ -1,5 +1,6 @@
<script>
import { page } from '@sapper/app';
import { stores } from '@sapper/app';
const { page } = stores();
</script>
<h1>{$page.params.slug}</h1>

View File

@@ -5,8 +5,8 @@
</script>
<script>
import { getSession } from '@sapper/app';
const session = getSession();
import { stores } from '@sapper/app';
const { session } = stores();
export let title;
</script>

View File

@@ -1,6 +1,6 @@
<script>
import { getSession } from '@sapper/app';
const session = getSession();
import { stores } from '@sapper/app';
const { session } = stores();
</script>
<h1>{$session.title}</h1>

View File

@@ -1 +1,2 @@
<h1>Great success!</h1>
<a href="redirect-from">redirect from</a>

View File

@@ -0,0 +1,7 @@
<script context="module">
export function preload() {
this.redirect(301, 'redirect-to');
}
</script>
<h1>unredirected</h1>

View File

@@ -0,0 +1 @@
<h1>redirected</h1>

View File

@@ -3,6 +3,8 @@ import * as puppeteer from 'puppeteer';
import * as api from '../../../api';
import { walk } from '../../utils';
import { AppRunner } from '../AppRunner';
import { wait } from '../../utils';
describe('with-basepath', function() {
this.timeout(10000);
@@ -11,6 +13,11 @@ describe('with-basepath', function() {
let page: puppeteer.Page;
let base: string;
// helpers
let start: () => Promise<void>;
let prefetchRoutes: () => Promise<void>;
let title: () => Promise<string>;
// hooks
before(async () => {
await api.build({ cwd: __dirname });
@@ -21,7 +28,7 @@ describe('with-basepath', function() {
});
runner = new AppRunner(__dirname, '__sapper__/build/server/server.js');
({ base, page } = await runner.start());
({ base, start, page, prefetchRoutes, title } = await runner.start());
});
after(() => runner.end());
@@ -56,8 +63,43 @@ describe('with-basepath', function() {
assert.deepEqual(non_client_assets, [
'custom-basepath/global.css',
'custom-basepath/index.html',
'custom-basepath/redirect-from/index.html',
'custom-basepath/redirect-to/index.html',
'custom-basepath/service-worker-index.html',
'custom-basepath/service-worker.js'
]);
});
it('redirects on server', async () => {
await page.goto(`${base}/custom-basepath/redirect-from`);
assert.equal(
page.url(),
`${base}/custom-basepath/redirect-to`
);
assert.equal(
await title(),
'redirected'
);
});
it('redirects in client', async () => {
await page.goto(`${base}/custom-basepath`);
await start();
await prefetchRoutes();
await page.click('[href="redirect-from"]');
await wait(50);
assert.equal(
page.url(),
`${base}/custom-basepath/redirect-to`
);
assert.equal(
await title(),
'redirected'
);
});
});

View File

@@ -6,10 +6,10 @@ describe('manifest_data', () => {
it('creates routes', () => {
const { components, pages, server_routes } = create_manifest_data(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' };
const index = { name: 'index', file: 'index.html', has_preload: false };
const about = { name: 'about', file: 'about.html', has_preload: false };
const blog = { name: 'blog', file: 'blog/index.html', has_preload: false };
const blog_$slug = { name: 'blog_$slug', file: 'blog/[slug].html', has_preload: false };
assert.deepEqual(components, [
index,
@@ -36,7 +36,6 @@ describe('manifest_data', () => {
{
pattern: /^\/blog\/?$/,
parts: [
null,
{ component: blog, params: [] }
]
},
@@ -73,7 +72,7 @@ describe('manifest_data', () => {
// had to remove ? and " because windows
// const quote = { name: '$34', file: '".html' };
const hash = { name: '$35', file: '#.html' };
const hash = { name: '$35', has_preload: false, file: '#.html' };
// const question_mark = { name: '$63', file: '?.html' };
assert.deepEqual(components, [
@@ -89,15 +88,16 @@ describe('manifest_data', () => {
]);
});
it('allows regex qualifiers', () => {
const { pages } = create_manifest_data(path.join(__dirname, 'samples/qualifiers'));
assert.deepEqual(pages.map(p => p.pattern), [
/^\/([0-9-a-z]{3,})\/?$/,
/^\/([a-z]{2})\/?$/,
/^\/([^\/]+?)\/?$/
]);
});
// this test broken
// it('allows regex qualifiers', () => {
// const { pages } = create_manifest_data(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_manifest_data(path.join(__dirname, 'samples/sorting'));
@@ -105,13 +105,18 @@ describe('manifest_data', () => {
assert.deepEqual(pages.map(p => p.parts.map(part => part && part.component.file)), [
['index.html'],
['about.html'],
[null, 'post/index.html'],
['post/index.html'],
[null, 'post/bar.html'],
[null, 'post/foo.html'],
[null, 'post/f[xx].html'],
[null, 'post/[id([0-9-a-z]{3,})].html'],
[null, 'post/[id].html'],
['[wildcard].html']
['[wildcard].html'],
[null, null, null, '[...spread]/deep/[...deep_spread]/xyz.html'],
[null, null, '[...spread]/deep/[...deep_spread]/index.html'],
[null, '[...spread]/deep/index.html'],
[null, '[...spread]/abc.html'],
['[...spread]/index.html']
]);
});