mirror of
https://github.com/kevin-DL/sapper.git
synced 2026-01-13 19:45:26 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9538499d51 | ||
|
|
ff1e632057 | ||
|
|
aeeb231477 | ||
|
|
d1940db8c0 | ||
|
|
98f9a64b64 | ||
|
|
b9bef802d3 | ||
|
|
a7024b3806 | ||
|
|
423e02aeae | ||
|
|
12b73ecebf | ||
|
|
e1bc38b5a7 | ||
|
|
b66f624f01 | ||
|
|
502dd547d1 | ||
|
|
4c343490d2 | ||
|
|
b3027c5816 | ||
|
|
c29e8022cc | ||
|
|
e4cd4c9cb0 | ||
|
|
feddad42b2 | ||
|
|
3c4ebcda30 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
cypress/screenshots
|
||||
test/app/.sapper
|
||||
test/app/.sapper
|
||||
runtime.js
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
||||
# sapper changelog
|
||||
|
||||
## 0.3.1
|
||||
|
||||
* Fix missing `runtime.js`
|
||||
|
||||
## 0.3.0
|
||||
|
||||
* Move `sapper/runtime/app.js` to `sapper/runtime.js`
|
||||
* Cancel navigation if overtaken by second navigation ([#48](https://github.com/sveltejs/sapper/issues/48))
|
||||
* Store preloaded data, to avoiding double prefetching ([#49](https://github.com/sveltejs/sapper/issues/49))
|
||||
* Pass server request object to `preload` ([#54](https://github.com/sveltejs/sapper/pull/54))
|
||||
* Nested routes ([#55](https://github.com/sveltejs/sapper/issues/55))
|
||||
|
||||
## 0.2.10
|
||||
|
||||
* Handle deep links correctly ([#44](https://github.com/sveltejs/sapper/issues/44))
|
||||
|
||||
@@ -151,7 +151,7 @@ function get_route_handler(fn) {
|
||||
const data = { params: req.params, query: req.query };
|
||||
|
||||
if (mod.preload) {
|
||||
const promise = Promise.resolve(mod.preload(data)).then(preloaded => {
|
||||
const promise = Promise.resolve(mod.preload(req)).then(preloaded => {
|
||||
Object.assign(data, preloaded);
|
||||
return mod.render(data);
|
||||
});
|
||||
|
||||
@@ -16,9 +16,22 @@ module.exports = function create_matchers(files) {
|
||||
.filter(part => part[0] === '[')
|
||||
.map(part => part.slice(1, -1));
|
||||
|
||||
const pattern = new RegExp(
|
||||
`^\\/${parts.map(p => p[0] === '[' ? '([^/]+)' : p).join('\\/')}$`
|
||||
);
|
||||
let pattern_string = '';
|
||||
let i = parts.length;
|
||||
let nested = true;
|
||||
while (i--) {
|
||||
const part = parts[i];
|
||||
const dynamic = part[0] === '[';
|
||||
|
||||
if (dynamic) {
|
||||
pattern_string = nested ? `(?:\\/([^/]+)${pattern_string})?` : `\\/([^/]+)${pattern_string}`;
|
||||
} else {
|
||||
nested = false;
|
||||
pattern_string = `\\/${part}${pattern_string}`;
|
||||
}
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${pattern_string || '\\/'}$`);
|
||||
|
||||
const test = url => pattern.test(url);
|
||||
|
||||
@@ -58,8 +71,8 @@ module.exports = function create_matchers(files) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!a_part) return 1;
|
||||
if (!b_part) return -1;
|
||||
if (!a_part) return -1;
|
||||
if (!b_part) return 1;
|
||||
|
||||
const a_is_dynamic = a_part[0] === '[';
|
||||
const b_is_dynamic = b_part[0] === '[';
|
||||
|
||||
@@ -59,10 +59,10 @@ function generate_service_worker(chunk_files) {
|
||||
}]`;
|
||||
|
||||
return read('templates/service-worker.js')
|
||||
.replace('__timestamp__', Date.now())
|
||||
.replace('__assets__', JSON.stringify(assets))
|
||||
.replace('__shell__', JSON.stringify(chunk_files.concat('/index.html')))
|
||||
.replace('__routes__', route_code);
|
||||
.replace(/__timestamp__/g, Date.now())
|
||||
.replace(/__assets__/g, JSON.stringify(assets))
|
||||
.replace(/__shell__/g, JSON.stringify(chunk_files.concat('/index.html')))
|
||||
.replace(/__routes__/g, route_code);
|
||||
}
|
||||
|
||||
function generate_index(main_file) {
|
||||
|
||||
69
package-lock.json
generated
69
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sapper",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.10",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -790,6 +790,12 @@
|
||||
"integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==",
|
||||
"dev": true
|
||||
},
|
||||
"compare-versions": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-2.0.1.tgz",
|
||||
"integrity": "sha1-Htwfk2h/2XoyXFn1XkWgfbEGrKY=",
|
||||
"dev": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -1707,6 +1713,12 @@
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz",
|
||||
"integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM="
|
||||
},
|
||||
"estree-walker": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.2.1.tgz",
|
||||
"integrity": "sha1-va/oCVOD2EFNXcLs9MkXO225QS4=",
|
||||
"dev": true
|
||||
},
|
||||
"esutils": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
|
||||
@@ -5583,6 +5595,43 @@
|
||||
"inherits": "2.0.3"
|
||||
}
|
||||
},
|
||||
"rollup": {
|
||||
"version": "0.53.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-0.53.0.tgz",
|
||||
"integrity": "sha512-bG5RzkF7wcOHmKoVAFtERZ5P9TNJP9/AF+ldwGm/Rx6pejura+Z9BDU0GJtzWu+lYXwjfINmgiCclhLJzP/OXA==",
|
||||
"dev": true
|
||||
},
|
||||
"rollup-plugin-typescript": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-typescript/-/rollup-plugin-typescript-0.8.1.tgz",
|
||||
"integrity": "sha1-L/fuzCHPa7K0P8J+W2iJUs5xkko=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"compare-versions": "2.0.1",
|
||||
"object-assign": "4.1.1",
|
||||
"rollup-pluginutils": "1.5.2",
|
||||
"tippex": "2.3.1",
|
||||
"typescript": "1.8.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"typescript": {
|
||||
"version": "1.8.10",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-1.8.10.tgz",
|
||||
"integrity": "sha1-tHXW4N/wv1DyluXKbvn7tccyDx4=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"rollup-pluginutils": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-1.5.2.tgz",
|
||||
"integrity": "sha1-HhVud4+UtyVb+hs9AXi+j1xVJAg=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"estree-walker": "0.2.1",
|
||||
"minimatch": "3.0.4"
|
||||
}
|
||||
},
|
||||
"run-async": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz",
|
||||
@@ -6191,6 +6240,12 @@
|
||||
"setimmediate": "1.0.5"
|
||||
}
|
||||
},
|
||||
"tippex": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tippex/-/tippex-2.3.1.tgz",
|
||||
"integrity": "sha1-ov1bcIfXy/sgyYBqbBYQjCwPr9o=",
|
||||
"dev": true
|
||||
},
|
||||
"tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
@@ -6229,6 +6284,12 @@
|
||||
"integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=",
|
||||
"dev": true
|
||||
},
|
||||
"tslib": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.8.1.tgz",
|
||||
"integrity": "sha1-aUavLR1lGnsYY7Ux1uWvpBqkTqw=",
|
||||
"dev": true
|
||||
},
|
||||
"tty-browserify": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
|
||||
@@ -6275,6 +6336,12 @@
|
||||
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
|
||||
"dev": true
|
||||
},
|
||||
"typescript": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-2.6.2.tgz",
|
||||
"integrity": "sha1-PFtv1/beCRQmkCfwPAlGdY92c6Q=",
|
||||
"dev": true
|
||||
},
|
||||
"uglify-js": {
|
||||
"version": "2.8.29",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz",
|
||||
|
||||
18
package.json
18
package.json
@@ -1,11 +1,18 @@
|
||||
{
|
||||
"name": "sapper",
|
||||
"version": "0.2.10",
|
||||
"version": "0.3.1",
|
||||
"description": "Military-grade apps, engineered by Svelte",
|
||||
"main": "lib/index.js",
|
||||
"bin": {
|
||||
"sapper": "cli/index.js"
|
||||
},
|
||||
"files": [
|
||||
"cli",
|
||||
"lib",
|
||||
"runtime",
|
||||
"runtime.js",
|
||||
"webpack"
|
||||
],
|
||||
"directories": {
|
||||
"test": "test"
|
||||
},
|
||||
@@ -30,14 +37,21 @@
|
||||
"nightmare": "^2.10.0",
|
||||
"node-fetch": "^1.7.3",
|
||||
"npm-run-all": "^4.1.2",
|
||||
"rollup": "^0.53.0",
|
||||
"rollup-plugin-typescript": "^0.8.1",
|
||||
"style-loader": "^0.19.1",
|
||||
"svelte": "^1.49.1",
|
||||
"svelte-loader": "^2.3.2",
|
||||
"tslib": "^1.8.1",
|
||||
"typescript": "^2.6.2",
|
||||
"wait-on": "^2.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"cy:open": "cypress open",
|
||||
"test": "mocha --opts mocha.opts"
|
||||
"test": "mocha --opts mocha.opts",
|
||||
"pretest": "npm run build",
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -cw"
|
||||
},
|
||||
"repository": "https://github.com/sveltejs/sapper",
|
||||
"keywords": [
|
||||
|
||||
17
rollup.config.js
Normal file
17
rollup.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import typescript from 'rollup-plugin-typescript';
|
||||
|
||||
export default [
|
||||
// runtime.js
|
||||
{
|
||||
input: 'src/runtime/index.ts',
|
||||
output: {
|
||||
file: 'runtime.js',
|
||||
format: 'es'
|
||||
},
|
||||
plugins: [
|
||||
typescript({
|
||||
typescript: require('typescript')
|
||||
})
|
||||
]
|
||||
}
|
||||
];
|
||||
213
runtime/app.js
213
runtime/app.js
@@ -1,211 +1,2 @@
|
||||
const detach = node => {
|
||||
node.parentNode.removeChild(node);
|
||||
};
|
||||
|
||||
export let component;
|
||||
let target;
|
||||
let routes;
|
||||
|
||||
const history = typeof window !== 'undefined' ? window.history : {
|
||||
pushState: () => {},
|
||||
replaceState: () => {},
|
||||
};
|
||||
|
||||
const scroll_history = {};
|
||||
let uid = 1;
|
||||
let cid;
|
||||
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
|
||||
function select_route(url) {
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
|
||||
for (const route of routes) {
|
||||
const match = route.pattern.exec(url.pathname);
|
||||
if (match) {
|
||||
const params = route.params(match);
|
||||
|
||||
const query = {};
|
||||
for (const [key, value] of url.searchParams) query[key] = value || true;
|
||||
|
||||
return { route, data: { params, query } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(Component, data, scroll) {
|
||||
Promise.resolve(
|
||||
Component.preload ? Component.preload(data) : {}
|
||||
).then(preloaded => {
|
||||
if (component) {
|
||||
component.destroy();
|
||||
} else {
|
||||
// first load — remove SSR'd <head> contents
|
||||
const start = document.querySelector('#sapper-head-start');
|
||||
const end = document.querySelector('#sapper-head-end');
|
||||
|
||||
if (start && end) {
|
||||
while (start.nextSibling !== end) detach(start.nextSibling);
|
||||
detach(start);
|
||||
detach(end);
|
||||
}
|
||||
|
||||
// preload additional routes
|
||||
routes.reduce((promise, route) => promise.then(route.load), Promise.resolve());
|
||||
}
|
||||
|
||||
component = new Component({
|
||||
target,
|
||||
data: Object.assign(data, preloaded),
|
||||
hydrate: !!component
|
||||
});
|
||||
|
||||
if (scroll) {
|
||||
window.scrollTo(scroll.x, scroll.y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(url, id) {
|
||||
const selected = select_route(url);
|
||||
if (selected) {
|
||||
if (id) {
|
||||
// popstate or initial navigation
|
||||
cid = id;
|
||||
} else {
|
||||
// clicked on a link. preserve scroll state
|
||||
scroll_history[cid] = scroll_state();
|
||||
|
||||
id = cid = ++uid;
|
||||
scroll_history[cid] = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
selected.route.load().then(mod => {
|
||||
render(mod.default, selected.data, scroll_history[id]);
|
||||
});
|
||||
|
||||
cid = id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function handle_click(event) {
|
||||
// Adapted from https://github.com/visionmedia/page.js
|
||||
// MIT license https://github.com/visionmedia/page.js#license
|
||||
if (which(event) !== 1) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
const a = findAnchor(event.target);
|
||||
if (!a) return;
|
||||
|
||||
// check if link is inside an svg
|
||||
// in this case, both href and target are always inside an object
|
||||
const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString';
|
||||
const href = svg ? a.href.baseVal : a.href;
|
||||
|
||||
if (href === window.location.href) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if tag has
|
||||
// 1. 'download' attribute
|
||||
// 2. rel='external' attribute
|
||||
if (a.hasAttribute('download') || a.getAttribute('rel') === 'external') return;
|
||||
|
||||
// Ignore if <a> has a target
|
||||
if (svg ? a.target.baseVal : a.target) return;
|
||||
|
||||
const url = new URL(href);
|
||||
|
||||
// Don't handle hash changes
|
||||
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
||||
|
||||
if (navigate(url, null)) {
|
||||
event.preventDefault();
|
||||
history.pushState({ id: cid }, '', url.href);
|
||||
}
|
||||
}
|
||||
|
||||
function handle_popstate(event) {
|
||||
scroll_history[cid] = scroll_state();
|
||||
|
||||
if (event.state) {
|
||||
navigate(new URL(window.location), event.state.id);
|
||||
} else {
|
||||
// hashchange
|
||||
cid = ++uid;
|
||||
history.replaceState({ id: cid }, '', window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
function prefetch(event) {
|
||||
const a = findAnchor(event.target);
|
||||
if (!a || a.rel !== 'prefetch') return;
|
||||
|
||||
const selected = select_route(new URL(a.href));
|
||||
|
||||
if (selected) {
|
||||
selected.route.load().then(mod => {
|
||||
if (mod.default.preload) mod.default.preload(selected.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function findAnchor(node) {
|
||||
while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG <a> elements have a lowercase name
|
||||
return node;
|
||||
}
|
||||
|
||||
let inited;
|
||||
|
||||
export function init(_target, _routes) {
|
||||
target = _target;
|
||||
routes = _routes;
|
||||
|
||||
if (!inited) { // this check makes HMR possible
|
||||
window.addEventListener('click', handle_click);
|
||||
window.addEventListener('popstate', handle_popstate);
|
||||
|
||||
// prefetch
|
||||
window.addEventListener('touchstart', prefetch);
|
||||
window.addEventListener('mouseover', prefetch);
|
||||
|
||||
inited = true;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const { hash, href } = window.location;
|
||||
|
||||
const deep_linked = hash && document.querySelector(hash);
|
||||
scroll_history[uid] = deep_linked ?
|
||||
{ x: 0, y: deep_linked.getBoundingClientRect().top } :
|
||||
scroll_state();
|
||||
|
||||
history.replaceState({ id: uid }, '', href);
|
||||
navigate(new URL(window.location), uid);
|
||||
});
|
||||
}
|
||||
|
||||
function which(event) {
|
||||
event = event || window.event;
|
||||
return event.which === null ? event.button : event.which;
|
||||
}
|
||||
|
||||
function scroll_state() {
|
||||
return {
|
||||
x: window.scrollX,
|
||||
y: window.scrollY
|
||||
};
|
||||
}
|
||||
|
||||
export function goto(href, opts = {}) {
|
||||
if (navigate(new URL(href, window.location.href))) {
|
||||
if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
console.error('sapper/runtime/app.js has been deprecated in favour of sapper/runtime.js');
|
||||
export * from '../runtime.js';
|
||||
217
src/runtime/index.ts
Normal file
217
src/runtime/index.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { detach, findAnchor, scroll_state, which } from './utils';
|
||||
import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition } from './interfaces';
|
||||
|
||||
export let component: Component;
|
||||
let target: Node;
|
||||
let routes: Route[];
|
||||
|
||||
const history = typeof window !== 'undefined' ? window.history : {
|
||||
pushState: (state: any, title: string, href: string) => {},
|
||||
replaceState: (state: any, title: string, href: string) => {},
|
||||
scrollRestoration: ''
|
||||
};
|
||||
|
||||
const scroll_history: Record<string, ScrollPosition> = {};
|
||||
let uid = 1;
|
||||
let cid: number;
|
||||
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
|
||||
function select_route(url: URL): { route: Route, data: RouteData } {
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
|
||||
for (const route of routes) {
|
||||
const match = route.pattern.exec(url.pathname);
|
||||
if (match) {
|
||||
const params = route.params(match);
|
||||
|
||||
const query: Record<string, string | true> = {};
|
||||
for (const [key, value] of url.searchParams) query[key] = value || true;
|
||||
|
||||
return { route, data: { params, query } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let current_token: {};
|
||||
|
||||
function render(Component: ComponentConstructor, data: any, scroll: ScrollPosition, token: {}) {
|
||||
if (current_token !== token) return;
|
||||
|
||||
if (component) {
|
||||
component.destroy();
|
||||
} else {
|
||||
// first load — remove SSR'd <head> contents
|
||||
const start = document.querySelector('#sapper-head-start');
|
||||
const end = document.querySelector('#sapper-head-end');
|
||||
|
||||
if (start && end) {
|
||||
while (start.nextSibling !== end) detach(start.nextSibling);
|
||||
detach(start);
|
||||
detach(end);
|
||||
}
|
||||
|
||||
// preload additional routes
|
||||
routes.reduce((promise: Promise<any>, route) => promise.then(route.load), Promise.resolve());
|
||||
}
|
||||
|
||||
component = new Component({
|
||||
target,
|
||||
data,
|
||||
hydrate: !!component
|
||||
});
|
||||
|
||||
if (scroll) {
|
||||
window.scrollTo(scroll.x, scroll.y);
|
||||
}
|
||||
}
|
||||
|
||||
function prepare_route(Component, data) {
|
||||
return Promise.resolve(
|
||||
Component.preload ? Component.preload(data) : {}
|
||||
).then(preloaded => {
|
||||
Object.assign(data, preloaded)
|
||||
return { Component, data };
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(url: URL, id: number) {
|
||||
const selected = select_route(url);
|
||||
if (selected) {
|
||||
if (id) {
|
||||
// popstate or initial navigation
|
||||
cid = id;
|
||||
} else {
|
||||
// clicked on a link. preserve scroll state
|
||||
scroll_history[cid] = scroll_state();
|
||||
|
||||
id = cid = ++uid;
|
||||
scroll_history[cid] = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const loaded = prefetching && prefetching.href === url.href ?
|
||||
prefetching.promise :
|
||||
selected.route.load().then(mod => prepare_route(mod.default, selected.data));
|
||||
|
||||
prefetching = null;
|
||||
|
||||
const token = current_token = {};
|
||||
|
||||
loaded.then(({ Component, data }) => {
|
||||
render(Component, data, scroll_history[id], token);
|
||||
});
|
||||
|
||||
cid = id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function handle_click(event: MouseEvent) {
|
||||
// Adapted from https://github.com/visionmedia/page.js
|
||||
// MIT license https://github.com/visionmedia/page.js#license
|
||||
if (which(event) !== 1) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
const a: HTMLAnchorElement | SVGAElement = <HTMLAnchorElement | SVGAElement>findAnchor(<Node>event.target);
|
||||
if (!a) return;
|
||||
|
||||
// check if link is inside an svg
|
||||
// in this case, both href and target are always inside an object
|
||||
const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString';
|
||||
const href = String(svg ? (<SVGAElement>a).href.baseVal : a.href);
|
||||
|
||||
if (href === window.location.href) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if tag has
|
||||
// 1. 'download' attribute
|
||||
// 2. rel='external' attribute
|
||||
if (a.hasAttribute('download') || a.getAttribute('rel') === 'external') return;
|
||||
|
||||
// Ignore if <a> has a target
|
||||
if (svg ? (<SVGAElement>a).target.baseVal : a.target) return;
|
||||
|
||||
const url = new URL(href);
|
||||
|
||||
// Don't handle hash changes
|
||||
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
||||
|
||||
if (navigate(url, null)) {
|
||||
event.preventDefault();
|
||||
history.pushState({ id: cid }, '', url.href);
|
||||
}
|
||||
}
|
||||
|
||||
function handle_popstate(event: PopStateEvent) {
|
||||
scroll_history[cid] = scroll_state();
|
||||
|
||||
if (event.state) {
|
||||
navigate(new URL(window.location.href), event.state.id);
|
||||
} else {
|
||||
// hashchange
|
||||
cid = ++uid;
|
||||
history.replaceState({ id: cid }, '', window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
let prefetching: {
|
||||
href: string;
|
||||
promise: Promise<{ Component: ComponentConstructor, data: any }>;
|
||||
} = null;
|
||||
|
||||
function prefetch(event: MouseEvent | TouchEvent) {
|
||||
const a: HTMLAnchorElement = <HTMLAnchorElement>findAnchor(<Node>event.target);
|
||||
if (!a || a.rel !== 'prefetch') return;
|
||||
|
||||
const selected = select_route(new URL(a.href));
|
||||
|
||||
if (selected) {
|
||||
prefetching = {
|
||||
href: a.href,
|
||||
promise: selected.route.load().then(mod => prepare_route(mod.default, selected.data))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let inited: boolean;
|
||||
|
||||
export function init(_target: Node, _routes: Route[]) {
|
||||
target = _target;
|
||||
routes = _routes;
|
||||
|
||||
if (!inited) { // this check makes HMR possible
|
||||
window.addEventListener('click', handle_click);
|
||||
window.addEventListener('popstate', handle_popstate);
|
||||
|
||||
// prefetch
|
||||
window.addEventListener('touchstart', prefetch);
|
||||
window.addEventListener('mouseover', prefetch);
|
||||
|
||||
inited = true;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const { hash, href } = window.location;
|
||||
|
||||
const deep_linked = hash && document.querySelector(hash);
|
||||
scroll_history[uid] = deep_linked ?
|
||||
{ x: 0, y: deep_linked.getBoundingClientRect().top } :
|
||||
scroll_state();
|
||||
|
||||
history.replaceState({ id: uid }, '', href);
|
||||
navigate(new URL(window.location.href), uid);
|
||||
});
|
||||
}
|
||||
|
||||
export function goto(href: string, opts = { replaceState: false }) {
|
||||
if (navigate(new URL(href, window.location.href), null)) {
|
||||
if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
23
src/runtime/interfaces.ts
Normal file
23
src/runtime/interfaces.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type Params = Record<string, string>;
|
||||
export type Query = Record<string, string | true>;
|
||||
export type RouteData = { params: Params, query: Query };
|
||||
|
||||
export interface ComponentConstructor {
|
||||
new (options: { target: Node, data: any, hydrate: boolean }): Component;
|
||||
preload: (data: { params: Params, query: Query }) => Promise<any>;
|
||||
};
|
||||
|
||||
export interface Component {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export type Route = {
|
||||
pattern: RegExp;
|
||||
params: (match: RegExpExecArray) => Record<string, string>;
|
||||
load: () => Promise<{ default: ComponentConstructor }>
|
||||
};
|
||||
|
||||
export type ScrollPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
19
src/runtime/utils.ts
Normal file
19
src/runtime/utils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function detach(node: Node) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
|
||||
export function findAnchor(node: Node) {
|
||||
while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG <a> elements have a lowercase name
|
||||
return node;
|
||||
}
|
||||
|
||||
export function which(event: MouseEvent) {
|
||||
return event.which === null ? event.button : event.which;
|
||||
}
|
||||
|
||||
export function scroll_state() {
|
||||
return {
|
||||
x: window.scrollX,
|
||||
y: window.scrollY
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a class='{{page === "home" ? "selected" : ""}}' href='/'>home</a></li>
|
||||
<li><a class='{{page === "about" ? "selected" : ""}}' href='/about'>about</a></li>
|
||||
|
||||
<!-- for the blog link, we're using rel=prefetch so that Sapper prefetches
|
||||
the blog data when we hover over the link or tap it on a touchscreen -->
|
||||
<li><a href='/'>home</a></li>
|
||||
<li><a href='/about'>about</a></li>
|
||||
<li><a href='/slow-preload'>slow preload</a></li>
|
||||
<li><a rel=prefetch class='{{page === "blog" ? "selected" : ""}}' href='/blog'>blog</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<script>
|
||||
import Layout from './_components/Layout.html';
|
||||
import { goto } from '../../../runtime/app.js';
|
||||
import { goto } from '../../../runtime.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -13,7 +13,7 @@ export function get(req, res, next) {
|
||||
if (slug in lookup) {
|
||||
res.set({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': `max-age=${30 * 60 * 1e3}` // cache for 30 minutes
|
||||
'Cache-Control': `no-cache`
|
||||
});
|
||||
|
||||
res.end(lookup[slug]);
|
||||
|
||||
9
test/app/routes/show-url.html
Normal file
9
test/app/routes/show-url.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<p>URL is {{url}}</p>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
preload({ url }) {
|
||||
return { url };
|
||||
}
|
||||
};
|
||||
</script>
|
||||
11
test/app/routes/slow-preload.html
Normal file
11
test/app/routes/slow-preload.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<h1>This page should never render</h1>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
preload() {
|
||||
return new Promise(fulfil => {
|
||||
window.fulfil = fulfil;
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { init } from '../../../runtime/app.js';
|
||||
import { init } from '../../../runtime.js';
|
||||
|
||||
// `routes` is an array of route objects injected by Sapper
|
||||
init(document.querySelector('#sapper'), __routes__);
|
||||
|
||||
@@ -11,7 +11,7 @@ run('development');
|
||||
|
||||
function run(env) {
|
||||
describe(`env=${env}`, function () {
|
||||
this.timeout(5000);
|
||||
this.timeout(20000);
|
||||
|
||||
let PORT;
|
||||
let server;
|
||||
@@ -208,6 +208,73 @@ function run(env) {
|
||||
|
||||
assert.ok(scrollY > 0, scrollY);
|
||||
});
|
||||
|
||||
it('reuses prefetch promise', async () => {
|
||||
await nightmare
|
||||
.goto(`${base}/blog`)
|
||||
.wait(() => window.READY)
|
||||
.wait(200);
|
||||
|
||||
const mouseover_requests = (await capture(async () => {
|
||||
await nightmare
|
||||
.mouseover('[href="/blog/what-is-sapper"]')
|
||||
.wait(200);
|
||||
})).map(r => r.url);
|
||||
|
||||
assert.deepEqual(mouseover_requests, [
|
||||
'/api/blog/what-is-sapper'
|
||||
]);
|
||||
|
||||
const click_requests = (await capture(async () => {
|
||||
await nightmare
|
||||
.click('[href="/blog/what-is-sapper"]')
|
||||
.wait(200);
|
||||
})).map(r => r.url);
|
||||
|
||||
assert.deepEqual(click_requests, []);
|
||||
});
|
||||
|
||||
it('cancels navigation if subsequent navigation occurs during preload', async () => {
|
||||
await nightmare
|
||||
.goto(base)
|
||||
.wait(() => window.READY)
|
||||
.click('a[href="/slow-preload"]')
|
||||
.wait(100)
|
||||
.click('a[href="/about"]')
|
||||
.wait(100);
|
||||
|
||||
assert.equal(
|
||||
await nightmare.path(),
|
||||
'/about'
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await nightmare.evaluate(() => document.querySelector('h1').textContent),
|
||||
'About this site'
|
||||
);
|
||||
|
||||
await nightmare
|
||||
.evaluate(() => window.fulfil({}))
|
||||
.wait(100);
|
||||
|
||||
assert.equal(
|
||||
await nightmare.path(),
|
||||
'/about'
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await nightmare.evaluate(() => document.querySelector('h1').textContent),
|
||||
'About this site'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes entire request object to preload', async () => {
|
||||
const html = await nightmare
|
||||
.goto(`${base}/show-url`)
|
||||
.evaluate(() => document.querySelector('p').innerHTML);
|
||||
|
||||
assert.equal(html, `URL is /show-url`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headers', () => {
|
||||
|
||||
@@ -10,12 +10,12 @@ describe('create_routes', () => {
|
||||
assert.deepEqual(
|
||||
routes.map(r => r.file),
|
||||
[
|
||||
'index.html',
|
||||
'about.html',
|
||||
'post/foo.html',
|
||||
'post/bar.html',
|
||||
'post/[id].html',
|
||||
'about.html',
|
||||
'[wildcard].html',
|
||||
'index.html'
|
||||
'[wildcard].html'
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -45,8 +45,8 @@ describe('create_routes', () => {
|
||||
assert.deepEqual(
|
||||
routes.map(r => r.file),
|
||||
[
|
||||
'e/f/g/h.html',
|
||||
'index.html'
|
||||
'index.html',
|
||||
'e/f/g/h.html'
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -71,4 +71,44 @@ describe('create_routes', () => {
|
||||
create_routes(['[foo].html', '[bar]/index.html']);
|
||||
}, /The \[foo\].html and \[bar\]\/index.html routes clash/);
|
||||
});
|
||||
|
||||
it('matches nested routes', () => {
|
||||
const route = create_routes(['settings/[submenu].html'])[0];
|
||||
|
||||
assert.deepEqual(route.exec('/settings/foo'), {
|
||||
submenu: 'foo'
|
||||
});
|
||||
|
||||
assert.deepEqual(route.exec('/settings'), {
|
||||
submenu: null
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers index routes to nested routes', () => {
|
||||
const routes = create_routes(['settings/[submenu].html', 'settings.html']);
|
||||
|
||||
assert.deepEqual(
|
||||
routes.map(r => r.file),
|
||||
['settings.html', 'settings/[submenu].html']
|
||||
);
|
||||
});
|
||||
|
||||
it('matches deeply nested routes', () => {
|
||||
const route = create_routes(['settings/[a]/[b]/index.html'])[0];
|
||||
|
||||
assert.deepEqual(route.exec('/settings/foo/bar'), {
|
||||
a: 'foo',
|
||||
b: 'bar'
|
||||
});
|
||||
|
||||
assert.deepEqual(route.exec('/settings/foo'), {
|
||||
a: 'foo',
|
||||
b: null
|
||||
});
|
||||
|
||||
assert.deepEqual(route.exec('/settings'), {
|
||||
a: null,
|
||||
b: null
|
||||
});
|
||||
});
|
||||
});
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"diagnostics": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitAny": true,
|
||||
"noEmitOnError": true,
|
||||
"allowJs": true,
|
||||
"lib": ["es5", "es6", "dom"],
|
||||
"importHelpers": true
|
||||
},
|
||||
"target": "ES5",
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
const path = require('path');
|
||||
const { src, dest, dev, entry } = require('../lib/config.js');
|
||||
const { dest, dev, entry } = require('../lib/config.js');
|
||||
|
||||
module.exports = {
|
||||
dev,
|
||||
@@ -25,7 +24,7 @@ module.exports = {
|
||||
entry: () => {
|
||||
return {
|
||||
main: entry.server
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
output: () => {
|
||||
|
||||
Reference in New Issue
Block a user