From b3027c581665671ee43b2f093b62cbe91cdc41c2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 25 Dec 2017 17:14:39 -0500 Subject: [PATCH] port runtime to typescript, move runtime/app.js to runtime.js --- .gitignore | 3 +- package-lock.json | 69 +++++++++++- package.json | 8 +- rollup.config.js | 17 +++ runtime/app.js | 217 +------------------------------------ src/runtime/index.ts | 198 +++++++++++++++++++++++++++++++++ src/runtime/interfaces.ts | 23 ++++ src/runtime/utils.ts | 19 ++++ test/app/routes/about.html | 2 +- test/app/templates/main.js | 2 +- tsconfig.json | 18 +++ 11 files changed, 356 insertions(+), 220 deletions(-) create mode 100644 rollup.config.js create mode 100644 src/runtime/index.ts create mode 100644 src/runtime/interfaces.ts create mode 100644 src/runtime/utils.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 7dcc88c..7b62003 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store node_modules cypress/screenshots -test/app/.sapper \ No newline at end of file +test/app/.sapper +runtime.js \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6b26666..54cd665 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1575426..9b8cf3c 100644 --- a/package.json +++ b/package.json @@ -30,14 +30,20 @@ "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", + "build": "rollup -c", + "dev": "rollup -cw" }, "repository": "https://github.com/sveltejs/sapper", "keywords": [ diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..77a4f03 --- /dev/null +++ b/rollup.config.js @@ -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') + }) + ] + } +]; \ No newline at end of file diff --git a/runtime/app.js b/runtime/app.js index 1654bca..43ebb81 100644 --- a/runtime/app.js +++ b/runtime/app.js @@ -1,215 +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 } }; - } - } -} - -let current_token; - -function render(Component, data, scroll, token) { - Promise.resolve( - Component.preload ? Component.preload(data) : {} - ).then(preloaded => { - if (current_token !== token) return; - - if (component) { - component.destroy(); - } else { - // first load — remove SSR'd 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], current_token = {}); - }); - - 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 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 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; - } -} \ No newline at end of file +console.error('sapper/runtime/app.js has been deprecated in favour of sapper/runtime.js'); +export * from '../runtime.js'; \ No newline at end of file diff --git a/src/runtime/index.ts b/src/runtime/index.ts new file mode 100644 index 0000000..063785b --- /dev/null +++ b/src/runtime/index.ts @@ -0,0 +1,198 @@ +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 = {}; +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 = {}; + for (const [key, value] of url.searchParams) query[key] = value || true; + + return { route, data: { params, query } }; + } + } +} + +let current_token: {}; + +function render(Component: ComponentConstructor, data: { query: Query, params: Params }, scroll: ScrollPosition, token: {}) { + Promise.resolve( + Component.preload ? Component.preload(data) : {} + ).then(preloaded => { + if (current_token !== token) return; + + if (component) { + component.destroy(); + } else { + // first load — remove SSR'd 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, 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: 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 }; + } + + selected.route.load().then(mod => { + render(mod.default, selected.data, scroll_history[id], current_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 = 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 = String(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 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: 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); + } +} + +function prefetch(event: MouseEvent | TouchEvent) { + const a: HTMLAnchorElement = 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); + }); + } +} + +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; + } +} \ No newline at end of file diff --git a/src/runtime/interfaces.ts b/src/runtime/interfaces.ts new file mode 100644 index 0000000..02d6b70 --- /dev/null +++ b/src/runtime/interfaces.ts @@ -0,0 +1,23 @@ +export type Params = Record; +export type Query = Record; +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; +}; + +export interface Component { + destroy: () => void; +} + +export type Route = { + pattern: RegExp; + params: (match: RegExpExecArray) => Record; + load: () => Promise<{ default: ComponentConstructor }> +}; + +export type ScrollPosition = { + x: number; + y: number; +}; \ No newline at end of file diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts new file mode 100644 index 0000000..cec5f69 --- /dev/null +++ b/src/runtime/utils.ts @@ -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 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 + }; +} \ No newline at end of file diff --git a/test/app/routes/about.html b/test/app/routes/about.html index 56342b9..911cb5e 100644 --- a/test/app/routes/about.html +++ b/test/app/routes/about.html @@ -12,7 +12,7 @@