diff --git a/.gitignore b/.gitignore index 7c02f25..23d0b9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ package-lock.json dist/ -.cache/ \ No newline at end of file +.cache/ +.rts2_cache_*/ \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..d8dc600 --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +test/ +dist/ +.rts2_cache_*/ \ No newline at end of file diff --git a/ReadMe.md b/ReadMe.md new file mode 100644 index 0000000..60fe771 --- /dev/null +++ b/ReadMe.md @@ -0,0 +1,12 @@ +# Cell Router + +[Web Component][1] Router based on [WebCell][2] & [MobX][3] + +[![](https://data.jsdelivr.com/v1/package/npm/cell-router/badge?style=rounded)][3] + +[![NPM](https://nodei.co/npm/cell-router.png?downloads=true&downloadRank=true&stars=true)][4] + +[1]: https://www.webcomponents.org/ +[2]: https://web-cell.dev/ +[3]: https://mobx.js.org/ +[4]: https://nodei.co/npm/cell-router/ diff --git a/package.json b/package.json index a4cb8f5..8adf431 100644 --- a/package.json +++ b/package.json @@ -1,55 +1,76 @@ { - "name": "cell-router", - "version": "2.0.0-alpha.1", - "license": "AGPL-3.0", - "description": "", - "keywords": [], - "author": "shiy2008@gmail.com", - "homepage": "https://github.com/EasyWebApp/cell-router#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/EasyWebApp/cell-router.git" - }, - "bugs": { - "url": "https://github.com/EasyWebApp/cell-router/issues" - }, - "main": "dist/index.js", - "module": "source/index.ts", - "devDependencies": { - "@types/jest": "^24.0.18", - "@types/jsdom": "^12.2.4", - "@types/mocha": "^5.2.7", - "husky": "^3.0.7", - "jest": "^24.9.0", - "jsdom": "^15.1.1", - "lint-staged": "^9.4.1", - "parcel-bundler": "^1.12.3", - "prettier": "^1.18.2", - "ts-jest": "^24.1.0", - "typescript": "^3.6.3" - }, - "scripts": { - "test": "lint-staged && jest", - "build": "parcel build source/index.ts" - }, - "prettier": { - "singleQuote": true, - "tabWidth": 4 - }, - "jest": { - "preset": "ts-jest", - "testEnvironment": "node" - }, - "lint-staged": { - "*.ts": [ - "prettier --write", - "git add" - ] - }, - "husky": { - "hooks": { - "pre-commit": "npm test", - "pre-push": "npm run build" + "name": "cell-router", + "version": "2.0.0-beta.0", + "license": "LGPL-3.0", + "description": "Web Component Router based on WebCell & MobX", + "keywords": [ + "Web", + "component", + "router", + "WebCell", + "MobX" + ], + "author": "shiy2008@gmail.com", + "homepage": "https://web-cell.dev/cell-router/", + "repository": { + "type": "git", + "url": "git+https://github.com/EasyWebApp/cell-router.git" + }, + "bugs": { + "url": "https://github.com/EasyWebApp/cell-router/issues" + }, + "source": "source/index.ts", + "types": "dist/index.d.ts", + "main": "dist/cell-router.umd.js", + "module": "dist/cell-router.js", + "peerDependencies": { + "mobx": "^5.14.0", + "mobx-web-cell": "^0.1.1", + "web-cell": "^2.0.0-beta.1" + }, + "devDependencies": { + "@types/jest": "^24.0.18", + "@types/puppeteer-core": "^1.9.0", + "husky": "^3.0.8", + "jest": "^24.9.0", + "koapache": "^1.0.6", + "lint-staged": "^9.4.1", + "microbundle": "^0.11.0", + "mobx": "^5.14.0", + "mobx-web-cell": "^0.1.1", + "parcel-bundler": "^1.12.3", + "prettier": "^1.18.2", + "puppeteer-core": "^1.20.0", + "ts-jest": "^24.1.0", + "typescript": "^3.6.3", + "web-cell": "^2.0.0-beta.1" + }, + "scripts": { + "debug": "cd test/ && parcel source/index.html", + "lint": "lint-staged", + "pack-test": "cd test/ && parcel build source/index.html", + "test": "npm run lint && npm run pack-test && jest --forceExit", + "build": "microbundle --external web-cell,mobx --globals web-cell=WebCell --name CellRouter", + "prepublishOnly": "npm test && npm run build" + }, + "prettier": { + "singleQuote": true, + "tabWidth": 4 + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node" + }, + "lint-staged": { + "*.{html,md,js,json,ts,tsx}": [ + "prettier --write", + "git add" + ] + }, + "husky": { + "hooks": { + "pre-commit": "npm run lint", + "pre-push": "npm test && npm run build" + } } - } } diff --git a/source/HTMLRouter.ts b/source/HTMLRouter.ts new file mode 100644 index 0000000..23a3154 --- /dev/null +++ b/source/HTMLRouter.ts @@ -0,0 +1,26 @@ +import { mixin, delegate } from 'web-cell'; + +import History from './History'; + +const NonRoute = /^((\w+:)?\/\/|#|javascript:)/; + +export default abstract class HTMLRouter extends mixin() { + protected abstract history: History; + + constructor() { + super(); + + this.addEventListener('click', delegate('a[href]', this.handleLink)); + } + + handleLink = (event: MouseEvent, link: HTMLAnchorElement) => { + if ((link.target || '_self') !== '_self') return; + + event.preventDefault(), event.stopPropagation(); + + const path = link.getAttribute('href'); + + if (path && !NonRoute.test(path)) + this.history.push(path, link.title || link.textContent.trim()); + }; +} diff --git a/source/History.ts b/source/History.ts index d5e5f5a..2faa712 100644 --- a/source/History.ts +++ b/source/History.ts @@ -1,109 +1,35 @@ -export enum HistoryMode { - hash = '#', - path = '/' -} - -enum HistoryEvent { - push = 'push', - pop = 'pop' -} - -type HistoryHandler = (path: string, data: any) => void; - -interface RouterStore { - [type: string]: Map; -} - -const { location } = window; - -const NonRoute = /^((\w+:)?\/\/|#|javascript:)/; +import { observable } from 'mobx'; export default class History { - readonly root = location.pathname; - mode = HistoryMode.hash; - - private router: RouterStore = { - push: new Map(), - pop: new Map() - }; - - constructor(mode?: HistoryMode) { - this.mode = mode || this.mode; - - document.addEventListener('click', (event: MouseEvent) => { - const link = event.target as (HTMLAnchorElement | HTMLAreaElement); + static get path() { + return window.location.hash.slice(1); + } + static get title() { + return (window.history.state || '').title || document.title; + } - if ((link.target || '_self') !== '_self') return; + @observable + path = History.path; - const path = link.getAttribute('href'); + constructor() { + const { title } = History; - if (!path || NonRoute.test(path)) return; + window.history.replaceState({ title }, (document.title = title)); - event.preventDefault(); + window.addEventListener('popstate', () => { + document.title = History.title; - this.push(path, link.title || (link.textContent as string)); + this.path = History.path; }); - - window.addEventListener('popstate', () => this.emit('pop')); } - get path() { - const base = - location.origin + (this.root + this.mode).replace(/\/{2,}/g, '/'); - - return location.href.indexOf(base) - ? '' - : location.href.slice(base.length); - } - - push(path: string, title = document.title, data = {}) { + push(path: string, title = document.title, data?: any) { window.history.pushState( - data, + { ...data, title }, (document.title = title), - (this.root + this.mode + path).replace(/\/{2,}/g, '/') + '#' + path ); - this.emit('push'); - } - - static match(path: string, pattern: string | RegExp) { - return pattern instanceof RegExp - ? path.match(pattern) - : !path.indexOf(pattern); - } - - protected emit(type: keyof typeof HistoryEvent) { - const router = this.router[type], - { path } = this; - - if (path) - for (const [pattern, handlers] of router) { - const data = History.match(path, pattern); - - if (data) { - for (const callback of handlers) callback(path, data); - break; - } - } - } - - on( - type: keyof typeof HistoryEvent, - path: string | RegExp, - callback: HistoryHandler - ) { - const router = this.router[type]; - - var handlers: HistoryHandler[] = []; - - for (const [pattern, handler] of router) - if (pattern + '' === path + '') { - handlers = handler; - break; - } - - if (!handlers[0]) router.set(path, (handlers = [])); - - handlers.push(callback); + this.path = path; } } diff --git a/source/index.ts b/source/index.ts index 91a3211..46c5b24 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1 +1,2 @@ -export * from './History'; +export { default as History } from './History'; +export { default as HTMLRouter } from './HTMLRouter'; diff --git a/test/History.spec.ts b/test/History.spec.ts deleted file mode 100644 index 8896fd5..0000000 --- a/test/History.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import './polyfill'; -import History from '../source/History'; - -describe('History', () => { - var history: History; - - it('should have correct properties after construction', () => { - history = new History(); - - expect(history.root).toBe('/'); - expect(history.mode).toBe('#'); - }); - - it('should change path & title of document after calling .goto()', () => { - history.push('/test', 'Test'); - - expect(history.path).toBe('/test'); - expect(document.title).toBe('Test'); - }); - - it('should emit callback after changing path', () => { - const example_handler = jest.fn(); - - history.on('push', /exam/, example_handler); - history.push('/example', 'Example'); - - expect(example_handler).toBeCalledWith( - '/example', - expect.arrayContaining(['exam']) - ); - }); - - it('should handle or clicking', () => { - const sample_handler = jest.fn(), - link = document.querySelector('a'); - - history.on('push', '/sample', sample_handler); - link!.click(); - - expect(sample_handler).toBeCalledTimes(1); - }); -}); diff --git a/test/browser.ts b/test/browser.ts new file mode 100644 index 0000000..7a65a6b --- /dev/null +++ b/test/browser.ts @@ -0,0 +1,29 @@ +import WebServer from 'koapache'; +import { Browser, Page, launch } from 'puppeteer-core'; +import { join } from 'path'; + +export async function bootServer() { + const server = new WebServer('test/dist/'); + + const { address, port } = await server.workerHost(); + + return `http://${address}:${port}/`; +} + +const { npm_config_chrome } = process.env; + +var browser: Browser, page: Page; + +export async function getPage(path: string) { + browser = browser || (await launch({ executablePath: npm_config_chrome })); + + page = page || (await browser.pages())[0]; + + await page.goto(path); + + return page; +} + +export function delay(seconds = 0.1) { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); +} diff --git a/test/index.spec.ts b/test/index.spec.ts new file mode 100644 index 0000000..4e9411a --- /dev/null +++ b/test/index.spec.ts @@ -0,0 +1,62 @@ +import { Page } from 'puppeteer-core'; +import { bootServer, getPage } from './browser'; + +var server: string, page: Page; + +async function expectPage(content: string, title: string, path: string) { + expect( + await page.$eval('div', tag => [ + tag.textContent, + document.title, + window.location.hash + ]) + ).toStrictEqual(expect.arrayContaining([content, title, path])); +} + +describe('HTMLRouter', () => { + beforeAll(async () => { + server = await bootServer(); + + page = await getPage(server); + }); + + it('should render Router component', async () => { + expect(await page.$eval('page-router', tag => tag.innerHTML)).toBe( + '
' + ); + }); + + it('should turn to a page after clicking a link', async () => { + await page.click('li:first-child a'); + + await expectPage('test', 'Test', '#test'); + + await page.click('li:last-child a'); + + await expectPage('example', 'Example', '#example'); + }); + + it('should turn to a page after navigating', async () => { + await page.goBack(); + + await expectPage('test', 'Test', '#test'); + + await page.goForward(); + + await expectPage('example', 'Example', '#example'); + }); + + it('should render a page based on Router path after reloading', async () => { + await page.reload(); + + await expectPage('example', 'Example', '#example'); + + await page.goBack(); + + await expectPage('test', 'Test', '#test'); + + await page.goBack(); + + await expectPage('', 'Cell Router', ''); + }); +}); diff --git a/test/polyfill.ts b/test/polyfill.ts deleted file mode 100644 index 5474c37..0000000 --- a/test/polyfill.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { JSDOM } from 'jsdom'; - -const { window } = new JSDOM('Sample', { - url: 'http://localhost/' -}); - -// @ts-ignore -for (const name of ['window', 'document']) global[name] = window[name]; diff --git a/test/source/index.html b/test/source/index.html new file mode 100644 index 0000000..d587bb0 --- /dev/null +++ b/test/source/index.html @@ -0,0 +1,14 @@ + + + + + + Cell Router + + + + + + + + diff --git a/test/source/model/index.ts b/test/source/model/index.ts new file mode 100644 index 0000000..b9fb8da --- /dev/null +++ b/test/source/model/index.ts @@ -0,0 +1,3 @@ +import History from '../../../source/History'; + +export const history = new History(); diff --git a/test/source/page/PageRouter.tsx b/test/source/page/PageRouter.tsx new file mode 100644 index 0000000..e2b23c2 --- /dev/null +++ b/test/source/page/PageRouter.tsx @@ -0,0 +1,30 @@ +import { createCell, component } from 'web-cell'; +import { observer } from 'mobx-web-cell'; +import { HTMLRouter } from '../../../source/'; + +import { history } from '../model'; + +@observer +@component({ + tagName: 'page-router', + renderTarget: 'children' +}) +export default class PageRouter extends HTMLRouter { + protected history = history; + + render() { + return ( +
+ +
{history.path}
+
+ ); + } +} diff --git a/test/source/page/index.tsx b/test/source/page/index.tsx new file mode 100644 index 0000000..faed58d --- /dev/null +++ b/test/source/page/index.tsx @@ -0,0 +1,5 @@ +import { createCell, render } from 'web-cell'; + +import PageRouter from './PageRouter'; + +render(); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..79f0dd4 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es5", + "experimentalDecorators": true, + "jsx": "react", + "jsxFactory": "createCell" + }, + "include": ["./**/*", "../source/**/*"] +} diff --git a/tsconfig.json b/tsconfig.json index afd39dd..4fcdf31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,7 @@ { "compilerOptions": { - "outDir": "dist/", - "sourceMap": true, - "strict": true, - "noImplicitReturns": true, - "noImplicitAny": true, - "downlevelIteration": true, - "module": "es6", - "moduleResolution": "node", - "esModuleInterop":true, "target": "es5", - "allowJs": true + "experimentalDecorators": true }, - "include": [ - "source/**/*" - ] -} \ No newline at end of file + "include": ["source/**/*"] +}