}, unknown> {}
+ export default RouteLinkComponent
+}
diff --git a/src/RouterLink.svelte b/src/RouteLink.svelte
similarity index 50%
rename from src/RouterLink.svelte
rename to src/RouteLink.svelte
index 3421053..891073c 100644
--- a/src/RouterLink.svelte
+++ b/src/RouteLink.svelte
@@ -1,27 +1,28 @@
-
+
diff --git a/src/RouterViewport.svelte b/src/RouterViewport.svelte
deleted file mode 100644
index 0d7d6fb..0000000
--- a/src/RouterViewport.svelte
+++ /dev/null
@@ -1,89 +0,0 @@
-{#if componentNotChanged}
- {
- introstart(event)
- }}
- on:introend={(event)=> {
- introend(event)
- }}
- on:outrostart={(event)=> {
- outrostart(event)
- }}
- on:outroend={(event)=> {
- updateComponent()
- outroend(event)
- }}>
-
-
-{/if}
-
-
diff --git a/src/Viewport.d.ts b/src/Viewport.d.ts
new file mode 100644
index 0000000..40ad358
--- /dev/null
+++ b/src/Viewport.d.ts
@@ -0,0 +1,22 @@
+import type {EasingFunction, TransitionConfig} from 'svelte/types/runtime/transition'
+import {SvelteComponentTyped} from 'svelte'
+
+declare module 'Viewport.svelte' {
+ type CustomTransition =(node: Element, {isIntro, duration, easing, delay}: {
+ isIntro?: boolean,
+ duration?: number,
+ easing?: EasingFunction,
+ delay?: number
+ }?)=> TransitionConfig
+
+ interface ViewportProps {
+ router: SvelteRouter
+ duration?: number
+ delay?: number
+ easing?: EasingFunction
+ transition?: CustomTransition
+ }
+
+ class ViewportComponent extends SvelteComponentTyped}, unknown> {}
+ export default ViewportComponent
+}
diff --git a/src/Viewport.svelte b/src/Viewport.svelte
new file mode 100644
index 0000000..6c8fa82
--- /dev/null
+++ b/src/Viewport.svelte
@@ -0,0 +1,84 @@
+{#if isActualView}
+ {
+ updateComponent()
+ }}>
+
+
+{/if}
+
+
diff --git a/src/index.js b/src/index.js
index 80c64f7..1bacd7b 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,701 +1,3 @@
-import { writable, get as getStore } from 'svelte/store'
-
-export { default as RouterViewport } from './RouterViewport.svelte'
-export { default as RouterLink } from './RouterLink.svelte'
-
-function isValidTokenChar(code) {
- // a-z
- if (code >= 97 && code <= 122) {
- return true
- }
- // A-Z
- if (code >= 65 && code <= 90) {
- return true
- }
- // 0-9
- if (code >= 48 && code <= 57) {
- return true
- }
-
- switch (code) {
- case 33: // ! Exclamation mark
- case 36: // $ Dollar sign
- case 38: // & Ampersand
- case 39: // ' Apostrophe
- case 40: // ( Left parenthesis
- case 41: // ) Right parenthesis
- case 42: // * Asterisk
- case 43: // + Plus sign
- case 44: // , Comma
- case 45: // - Hyphen
- case 46: // . Period
- case 59: // ; Semicolon
- case 61: // = Equals sign
- case 64: // @ At
- case 95: // _ Underscore
- case 126: // ~ Tilde
- return true
- }
-
- return false
-}
-
-function parsePathTemplate(template) {
- if (typeof template !== 'string') {
- return new Error(`unexpected type (${typeof template})`)
- }
-
- if (template.length < 1) {
- return new Error(`invalid path (empty)`)
- }
-
- const templObject = {
- tokens: [],
- parameters: [],
- }
-
- const regToken = (isParam, begin, end) => {
- const slice = template.substr(begin, end-begin)
-
- if (isParam) {
- if (slice.length < 1) {
- return new Error(`missing parameter name at ${begin}`)
- }
-
- if (slice in templObject.parameters) {
- return new Error(`redeclared parameter '${slice}' at ${begin}`)
- }
-
- if (isParam) {
- templObject.parameters.push(slice)
- }
- }
-
- templObject.tokens.push({
- token: slice,
- param: isParam,
- })
- }
-
- if (template.charCodeAt(0) != 47) {
- return new Error('a path template must begin with a slash')
- }
-
- let isPreviousSlash = true
- let isStatic = false
- let isParam = false
- let tokenStart = 1
-
- for (let itr = 0; itr < template.length; itr++) {
- const charCode = template.charCodeAt(itr)
-
- if (isPreviousSlash) {
- // Ignore multiple slashes
- if (charCode == 47) {
- continue
- }
- isPreviousSlash = false
-
- // Start scanning parameter
- if (charCode == 58) {
- isStatic = false
- isParam = true
- tokenStart = itr+1
- }
- // Start scanning static token
- else if (isValidTokenChar(charCode)) {
- isStatic = true
- isParam = false
- tokenStart = itr
- }
- else {
- return new Error(
- `unexpected '${String.fromCharCode(charCode)}' at ${itr}`
- )
- }
- }
- else if (charCode == 47) {
- // Terminating slash encountered
- isPreviousSlash = true
-
- const err = regToken(isParam, tokenStart, itr)
- if (err != null) {
- return err
- }
-
- isStatic = false
- isParam = false
- }
- else if (!isValidTokenChar(charCode)) {
- return new Error(
- `unexpected '${String.fromCharCode(charCode)}' at ${itr}`
- )
- }
-
- if (itr+1 >= template.length) {
- // Last character reached
- if (isPreviousSlash) {
- break
- }
-
- if (charCode == 58) {
- return new Error(`missing parameter name at ${itr}`)
- }
-
- const err = regToken(isParam, tokenStart, template.length)
- if (err != null) {
- return err
- }
- }
- }
-
- return templObject
-}
-
-function validateRouteName(routeName) {
- if (routeName.length < 1) {
- return new Error(`invalid route name (empty)`)
- }
-
- const charCode = routeName.charCodeAt(0)
- if (
- /*A-Z*/ (charCode < 65 && charCode > 90) &&
- /*a-z*/ (charCode < 97 && charCode > 122)
- ) {
- return new Error(
- `unexpected character ${String.fromCharCode(charCode)} ` +
- `in route name at 0 (leading character must be [A-Za-z])`
- )
- }
-
- for (let itr = 1; itr < routeName.length; itr++) {
- const charCode = routeName.charCodeAt(itr)
-
- // A-Z
- if (charCode >= 65 && charCode <= 90) {
- continue
- }
- // a-z
- if (charCode >= 97 && charCode <= 122) {
- continue
- }
- // 0-9
- if (charCode >= 48 && charCode <= 57) {
- continue
- }
-
- switch (charCode) {
- case 45: // - Hyphen
- case 46: // . Period
- case 95: // _ Underscore
- continue
- }
-
- return new Error(
- `unexpected character ${String.fromCharCode(charCode)} ` +
- `in route name at ${itr}`
- )
- }
-}
-
-function parseURLPath(path, urlParams) {
- if (typeof path !== 'string') {
- return new Error(`unexpected type (${typeof path})`)
- }
-
- if (path.length < 1) {
- return new Error(`invalid path (empty)`)
- }
-
- const pathTokens = []
-
- // Check if path begin with a slash
- if (path.charCodeAt(0) != 47) {
- return new Error('a path path must begin with a slash')
- }
-
- let isPreviousSlash = true
- let tokenStart = 1
-
- for (let itr = 1; itr < path.length; itr++) {
- const charCode = path.charCodeAt(itr)
-
- if (isPreviousSlash) {
- // Ignore multiple slashes
- if (charCode == 47) {
- continue
- }
- isPreviousSlash = false
-
- // Start scanning token
- if (isValidTokenChar(charCode)) {
- tokenStart = itr
- }
- else {
- return new Error(
- `unexpected '${String.fromCharCode(charCode)}' at ${itr}`
- )
- }
- }
- // Terminating slash encountered
- else if (charCode == 47) {
- isPreviousSlash = true
- pathTokens.push(
- path.substr(
- tokenStart,
- itr-tokenStart,
- )
- )
- }
- else if (!isValidTokenChar(charCode)) {
- return new Error(
- `unexpected '${String.fromCharCode(charCode)}' at ${itr}`
- )
- }
-
- if (itr+1 >= path.length) {
- // Last character reached
- if (isPreviousSlash) {
- break
- }
- pathTokens.push(
- path.substr(
- tokenStart,
- path.length-tokenStart
- )
- )
- }
- }
-
- let urlParamTokens = null
-
- if (urlParams) {
- urlParamTokens = {}
- let question = urlParams.indexOf('?')
- let hash = urlParams.indexOf('#')
- if(hash == -1 && question == -1) {
- return {}
- }
- if(hash == -1) {
- hash = urlParams.length
- }
- let query = (
- question == -1 ||
- hash == question + 1 ?
- urlParams.substring(hash)
- : urlParams.substring(question + 1, hash)
- )
- let result = {}
- query.split('&').forEach((part)=> {
- if(!part) {
- return
- }
- // replace every + with space, regexp-free version
- part = part.split("+").join(' ')
-
- let eq = part.indexOf('=')
- let key = eq >- 1 ?
- part.substr(0,eq) : part
- let val = eq >- 1 ?
- decodeURIComponent(part.substr(eq+1)) : ''
-
- let from = key.indexOf('[')
- if (from == -1) {
- urlParamTokens[decodeURIComponent(key)] = val
- }
- else {
- let to = key.indexOf(']',from)
- let index = decodeURIComponent(
- key.substring(from + 1, to)
- )
- key = decodeURIComponent(
- key.substring(0, from)
- )
- if(!urlParamTokens[key]) {
- urlParamTokens[key] = []
- }
- if(!index) {
- urlParamTokens[key].push(val)
- }
- else {
- urlParamTokens[key][index] = val
- }
- }
- })
- }
- return { pathTokens, urlParamTokens }
-}
-
-export function Router(conf) {
- if (conf.routes == null || conf.routes.length < 1) {
- throw new Error('missing routes')
- }
-
- const _window = (function() {
- if (conf.window == null) {
- throw new Error('missing window reference')
- }
- return conf.window
- })();
- const eventRouteUpdated = new CustomEvent('routeUpdated')
- const _templates = {}
- const _routes = {}
- const _index = {
- routeName: null,
- param: null,
- routes: {},
- component: null,
- }
-
- const _beforePush = conf.beforePush !== undefined ?
- conf.beforePush : null
-
- const _fallbackRoute = conf.fallback
- // if redirect is not set then it's false
- if (_fallbackRoute && _fallbackRoute.redirect == undefined) {
- _fallbackRoute.redirect = false
- }
-
- const {
- subscribe: storeSubscribe,
- update: storeUpdate,
- } = writable({
- routes: [],
- route: {
- name: '',
- params: {},
- component: null,
- },
- })
-
- for (const routeName in conf.routes) {
- const route = conf.routes[routeName]
- const template = route.path
-
- // Ensure route name validity
- let err = validateRouteName(routeName)
- if (err instanceof Error) {
- throw err
- }
-
- // Ensure route name uniqueness
- if (routeName in _routes) {
- throw new Error(`redeclaration of route ${routeName}`)
- }
-
- // Parse path and ensure it's validity
- const path = parsePathTemplate(template)
- if (path instanceof Error) {
- throw new Error(
- `route ${routeName} defines an invalid path template: ${path}`
- )
- }
-
- const entry = {
- path,
- component: route.component || null,
- metadata: route.metadata || null,
- }
-
- // Ensure path template uniqueness
- if (!(template in _templates)) {
- _templates[template] = entry
- }
- _routes[routeName] = entry
-
- let currentNode = _index
- if (path.tokens.length <= 0) {
- currentNode.routeName = routeName
- }
- else for (let level = 0; level < path.tokens.length; level++) {
- const token = path.tokens[level]
-
- if (token.param) {
- // Follow node
- if (currentNode.param != null) {
- currentNode = currentNode.param
- }
- // Initialize parameterized branch
- else {
- const newNode = {
- routeName,
- name: token.token,
- param: null,
- routes: {},
- metadata: route.metadata,
- component: null,
- }
- currentNode.param = newNode
- currentNode = newNode
- }
- }
- else {
- const routeNode = currentNode.routes[token.token]
- // Declare static route node
- if (!routeNode) {
- const newNode = {
- routeName,
- param: null,
- routes: {},
- metadata: route.metadata,
- component: null,
- }
- currentNode.routes[token.token] = newNode
- currentNode = newNode
- }
- // Follow node
- else {
- currentNode = routeNode
- }
- }
- }
- currentNode.component = entry.component
- }
-
- storeUpdate(store => {
- for (let route in _routes) {
- store.routes.push({
- name: route,
- ..._routes[route],
- })
- }
- return store
- })
-
- function verifyNameAndParams(name, params) {
- if (name === undefined) {
- throw new Error('missing parameter name')
- }
- const route = _routes[name]
- if (route == null) {
- throw new Error(`route '${name}' not found`)
- }
-
- const paramNames = route.path.parameters
- if (paramNames.length > 0) {
- if (!params) {
- throw new Error(`missing parameters: ${paramNames}`)
- }
-
- // Parameters expected
- for (const paramName of route.path.parameters) {
- if (!(paramName in params)) {
- throw new Error(`missing parameter '${paramName}'`)
- }
- }
- }
-
- return route
- }
-
- function getRoute(path, urlParams) {
- const parsedURLPath = parseURLPath(path, urlParams)
- if (parsedURLPath instanceof Error) {
- return parsedURLPath
- }
- const tokens = parsedURLPath.pathTokens
- const urlParamTokens = parsedURLPath.urlParamTokens
- let currentNode = _index
- const params = {}
-
- if (tokens.length === 0) {
- if (currentNode.routeName == null) {
- return new Error(`path ${path} doesn't resolve any route`)
- }
- return {
- name: currentNode.routeName,
- urlParams: urlParamTokens,
- component: currentNode.component,
- }
- }
- else for (let level = 0; level < tokens.length; level++) {
- const token = tokens[level]
-
- // tokens is a static route
- if (token in currentNode.routes) {
- currentNode = currentNode.routes[token]
- }
- // parameter route
- else if(currentNode.param) {
- currentNode = currentNode.param
- params[currentNode.name] = token
- }
- else {
- return new Error(`path ${path} doesn't resolve any route`)
- }
-
- // is last token
- if (level + 1 >= tokens.length) {
- // display component
- if (currentNode.component) {
- return {
- name: currentNode.routeName,
- params,
- urlParams: urlParamTokens,
- component: currentNode.component
- }
- }
- else {
- return new Error(`path ${path} doesn't resolve any route`)
- }
- }
- }
- }
-
- function stringifyRoutePath(tokens, params, urlParams) {
- let str = ''
- if (tokens.length < 1) {
- return '/'
- }
- for (const idx in tokens) {
- const token = tokens[idx]
- if (token.param && !params) {
- throw new Error(
- `expected parameter '${token.token}' but got '${params}'`
- )
- }
- str += token.param ? `/${params[token.token]}` : `/${token.token}`
- }
- if (urlParams) {
- const urlParamsLen = Object.keys(urlParams).length
- let itr = 0
- if (urlParamsLen > 0) {
- str += '?'
- for (const param in urlParams) {
- str += param +'='+ urlParams[param]
- if (itr < urlParamsLen - 1) {
- str += '&'
- }
- itr++
- }
- }
- }
- return str
- }
-
- function nameToPath(name, params, urlParams) {
- if (name && name === '') {
- throw new Error(`invalid name: '${name}'`)
- }
- return stringifyRoutePath(
- _routes[name].path.tokens,
- params,
- urlParams,
- )
- }
-
- // setCurrentRoute executes the beforePush hook (if any), updates the
- // current route pushing the path to the browser history if the current
- // browser URL doesn't match and returns the name and parameters of
- // the route that was finally selected
- function setCurrentRoute(path, name, params, urlParams, redirect = true) {
- let route = verifyNameAndParams(name, params)
-
- if (_beforePush !== null) {
- let prevRoute = getStore({subscribe: storeSubscribe}).route
- if (prevRoute.name === '' && prevRoute.component === null) {
- prevRoute = null
- }
- const beforePushRes = _beforePush(name, params, urlParams, prevRoute)
-
- if (beforePushRes === false) {
- return false
- }
- else if (beforePushRes === null) {
- throw new Error(
- 'beforePush must return either false ' +
- 'or {name, ?params, ?urlParams}' +
- `; returned: ${beforePushRes}`,
- )
- }
- else if (beforePushRes !== null) {
- if (!beforePushRes.hasOwnProperty("name")) {
- throw new Error(
- 'beforePush must return either false ' +
- 'or {name, ?params, ?urlParams}' +
- `; returned: ${JSON.stringify(beforePushRes)}`,
- )
- }
- name = beforePushRes.name
- params = beforePushRes.params
- urlParams = beforePushRes.urlParams
- path = nameToPath(name, params, urlParams)
- }
-
- route = verifyNameAndParams(name, params)
- }
-
- // Update store
- storeUpdate(store => {
- store.route = {
- name,
- params,
- urlParams,
- component: route.component,
- metadata: route.metadata,
- }
- return store
- })
-
- // Reconstruct path from route tokens and parameters if non is given
- if (path == null) {
- path = stringifyRoutePath(route.path.tokens, params, urlParams)
- }
-
- if (
- redirect &&
- _window.location.pathname + _window.location.search != path
- ) {
- _window.history.pushState({name, params, urlParams}, null, path)
- }
-
- return {name, path, params, urlParams}
- }
-
- function push(name, params, urlParams) {
- return setCurrentRoute(null, name, params, urlParams)
- }
-
- function navigate(path, urlParams) {
- const route = getRoute(path, urlParams)
- if (route instanceof Error) {
- if (_fallbackRoute != null) {
- return setCurrentRoute(
- null,
- _fallbackRoute.name,
- _fallbackRoute.params,
- route.urlParams,
- _fallbackRoute.redirect,
- )
- }
- else {
- throw route
- }
- }
-
- return setCurrentRoute(path, route.name, route.params, route.urlParams)
- }
-
- _window.addEventListener('popstate', () => {
- navigate(_window.location.pathname, _window.location.search)
- _window.dispatchEvent(eventRouteUpdated)
- })
-
- Object.defineProperties(this, {
- subscribe: { value: storeSubscribe },
- push: { value: (name, params) => {
- push(name, params)
- _window.dispatchEvent(eventRouteUpdated)
- }},
- back: { value: () => _window.history.back() },
- forward: { value: () => _window.history.forward() },
- nameToPath: { value: nameToPath },
- navigate: { value: path => {
- navigate(path)
- _window.dispatchEvent(eventRouteUpdated)
- }},
- })
-
- // Initialize current route
- navigate(_window.location.pathname, _window.location.search)
-}
+export * from './router'
+export {default as Viewport} from './Viewport.svelte'
+export {default as RouteLink} from './RouteLink.svelte'
diff --git a/src/router.ts b/src/router.ts
new file mode 100644
index 0000000..966ee91
--- /dev/null
+++ b/src/router.ts
@@ -0,0 +1,1064 @@
+import {writable, get as getStore, Readable, Writable} from 'svelte/store'
+import {SvelteComponent, tick, getContext} from 'svelte'
+
+export {default as Viewport} from './Viewport.svelte'
+export {default as RouteLink} from './RouteLink.svelte'
+
+
+
+// Type Definitionzs ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+enum Char {
+ CapitalA = 'A',
+ CapitalZ = 'Z',
+ LowerA = 'a',
+ LowerZ = 'z',
+ ExclamationMark = '!',
+ Dollar = '$',
+ Ampersand = '&',
+ Apostrophe = "'",
+ LeftParenthesis = '(',
+ RightParenthesis = ')',
+ LeftBracket = '[',
+ RightBracket = ']',
+ Asterisk = '*',
+ Plus = '+',
+ Comma = ',',
+ Hyphen = '-',
+ Period = '.',
+ Semicolon = ';',
+ Colon = ':',
+ Equals = '=',
+ At = '@',
+ Underscore = '_',
+ Tilde = '~',
+ Slash = '/',
+ Backslash = '\\',
+ Space = ' ',
+ Hash = '#',
+ QuestionMark = '?',
+}
+
+export type RouteParams = {[key: string]: string}
+
+export type RouterLocation = {
+ path: string
+ name: string
+ params?: RouteParams
+ urlQuery?: RouteParams
+ component?: typeof SvelteComponent
+ props?: unknown
+}
+
+export type RouterActualRoute = {
+ path: string
+ name: string
+ params?: RouteParams
+ urlQuery?: RouteParams
+}
+
+export type RouterRouteData = {
+ name: string
+ params?: RouteParams
+ urlQuery?: RouteParams
+}
+
+export type RouterBeforePush = (args: {
+ pendingRoute: RouterRouteData,
+ location?: RouterLocation,
+ resolve: ()=> void
+ reject: (newRoute?: RouterRouteData)=> void
+})=> void
+
+export type RouterFallback = {
+ name: string
+ replace?: boolean
+}
+
+export interface RouterConfig {
+ window: Window
+ beforePush?: RouterBeforePush
+ fallback?: RouterFallback
+ restoreScroll?: boolean
+ routes: {[routeName: string]: {
+ path: string
+ component: typeof SvelteComponent
+ props?: unknown
+ }}
+}
+
+type PathTemplate = {
+ tokens: Array
+ params: Array
+}
+
+export type RouterRoute = {
+ path: PathTemplate
+ component: typeof SvelteComponent
+ props: unknown
+}
+
+type Router_Index = {
+ name: string
+ token: string
+ param?: Router_Index
+ component?: typeof SvelteComponent
+ routes: {[token: string]: Router_Index}
+}
+
+type Router_Internal = {
+ routes: {[token: string]: RouterRoute}
+ index: Router_Index
+}
+
+export type Router = {
+ isLoading: boolean
+ routes: {[routeName: string]: RouterRoute}
+ location: RouterLocation
+}
+
+
+
+// Svelte Router implementation ::::::::::::::::::::::::::::::::::::::::::::::::
+
+export class SvelteRouter implements Readable {
+ #store: Writable = writable({
+ isLoading: true,
+ routes: {},
+ location: {
+ path: '',
+ name: '',
+ params: undefined,
+ urlQuery: undefined,
+ component: undefined,
+ props: undefined,
+ },
+ })
+ public readonly subscribe = this.#store.subscribe
+
+ #internalStore: Writable = writable({
+ routes: {},
+ index: {
+ name: '',
+ token: '',
+ param: undefined,
+ component: undefined,
+ routes: {},
+ },
+ })
+
+ private _window: Window
+ private _beforePush: {[id: string]: RouterBeforePush} = {}
+ private _beforePushOrder: Array = []
+ private _fallback?: RouterFallback
+ private _routeUpdatedEventName = 'routeUpdated'
+ private _restoreScroll = true
+ private _globalBeforePushHookID
+
+ constructor(conf: RouterConfig) {
+ if (!conf.routes || Object.keys(conf.routes).length < 1) {
+ throw new Error('[SvelteRouter] missing routes')
+ }
+
+ // Check if required window API points exist
+ if (
+ !conf.window?.location ||
+ !conf.window?.history ||
+ !conf.window?.addEventListener ||
+ !conf.window.removeEventListener ||
+ !conf.window?.dispatchEvent
+ ) {
+ throw new Error(
+ '[SvelteRouter] invalid window, not implementing required ' +
+ 'API points [location, history, addEventListener, ' +
+ 'removeEventListener dispatchEvent]'
+ )
+ }
+ this._window = conf.window
+
+ if (conf.beforePush) {
+ this._globalBeforePushHookID = '__gloal_before_push_hook'
+ this._beforePush[this._globalBeforePushHookID] = conf.beforePush
+ this._beforePushOrder.push(this._globalBeforePushHookID)
+ }
+ if (conf.fallback) {
+ this._fallback = conf.fallback
+ if (typeof this._fallback.replace !== 'boolean') {
+ this._fallback.replace = true
+ }
+ }
+ if (conf.restoreScroll === false) {
+ this._restoreScroll = false
+ }
+
+ let err = null
+ this.#internalStore.update(($intStr)=> {
+ for (const routeName in conf.routes) {
+ const route = conf.routes[routeName]
+ const pathTemplate = route.path
+
+ // Ensure route name validity
+ err = validateRouteName(routeName)
+ if (err !== null) return $intStr
+
+ // Ensure route name uniqueness
+ if (routeName in $intStr.routes) {
+ err = new Error(`redeclaration of route "${routeName}"`)
+ return $intStr
+ }
+
+ // Parse path and ensure it's validity
+ let path: PathTemplate
+ try {
+ path = parsePathTemplate(pathTemplate)
+ } catch(e) {
+ err = new Error(
+ `route "${routeName}" defines an invalid path template: ${e}`
+ )
+ return $intStr
+ }
+
+ // Ensure path template uniqueness
+ if (pathTemplate in $intStr.routes) {
+ err = new Error(`duplicate of route "${routeName}"`)
+ return $intStr
+ }
+ $intStr.routes[routeName] = {
+ path: path,
+ component: route.component,
+ props: route.props,
+ }
+
+ let currentNode = $intStr.index
+ if (path.tokens.length <= 0) {
+ currentNode.name = routeName
+ }
+ else for (let level = 0; level < path.tokens.length; level++) {
+ const token = path.tokens[level]
+
+ if (path.params.includes(token)) {
+ // Follow node
+ if (currentNode.param != null) {
+ currentNode = currentNode.param
+ }
+ // Initialize parameterized branch
+ else {
+ const newNode = {
+ name: routeName,
+ token: token,
+ param: undefined,
+ routes: {},
+ props: route.props,
+ component: undefined,
+ }
+ currentNode.param = newNode
+ currentNode = newNode
+ }
+ }
+ else {
+ const routeNode = currentNode.routes[token]
+ // Declare static route node
+ if (!routeNode) {
+ const newNode = {
+ name: routeName,
+ token: token,
+ param: undefined,
+ routes: {},
+ props: route.props,
+ component: undefined,
+ }
+ currentNode.routes[token] = newNode
+ currentNode = newNode
+ }
+ // Follow node
+ else {
+ currentNode = routeNode
+ }
+ }
+ }
+ currentNode.component = $intStr.routes[routeName].component
+ }
+ return $intStr
+ })
+ if (err !== null) throw err
+
+ this.#store.update(($rtrStr)=> {
+ const routes = getStore(this.#internalStore).routes
+ for (const routeName in routes) {
+ $rtrStr.routes[routeName] = routes[routeName]
+ }
+ return $rtrStr
+ })
+
+ this._window.addEventListener(
+ 'popstate', this._onPopState.bind(this), {passive: true},
+ )
+
+ const currentPath = (
+ this._window.location.pathname +
+ this._window.location.search
+ )
+
+ // Initialize current route
+ const historyState = this._window.history.state
+ if (historyState?.name) {
+ try {
+ this.verifyNameAndParams(
+ historyState.name, historyState.params,
+ )
+ this.setCurrentRoute(
+ currentPath,
+ historyState.name,
+ historyState.params,
+ historyState.urlQuery,
+ true,
+ )
+ } catch(e) {
+ this.pushPath(currentPath)
+ }
+ }
+ else this.pushPath(currentPath)
+ }
+
+ private _dispatchRouteUpdated() {
+ this._window.dispatchEvent(
+ new CustomEvent(
+ this._routeUpdatedEventName,
+ {detail: getStore(this.#store).location},
+ )
+ )
+ }
+
+ private async _onPopState() {
+ const state = this._window.history.state
+ this.push(
+ state.name,
+ state.params,
+ state.urlQuery,
+ )
+ this._dispatchRouteUpdated()
+ await tick()
+ setTimeout(()=> {
+ if (state.scroll) {
+ this._window.scrollTo({
+ left: state.scroll[0],
+ top: state.scroll[1],
+ })
+ }
+ })
+ }
+
+ /**
+ * addBeforePushHook removes the hook by the given ID from the router.
+ * Throws an error on not exsisting hook ID.
+ *
+ * @param hookID string
+ * @param hook Function
+ * @returns Function: void
+ */
+ private removeBeforePush(hookID: string): void {
+ const hookIdx = this._beforePushOrder.indexOf(hookID)
+ if (
+ hookIdx < 0 ||
+ (this._globalBeforePushHookID &&
+ this._globalBeforePushHookID === hookID)
+ ) {
+ throw new Error(
+ `[SvelteRouter] hook by ID "${hookID}" not subscribed`
+ )
+ }
+ delete this._beforePush[hookID]
+ this._beforePushOrder.splice(hookIdx, 1)
+ }
+
+ /**
+ * addBeforePushHook appends a hook to the router
+ *
+ * @param hookID string
+ * @param hook Function
+ * @returns Function: void
+ */
+ public addBeforePushHook(
+ hookID: string, hook: RouterBeforePush,
+ ): ()=> void {
+ if (this._beforePushOrder.includes(hookID)) {
+ throw new Error(
+ `[SvelteRouter] before push hook by ID "${hookID}" ` +
+ `is already existing`
+ )
+ }
+ this._beforePushOrder.push(hookID)
+ this._beforePush[hookID] = hook
+ return ()=> this.removeBeforePush(hookID)
+ }
+
+ /**
+ * verifyNameAndParams
+ * @param routeName string
+ * @param params RouteParams
+ * @returns RouterRoute
+ */
+ public verifyNameAndParams(
+ routeName: string, params?: RouteParams,
+ ): RouterRoute {
+ if (!routeName) {
+ throw new Error('missing parameter name')
+ }
+ const route = getStore(this.#internalStore).routes[routeName]
+ if (route == null) {
+ throw new Error(`route "${routeName}" not found`)
+ }
+
+ const paramNames = route.path.params
+ if (paramNames.length > 0) {
+ if (!params) {
+ throw new Error(`missing parameters: ${paramNames}`)
+ }
+
+ // Parameters expected
+ for (const paramName of route.path.params) {
+ if (!(paramName in params)) {
+ throw new Error(`missing parameter '${paramName}'`)
+ }
+ }
+ }
+
+ return route
+ }
+
+ /**
+ * getRoute parses the given URL and returns the matching route.
+ * It throws an error if no matching route was found.
+ * @param path
+ * @param urlQuery
+ * @returns RouterRouteData
+ * @throws Error
+ */
+ public getRoute(url: string): RouterRouteData {
+ const {pathTokens, urlQuery} = parseURLPath(url)
+
+ let currentNode = getStore(this.#internalStore).index
+ const params: {[token: string]: string} = {}
+
+ if (pathTokens.length === 0) {
+ if (currentNode.name == null) {
+ throw new Error(`URL "${url}" doesn't resolve any route`)
+ }
+ return {name: currentNode.name}
+ }
+ else for (let level=0; level < pathTokens.length; level++) {
+ const token = pathTokens[level]
+
+ // tokens is a static route
+ if (token in currentNode.routes) {
+ currentNode = currentNode.routes[token]
+ }
+ // parameter route
+ else if (currentNode.param) {
+ currentNode = currentNode.param
+ params[currentNode.token] = token
+ }
+ else throw new Error(
+ `URL "${url}" doesn't resolve any route`
+ )
+
+ // is last token
+ if (level+1 >= pathTokens.length) {
+ // display component
+ if (currentNode.component) {
+ return {
+ name: currentNode.name,
+ params: params || undefined,
+ urlQuery: urlQuery || undefined,
+ }
+ }
+ else throw new Error(
+ `URL "${url}" doesn't resolve any route`
+ )
+ }
+ }
+ throw new Error('unexpected')
+ }
+
+ /**
+ * stringifyRouteToURL parses a route into a URL by its path template,
+ * its corresponding parameters and a optionally URL query.
+ *
+ * @param path PathTemplate
+ * @param params RouteParams
+ * @param urlQuery RouteParams
+ * @returns string
+ * @throws Error
+ */
+ public stringifyRouteToURL(
+ path: PathTemplate, params?: RouteParams, urlQuery?: RouteParams,
+ ) {
+ let str = ''
+ if (path.tokens.length < 1) return '/'
+
+ for (const token of path.tokens) {
+ const isParam = path.params.includes(token)
+ if (isParam) {
+ if (params !== undefined) {
+ str += `/${params[token]}`
+ }
+ else throw new Error(
+ `expected parameter '${token}' but got '${params}'`
+ )
+ }
+ else {
+ str += `/${token}`
+ }
+ }
+
+ if (urlQuery) {
+ const queryLen = Object.keys(urlQuery).length
+ if (queryLen > 0) {
+ str += '?'
+ let itr = 0
+ for (const param in urlQuery) {
+ str += param +'='+ urlQuery[param]
+ if (itr < queryLen-1) str += '&'
+ itr++
+ }
+ }
+ }
+ return str
+ }
+
+ /**
+ * nameToPath parses a route into a URL by its name, its corresponding
+ * parameters and optionally a URL query.
+ *
+ * @param name string
+ * @param params RouteParams
+ * @param urlQuery RouteParams
+ * @returns string
+ * @throws Error
+ */
+ public nameToPath(
+ routeName: string, params?: RouteParams, urlQuery?: RouteParams,
+ ): string {
+ const routes = getStore(this.#internalStore).routes
+ if (
+ typeof routeName !== 'string' ||
+ routeName === '' ||
+ !(routeName in routes)
+ ) {
+ throw new Error(`invalid route name: '${routeName}'`)
+ }
+ return this.stringifyRouteToURL(
+ getStore(this.#internalStore).routes[routeName].path,
+ params,
+ urlQuery,
+ )
+ }
+
+ /**
+ * setCurrentRoute executes the beforePush hooks (if any), updates the
+ * current route, pushing the path to the browser history, (if the current
+ * browser URL doesn't match) and returns the name and parameters of
+ * the route that was finally selected
+ *
+ * @param path string
+ * @param name string
+ * @param params RouteParams
+ * @param urlQuery RouteParmas
+ * @param replace boolean
+ * @returns RouterActualRoute
+ * @throws Error
+ */
+ private async setCurrentRoute(
+ path: string, name: string, params?: RouteParams,
+ urlQuery?: RouteParams, replace = false,
+ ): Promise {
+ let route = this.verifyNameAndParams(name, params)
+
+ this.#store.update(($rtrStr)=> {
+ $rtrStr.isLoading = true
+ return $rtrStr
+ })
+
+ if (this._beforePushOrder.length > 0) {
+ const location: RouterLocation = (
+ getStore(this.#store).location
+ )
+
+ for (const hookID of this._beforePushOrder) {
+ try {
+ await new Promise((resolve, reject)=> {
+ this._beforePush[hookID]({
+ pendingRoute: {name, params, urlQuery},
+ location,
+ resolve: ()=> resolve(),
+ reject,
+ })
+ })
+ } catch(newRoute) {
+ if (newRoute === undefined) {
+ return {
+ name: location.name,
+ path: this.nameToPath(name, params, urlQuery),
+ params: location.params,
+ urlQuery: location.urlQuery,
+ }
+ }
+
+ if (!(newRoute as RouterRouteData)?.name) {
+ throw new Error(
+ 'before-push hook must reject with a return of a ' +
+ `new route; returned: ${JSON.stringify(newRoute)}`,
+ )
+ }
+ const nR = newRoute as RouterRouteData
+ name = nR.name
+ params = nR.params
+ urlQuery = nR.urlQuery
+ path = this.nameToPath(name, params, urlQuery)
+ route = this.verifyNameAndParams(name, params)
+
+ break
+ }
+ }
+ }
+
+ // Reconstruct path from route tokens and parameters if non is given
+ if ((this._fallback && name !== this._fallback.name) && path === '') {
+ path = this.stringifyRouteToURL(route.path, params, urlQuery)
+ }
+
+ // Update store
+ this.#store.update(($rtrStr)=> {
+ $rtrStr.isLoading = false
+ $rtrStr.location = {
+ name,
+ path,
+ params,
+ urlQuery,
+ component: route.component,
+ props: route.props,
+ }
+ return $rtrStr
+ })
+
+ if (replace) {
+ this._window.history.replaceState({
+ name, params, urlQuery,
+ }, '')
+ }
+ else if (
+ path != (
+ this._window.location.pathname +
+ this._window.location.search
+ )
+ ) {
+ const prevState = this._window.history.state
+ if (prevState && this._restoreScroll) {
+ this._window.history.replaceState({
+ name: prevState.name,
+ params: prevState.params,
+ urlQuery: prevState.urlQuery,
+ scroll: [this._window.scrollX, this._window.scrollY],
+ }, '')
+ }
+
+ this._window.history.pushState(
+ {name, params, urlQuery}, '', path,
+ )
+ this._window.scrollTo({top: 0, left: 0})
+ }
+
+ this._dispatchRouteUpdated()
+ return {name, path, params, urlQuery}
+ }
+
+ public push(
+ name: string, params?: RouteParams, rawUrlQuery?: RouteParams|string,
+ ) {
+ let urlQuery: RouteParams|undefined
+ if (typeof rawUrlQuery === 'string') {
+ urlQuery = parseUrlQuery(rawUrlQuery)
+ }
+ return this.setCurrentRoute('', name, params, urlQuery)
+ }
+
+ public pushPath(path: string) {
+ try {
+ const route = this.getRoute(path)
+ return this.setCurrentRoute(
+ path, route.name, route.params, route.urlQuery,
+ )
+ } catch(err) {
+ if (this._fallback) {
+ return this.setCurrentRoute(
+ path, this._fallback.name,
+ undefined, undefined,
+ this._fallback.replace,
+ )
+ }
+ else throw err
+ }
+ }
+
+ public back(): void {
+ this._window.history.back()
+ }
+
+ public forward(): void {
+ this._window.history.forward()
+ }
+
+ public destroy(): void {
+ this._window.removeEventListener(
+ 'popstate', this._onPopState.bind(this),
+ )
+ }
+}
+
+
+
+// Svelte Use Actions ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+export function link(node: Element, router?: SvelteRouter) {
+ if (!node || !node.tagName || node.tagName.toLowerCase() != 'a') {
+ throw Error(
+ '[SvelteRouter] The action "link" can only be used ' +
+ 'on tags with a href attribute'
+ )
+ }
+ const instance = (
+ router ?
+ router : getContext('svelte_router') as SvelteRouter
+ )
+ if (!instance) {
+ throw new Error(
+ '[SvelteRouter] invalid router instance. Either use ' +
+ 'this component inside a or provide the router ' +
+ 'instance in the paramters.'
+ )
+ }
+
+ const href = node.getAttribute('href')
+ if (!href || href.length < 1) {
+ throw Error(`invalid URL "${href}" as "href"`)
+ }
+ const route = instance.getRoute(href)
+ function _onclick(event: Event): void {
+ event.preventDefault()
+ instance.push(route.name, route.params, route.urlQuery)
+ }
+ node.addEventListener('click', _onclick)
+
+ return {
+ destroy() {
+ node.removeEventListener('click', _onclick)
+ },
+ }
+}
+
+
+
+// Utility functions :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+/**
+ * isValidLetter checks whether the given char is a valid letter in A-Z or a-z.
+ *
+ * @param char string
+ * @returns boolean
+ */
+function isValidLetter(char: string): boolean {
+ return (
+ char >= Char.CapitalA && char <= Char.CapitalZ ||
+ char >= Char.LowerA && char <= Char.LowerZ
+ )
+}
+
+/**
+ * isValidNumber checks whether the given char is a number in 0-9.
+ *
+ * @param char string
+ * @returns boolean
+ */
+function isValidNumber(char: string): boolean {
+ return !Number.isNaN(parseInt(char))
+}
+
+/**
+ * isValidTokenChar validates if character is either a valid letter,
+ * valid number or a valid symbole.
+ *
+ * Using a plain parser instead of RegEx because of sweet performance
+ * @param char string
+ * @returns boolean
+ */
+function isValidTokenChar(char: string): boolean {
+ // 0-9
+ if (isValidNumber(char)) return true
+ // A-Z a-z
+ if (isValidLetter(char)) return true
+
+ switch (char) {
+ case Char.ExclamationMark:
+ case Char.Dollar:
+ case Char.Ampersand:
+ case Char.Apostrophe:
+ case Char.LeftParenthesis:
+ case Char.RightParenthesis:
+ case Char.Asterisk:
+ case Char.Plus:
+ case Char.Comma:
+ case Char.Hyphen:
+ case Char.Period:
+ case Char.Semicolon:
+ case Char.Equals:
+ case Char.At:
+ case Char.Underscore:
+ case Char.Tilde:
+ return true
+ }
+ return false
+}
+
+/**
+ * validateRouteName
+ * @param routeName string
+ * @returns Error|null
+ */
+function validateRouteName(routeName: string): Error|null {
+ if (routeName.length < 1) {
+ return new Error(`invalid route name (empty)`)
+ }
+ for (const char of routeName) {
+ // 0-9
+ if (isValidNumber(char)) continue
+ // A-Z a-z
+ if (isValidLetter(char)) continue
+
+ switch (char) {
+ case Char.Hyphen:
+ case Char.Period:
+ case Char.Underscore:
+ continue
+ }
+ return new Error(
+ `unexpected character ${char} in route name "${routeName}"`
+ )
+ }
+ return null
+}
+
+
+
+/**
+ * parsePathTemplate parses path templates.
+ * Example path template: /some/random/path/:param1/:param2
+ *
+ * @param template string
+ * @returns PathTemplate
+ * @throws Error
+ */
+function parsePathTemplate(template: string): PathTemplate {
+ if (typeof template !== 'string') {
+ throw new Error(
+ `unexpected type of route path "${template}" (${typeof template})`
+ )
+ }
+ if (template.length < 1) {
+ throw new Error(`invalid path (empty)`)
+ }
+
+ const templObject: PathTemplate = {tokens: [], params: []}
+
+ function addToken(isParam: boolean, begin: number, end: number): Error|null {
+ const token = template.substring(begin, end)
+
+ if (isParam) {
+ if (token.length < 1) {
+ return new Error(`missing parameter name at ${begin}`)
+ }
+ if (token in templObject.params) {
+ return new Error(`redeclared parameter '${token}' at ${begin}`)
+ }
+ if (isParam) {
+ templObject.params.push(token)
+ }
+ }
+
+ templObject.tokens.push(token)
+ return null
+ }
+
+ if (template.charAt(0) !== Char.Slash) {
+ throw new Error('a path template must begin with a slash')
+ }
+
+ let isPreviousSlash = true
+ // let isStatic = false
+ let isParam = false
+ let tokenStart = 1
+
+ for (let itr = 0; itr < template.length; itr++) {
+ const char = template[itr]
+
+ if (isPreviousSlash) {
+ // Ignore multiple slashes
+ if (char == Char.Slash) {
+ continue
+ }
+ isPreviousSlash = false
+
+ // Start scanning parameter
+ if (char == Char.Colon) {
+ // isStatic = false
+ isParam = true
+ tokenStart = itr+1
+ }
+ // Start scanning static token
+ else if (isValidTokenChar(char)) {
+ // isStatic = true
+ isParam = false
+ tokenStart = itr
+ }
+ else {
+ throw new Error(`unexpected '${char}' at ${itr}`)
+ }
+ }
+ else if (char == Char.Slash) {
+ // Terminating slash encountered
+ isPreviousSlash = true
+
+ const err = addToken(isParam, tokenStart, itr)
+ if (err != null) throw err
+
+ // isStatic = false
+ isParam = false
+ }
+ else if (!isValidTokenChar(char)) {
+ throw new Error(`unexpected '${char}' at ${itr}`)
+ }
+
+ if (itr+1 >= template.length) {
+ // Last character reached
+ if (isPreviousSlash) break
+
+ if (char == Char.Colon) {
+ throw new Error(`missing parameter name at ${itr}`)
+ }
+
+ const err = addToken(isParam, tokenStart, template.length)
+ if (err != null) throw err
+ }
+ }
+
+ return templObject
+}
+
+
+
+/**
+ * parseURLPath
+ * @param url string
+ * @returns \{pathTokens: Array, urlQuery: RouteParams|undefined}
+ * @throws Error
+ */
+function parseURLPath(url: string): {
+ pathTokens: Array,
+ urlQuery: RouteParams|undefined,
+} {
+ if (typeof url !== 'string') {
+ throw new Error(`unexpected path type (${typeof url})`)
+ }
+ if (url.length < 1) {
+ throw new Error(`invalid path (empty)`)
+ }
+
+ const pathTokens: Array = []
+
+ // Check if path begin with a slash
+ if (url[0] !== Char.Slash) {
+ throw new Error('a path path must begin with a slash')
+ }
+
+ let isPreviousSlash = true
+ let tokenStart = 1
+
+ for (let itr=1; itr < url.length; itr++) {
+ const char = url[itr]
+
+ if (isPreviousSlash) {
+ // Ignore multiple slashes
+ if (char === Char.Slash) continue
+
+ isPreviousSlash = false
+
+ // Start scanning token
+ if (isValidTokenChar(char)) {
+ tokenStart = itr
+ }
+ else throw new Error(
+ `unexpected "${char}" at ${itr}`
+ )
+ }
+ // Terminating slash encountered
+ else if (char == Char.Slash) {
+ isPreviousSlash = true
+ pathTokens.push(
+ url.substring(tokenStart, itr)
+ )
+ }
+ // URL Query begins
+ else if (char == Char.QuestionMark) {
+ pathTokens.push(url.substring(tokenStart, itr))
+ break
+ }
+ // Validate character
+ else if (!isValidTokenChar(char)) {
+ throw new Error(`unexpected "${char}" at ${itr}`)
+ }
+
+
+ // Last character reached
+ if (itr+1 >= url.length) {
+ if (isPreviousSlash) break
+
+ pathTokens.push(url.substring(tokenStart, url.length))
+ }
+ }
+
+ let urlQuery: RouteParams|undefined
+ const qM = url.indexOf(Char.QuestionMark)
+ if (qM > -1 && qM !== url.length-1) {
+ const hash = url.indexOf(Char.Hash)
+ if (qM > -1 && hash < 0 || hash >= url.length-1) {
+ urlQuery = parseUrlQuery(url.substring(qM))
+ }
+ }
+ return {pathTokens, urlQuery}
+}
+
+/**
+ * parseUrlQuery parses the given URL query string into a key:value object
+ *
+ * @param query string
+ * @returns RouteParams|undefined
+ */
+function parseUrlQuery(query: string): RouteParams|undefined {
+ if (query[0] !== Char.QuestionMark) {
+ return undefined
+ }
+
+ // pQ parsed query
+ const pQ: RouteParams = {}
+
+ for (let chunk of query.substring(1, query.length).split(Char.Ampersand)) {
+ if(!chunk) return undefined
+ chunk = chunk.split(Char.Plus).join(Char.Space)
+
+ const eq = chunk.indexOf(Char.Equals)
+ const key = eq > -1 ? chunk.substring(0, eq) : chunk
+ const val = eq > -1 ? decodeURIComponent(chunk.substring(eq + 1)) : ''
+
+ pQ[decodeURIComponent(key)] = val
+ }
+ return pQ
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c89b411
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "@tsconfig/svelte/tsconfig.json",
+ "compilerOptions": {
+ "target": "es6",
+ "strict": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "removeComments": true,
+ "sourceMap": false,
+ "declarationDir": ".",
+ "outDir": "src"
+ },
+
+ "include": ["src/**/*"],
+ "exclude": ["node_modules/*"]
+}
\ No newline at end of file
diff --git a/webpack.config.js b/webpack.config.js
deleted file mode 100644
index f3a7e6d..0000000
--- a/webpack.config.js
+++ /dev/null
@@ -1,64 +0,0 @@
-const MiniCssExtractPlugin = require('mini-css-extract-plugin');
-
-const mode = process.env.NODE_ENV || 'development';
-const prod = mode === 'production';
-
-module.exports = {
- devServer: {
- historyApiFallback: true,
- disableHostCheck: true,
- watchContentBase: true,
- host: '0.0.0.0',
- compress: true,
- port: 8081,
- hot: false,
- overlay: {
- warnings: false,
- errors: true,
- },
- },
- entry: {
- bundle: ['./src/main.js']
- },
- resolve: {
- extensions: ['.mjs', '.js', '.svelte']
- },
- output: {
- path: __dirname + '/public',
- filename: '[name].js',
- chunkFilename: '[name].[id].js'
- },
- module: {
- rules: [
- {
- test: /\.svelte$/,
- exclude: /node_modules/,
- use: {
- loader: 'svelte-loader',
- options: {
- emitCss: true,
- hotReload: true
- }
- }
- },
- {
- test: /\.css$/,
- use: [
- /**
- * MiniCssExtractPlugin doesn't support HMR.
- * For developing, use 'style-loader' instead.
- * */
- prod ? MiniCssExtractPlugin.loader : 'style-loader',
- 'css-loader'
- ]
- }
- ]
- },
- mode,
- plugins: [
- new MiniCssExtractPlugin({
- filename: '[name].css'
- })
- ],
- devtool: prod ? false: 'source-map'
-};