From ba998e8831e886e8b77a4535ad3f85c4e510b58f Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Sat, 14 May 2022 20:43:10 +0300 Subject: [PATCH 01/22] add first test --- test/e2e/virtual-routes.test.js | 24 ++++++++++++++++++++++++ test/helpers/navigate.js | 12 ++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 test/e2e/virtual-routes.test.js create mode 100644 test/helpers/navigate.js diff --git a/test/e2e/virtual-routes.test.js b/test/e2e/virtual-routes.test.js new file mode 100644 index 000000000..54b8439ff --- /dev/null +++ b/test/e2e/virtual-routes.test.js @@ -0,0 +1,24 @@ +const docsifyInit = require('../helpers/docsify-init'); +const { navigateToRoute } = require('../helpers/navigate'); +const { test, expect } = require('./fixtures/docsify-init-fixture'); + +test.describe('Virtual Routes - Generate Dynamic Content via Config', () => { + test.only('rendering virtual routes specified in the configuration', async ({ + page, + }) => { + const routes = { + '/my-awesome-route': '# My Awesome Route', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my-awesome-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Route'); + }); +}); diff --git a/test/helpers/navigate.js b/test/helpers/navigate.js new file mode 100644 index 000000000..e19e33c65 --- /dev/null +++ b/test/helpers/navigate.js @@ -0,0 +1,12 @@ +/** + * Navigate to a specific hashtag route in the page, and wait for docsify to handle navigation. + * @param {import('playwright-core').Page} page + * @param {string} route + */ +async function navigateToRoute(page, route) { + await page.evaluate(r => (window.location.hash = r), route); + const mainElm = await page.waitForSelector('#main'); + await mainElm.waitForElementState('stable'); +} + +module.exports.navigateToRoute = navigateToRoute; From 7c92837e07e72b82964b36f9385e917d94ac9d4e Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Sat, 14 May 2022 20:53:25 +0300 Subject: [PATCH 02/22] new VirtualRoutes mixin that handles routes. fetch tries to use VirtualRoutes. default config updated --- .eslintrc.js | 9 ++++++- src/core/Docsify.js | 6 ++++- src/core/config.js | 1 + src/core/fetch/index.js | 21 +++++++++++++++- src/core/virtual-routes/index.js | 41 ++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/core/virtual-routes/index.js diff --git a/.eslintrc.js b/.eslintrc.js index 86b1d221f..3538e349d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -41,7 +41,14 @@ module.exports = { 'no-shadow': [ 'error', { - allow: ['Events', 'Fetch', 'Lifecycle', 'Render', 'Router'], + allow: [ + 'Events', + 'Fetch', + 'Lifecycle', + 'Render', + 'Router', + 'VirtualRoutes', + ], }, ], 'no-unused-vars': ['error', { args: 'none' }], diff --git a/src/core/Docsify.js b/src/core/Docsify.js index ad339311c..7ea7efa77 100644 --- a/src/core/Docsify.js +++ b/src/core/Docsify.js @@ -2,6 +2,7 @@ import { Router } from './router/index.js'; import { Render } from './render/index.js'; import { Fetch } from './fetch/index.js'; import { Events } from './event/index.js'; +import { VirtualRoutes } from './virtual-routes/index.js'; import initGlobalAPI from './global-api.js'; import config from './config.js'; @@ -11,7 +12,10 @@ import { Lifecycle } from './init/lifecycle'; /** @typedef {new (...args: any[]) => any} Constructor */ // eslint-disable-next-line new-cap -export class Docsify extends Fetch(Events(Render(Router(Lifecycle(Object))))) { +export class Docsify extends Fetch( + // eslint-disable-next-line new-cap + Events(Render(VirtualRoutes(Router(Lifecycle(Object))))) +) { constructor() { super(); diff --git a/src/core/config.js b/src/core/config.js index 235736764..5dc9a3850 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -33,6 +33,7 @@ export default function (vm) { notFoundPage: true, relativePath: false, repo: '', + routes: {}, routerMode: 'hash', subMaxLevel: 0, themeColor: '', diff --git a/src/core/fetch/index.js b/src/core/fetch/index.js index 4ee69be9d..abab7c969 100644 --- a/src/core/fetch/index.js +++ b/src/core/fetch/index.js @@ -96,13 +96,32 @@ export function Fetch(Base) { // Abort last request const file = this.router.getFile(path); - const req = request(file + qs, true, requestHeaders); this.isRemoteUrl = isExternal(file); // Current page is html this.isHTML = /\.html$/g.test(file); // Load main content + const req = Promise.resolve() + .then(() => { + if (!this.isRemoteUrl) { + return this.matchVirtualRoute(file); + } else { + return null; + } + }) + .then(text => { + if (typeof text === 'string') { + return { + then(fn) { + fn(text, {}); + }, + }; + } else { + return request(file + qs, true, requestHeaders); + } + }); + req.then( (text, opt) => this._renderMain( diff --git a/src/core/virtual-routes/index.js b/src/core/virtual-routes/index.js new file mode 100644 index 000000000..4a2945608 --- /dev/null +++ b/src/core/virtual-routes/index.js @@ -0,0 +1,41 @@ +/** @typedef {import('../Docsify').Constructor} Constructor */ + +/** @typedef {Record} VirtualRoutesMap */ +/** @typedef {(route: string, match: RegExpMatchArray | null) => string | void | Promise } VirtualRouteHandler */ + +/** + * @template {!Constructor} T + * @param {T} Base - The class to extend + */ +export function VirtualRoutes(Base) { + return class VirtualRoutes extends Base { + /** + * Gets the Routes object from the configuration + * @returns {VirtualRoutesMap} + */ + routes() { + return this.config.routes || {}; + } + + /** + * Attempts to match the given path with a virtual route + * @param {string} path + * @returns {Promise} resolves to string if route was matched, otherwise null + */ + matchVirtualRoute(path) { + const routes = this.routes(); + + for (const route of Object.keys(routes)) { + const match = path.match(route); + if (!match) { + continue; + } + + const virtualRoute = routes[route]; + return virtualRoute; + } + + return null; + } + }; +} From 7a2d0d0b61251618a8cfc401f01946e32233bcc3 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Sat, 14 May 2022 22:40:29 +0300 Subject: [PATCH 03/22] cover all basic use cases --- src/core/virtual-routes/index.js | 26 +++++++---- test/e2e/virtual-routes.test.js | 77 +++++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/src/core/virtual-routes/index.js b/src/core/virtual-routes/index.js index 4a2945608..22eb720a6 100644 --- a/src/core/virtual-routes/index.js +++ b/src/core/virtual-routes/index.js @@ -23,16 +23,26 @@ export function VirtualRoutes(Base) { * @returns {Promise} resolves to string if route was matched, otherwise null */ matchVirtualRoute(path) { - const routes = this.routes(); + const virtualRoutes = this.routes(); - for (const route of Object.keys(routes)) { - const match = path.match(route); - if (!match) { - continue; - } + const virtualRoutePaths = Object.keys(virtualRoutes); + const matchedVirtualRoutePath = virtualRoutePaths.find(route => + path.match(route) + ); - const virtualRoute = routes[route]; - return virtualRoute; + if (!matchedVirtualRoutePath) { + return null; + } + + const virtualRouteContentOrFn = virtualRoutes[matchedVirtualRoutePath]; + + if (typeof virtualRouteContentOrFn === 'string') { + return virtualRouteContentOrFn; + } + + if (typeof virtualRouteContentOrFn === 'function') { + const match = path.match(matchedVirtualRoutePath); + return virtualRouteContentOrFn(path, match); } return null; diff --git a/test/e2e/virtual-routes.test.js b/test/e2e/virtual-routes.test.js index 54b8439ff..3232b5bf5 100644 --- a/test/e2e/virtual-routes.test.js +++ b/test/e2e/virtual-routes.test.js @@ -2,23 +2,66 @@ const docsifyInit = require('../helpers/docsify-init'); const { navigateToRoute } = require('../helpers/navigate'); const { test, expect } = require('./fixtures/docsify-init-fixture'); -test.describe('Virtual Routes - Generate Dynamic Content via Config', () => { - test.only('rendering virtual routes specified in the configuration', async ({ - page, - }) => { - const routes = { - '/my-awesome-route': '# My Awesome Route', - }; - - await docsifyInit({ - config: { - routes, - }, +test.describe.only( + 'Virtual Routes - Generate Dynamic Content via Config', + () => { + test('rendering virtual routes specified as string', async ({ page }) => { + const routes = { + '/my-awesome-route': '# My Awesome Route', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my-awesome-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Route'); }); - await navigateToRoute(page, '/my-awesome-route'); + test('rendering virtual routes specified as functions', async ({ + page, + }) => { + const routes = { + '/my-awesome-function-route': function () { + return '# My Awesome Function Route'; + }, + }; - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Route'); - }); -}); + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my-awesome-function-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); + }); + + test('rendering virtual routes specified as async functions', async ({ + page, + }) => { + const routes = { + '/my-awesome-function-route': async function () { + return '# My Awesome Function Route'; + }, + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my-awesome-function-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); + }); + } +); From 3f359ed16e25d80aa74d0d706a294a2a82727f92 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Sat, 14 May 2022 23:45:43 +0300 Subject: [PATCH 04/22] regex matching in routes --- src/core/fetch/index.js | 2 +- src/core/virtual-routes/index.js | 7 +- test/e2e/virtual-routes.test.js | 172 +++++++++++++++++++++++-------- 3 files changed, 133 insertions(+), 48 deletions(-) diff --git a/src/core/fetch/index.js b/src/core/fetch/index.js index abab7c969..5cf63b6c9 100644 --- a/src/core/fetch/index.js +++ b/src/core/fetch/index.js @@ -105,7 +105,7 @@ export function Fetch(Base) { const req = Promise.resolve() .then(() => { if (!this.isRemoteUrl) { - return this.matchVirtualRoute(file); + return this.matchVirtualRoute(path); } else { return null; } diff --git a/src/core/virtual-routes/index.js b/src/core/virtual-routes/index.js index 22eb720a6..dc4000621 100644 --- a/src/core/virtual-routes/index.js +++ b/src/core/virtual-routes/index.js @@ -34,14 +34,17 @@ export function VirtualRoutes(Base) { return null; } + const match = path.match(matchedVirtualRoutePath); const virtualRouteContentOrFn = virtualRoutes[matchedVirtualRoutePath]; if (typeof virtualRouteContentOrFn === 'string') { - return virtualRouteContentOrFn; + return virtualRouteContentOrFn.replace( + /\$(\d+)/g, + (_, index) => match[parseInt(index, 10)] + ); } if (typeof virtualRouteContentOrFn === 'function') { - const match = path.match(matchedVirtualRoutePath); return virtualRouteContentOrFn(path, match); } diff --git a/test/e2e/virtual-routes.test.js b/test/e2e/virtual-routes.test.js index 3232b5bf5..d495d3940 100644 --- a/test/e2e/virtual-routes.test.js +++ b/test/e2e/virtual-routes.test.js @@ -5,63 +5,145 @@ const { test, expect } = require('./fixtures/docsify-init-fixture'); test.describe.only( 'Virtual Routes - Generate Dynamic Content via Config', () => { - test('rendering virtual routes specified as string', async ({ page }) => { - const routes = { - '/my-awesome-route': '# My Awesome Route', - }; - - await docsifyInit({ - config: { - routes, - }, + test.describe('Different types of virtual routes', () => { + test('rendering virtual routes specified as string', async ({ page }) => { + const routes = { + '/my-awesome-route': '# My Awesome Route', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my-awesome-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Route'); }); - await navigateToRoute(page, '/my-awesome-route'); + test('rendering virtual routes specified as functions', async ({ + page, + }) => { + const routes = { + '/my-awesome-function-route': function () { + return '# My Awesome Function Route'; + }, + }; - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Route'); - }); + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my-awesome-function-route'); - test('rendering virtual routes specified as functions', async ({ - page, - }) => { - const routes = { - '/my-awesome-function-route': function () { - return '# My Awesome Function Route'; - }, - }; - - await docsifyInit({ - config: { - routes, - }, + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); }); - await navigateToRoute(page, '/my-awesome-function-route'); + test('rendering virtual routes specified as async functions', async ({ + page, + }) => { + const routes = { + '/my-awesome-async-function-route': async function () { + return '# My Awesome Function Route'; + }, + }; + + await docsifyInit({ + config: { + routes, + }, + }); - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Function Route'); + await navigateToRoute(page, '/my-awesome-async-function-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); + }); }); - test('rendering virtual routes specified as async functions', async ({ - page, - }) => { - const routes = { - '/my-awesome-function-route': async function () { - return '# My Awesome Function Route'; - }, - }; - - await docsifyInit({ - config: { - routes, - }, + test.describe('Routes with regex matches', () => { + test('rendering virtual routes with regex matches', async ({ page }) => { + const routes = { + '/items/(.*)': '# Item Page', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/items/banana'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Item Page'); + }); + + test('formatting regex matches into string virtual routes', async ({ + page, + }) => { + const routes = { + '/items/(.*)': '# Item Page ($1)', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/items/apple'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Item Page (apple)'); + }); + + test('virtual route functions should get the route as first parameter', async ({ + page, + }) => { + const routes = { + '/pets/(.*)': function (route) { + return `# Route: /pets/dog`; + }, + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/pets/dog'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Route: /pets/dog'); }); - await navigateToRoute(page, '/my-awesome-function-route'); + test('virtual route functions should get the matched array as second parameter', async ({ + page, + }) => { + const routes = { + '/pets/(.*)': function (_, matched) { + return `# Pets Page (${matched[1]})`; + }, + }; - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Function Route'); + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/pets/cat'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Pets Page (cat)'); + }); }); } ); From 53c507f02e2a61c10e2f4999bf79b64d5e4f230c Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Sun, 15 May 2022 02:07:29 +0300 Subject: [PATCH 05/22] covered all virtual routes tests --- src/core/virtual-routes/index.js | 61 ++-- src/core/virtual-routes/match-utils.js | 17 ++ test/e2e/virtual-routes.test.js | 398 +++++++++++++++++-------- 3 files changed, 334 insertions(+), 142 deletions(-) create mode 100644 src/core/virtual-routes/match-utils.js diff --git a/src/core/virtual-routes/index.js b/src/core/virtual-routes/index.js index dc4000621..3fa37439f 100644 --- a/src/core/virtual-routes/index.js +++ b/src/core/virtual-routes/index.js @@ -1,3 +1,5 @@ +import { makeExactMatcher } from './match-utils'; + /** @typedef {import('../Docsify').Constructor} Constructor */ /** @typedef {Record} VirtualRoutesMap */ @@ -18,7 +20,7 @@ export function VirtualRoutes(Base) { } /** - * Attempts to match the given path with a virtual route + * Attempts to match the given path with a virtual route. * @param {string} path * @returns {Promise} resolves to string if route was matched, otherwise null */ @@ -26,29 +28,52 @@ export function VirtualRoutes(Base) { const virtualRoutes = this.routes(); const virtualRoutePaths = Object.keys(virtualRoutes); - const matchedVirtualRoutePath = virtualRoutePaths.find(route => - path.match(route) - ); - if (!matchedVirtualRoutePath) { - return null; - } + /** + * This is a tail recursion that resolves to the first properly matched route, to itself or to null. + * Used because async\await is not supported, so for loops over promises are out of the question... + * @returns {Promise} + */ + function asyncMatchNextRoute() { + const virtualRoutePath = virtualRoutePaths.shift(); + if (!virtualRoutePath) { + return Promise.resolve(null); + } - const match = path.match(matchedVirtualRoutePath); - const virtualRouteContentOrFn = virtualRoutes[matchedVirtualRoutePath]; + const matcher = makeExactMatcher(virtualRoutePath); + const matched = path.match(matcher); - if (typeof virtualRouteContentOrFn === 'string') { - return virtualRouteContentOrFn.replace( - /\$(\d+)/g, - (_, index) => match[parseInt(index, 10)] - ); - } + if (!matched) { + return Promise.resolve().then(asyncMatchNextRoute); + } + + const virtualRouteContentOrFn = virtualRoutes[virtualRoutePath]; + + if (typeof virtualRouteContentOrFn === 'string') { + const contents = virtualRouteContentOrFn.replace( + /\$(\d+)/g, + (_, index) => matched[parseInt(index, 10)] + ); - if (typeof virtualRouteContentOrFn === 'function') { - return virtualRouteContentOrFn(path, match); + return Promise.resolve(contents); + } else if (typeof virtualRouteContentOrFn === 'function') { + return Promise.resolve() + .then(() => virtualRouteContentOrFn(path, matched)) + .then(contents => { + if (typeof contents === 'string') { + return contents; + } else if (contents === false) { + return null; + } else { + return asyncMatchNextRoute(); + } + }); + } else { + return Promise.resolve().then(asyncMatchNextRoute); + } } - return null; + return asyncMatchNextRoute(); } }; } diff --git a/src/core/virtual-routes/match-utils.js b/src/core/virtual-routes/match-utils.js new file mode 100644 index 000000000..2f38cb592 --- /dev/null +++ b/src/core/virtual-routes/match-utils.js @@ -0,0 +1,17 @@ +/** + * Adds beginning of input (^) and end of input ($) assertions if needed into a regex string + * @param {string} matcher the string to match + * @returns {string} + */ +export function makeExactMatcher(matcher) { + const matcherWithBeginningOfInput = matcher.startsWith('^') + ? matcher + : `^${matcher}`; + + const matcherWithBeginningAndEndOfInput = + matcherWithBeginningOfInput.endsWith('$') + ? matcherWithBeginningOfInput + : `${matcherWithBeginningOfInput}$`; + + return matcherWithBeginningAndEndOfInput; +} diff --git a/test/e2e/virtual-routes.test.js b/test/e2e/virtual-routes.test.js index d495d3940..47c0cf7c1 100644 --- a/test/e2e/virtual-routes.test.js +++ b/test/e2e/virtual-routes.test.js @@ -2,148 +2,298 @@ const docsifyInit = require('../helpers/docsify-init'); const { navigateToRoute } = require('../helpers/navigate'); const { test, expect } = require('./fixtures/docsify-init-fixture'); -test.describe.only( - 'Virtual Routes - Generate Dynamic Content via Config', - () => { - test.describe('Different types of virtual routes', () => { - test('rendering virtual routes specified as string', async ({ page }) => { - const routes = { - '/my-awesome-route': '# My Awesome Route', - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/my-awesome-route'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Route'); +test.describe('Virtual Routes - Generate Dynamic Content via Config', () => { + test.describe('Different Types of Virtual Routes', () => { + test('rendering virtual routes specified as string', async ({ page }) => { + const routes = { + '/my-awesome-route': '# My Awesome Route', + }; + + await docsifyInit({ + config: { + routes, + }, }); - test('rendering virtual routes specified as functions', async ({ - page, - }) => { - const routes = { - '/my-awesome-function-route': function () { - return '# My Awesome Function Route'; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/my-awesome-function-route'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Function Route'); + await navigateToRoute(page, '/my-awesome-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Route'); + }); + + test('rendering virtual routes specified as functions', async ({ + page, + }) => { + const routes = { + '/my-awesome-function-route': function () { + return '# My Awesome Function Route'; + }, + }; + + await docsifyInit({ + config: { + routes, + }, }); - test('rendering virtual routes specified as async functions', async ({ - page, - }) => { - const routes = { - '/my-awesome-async-function-route': async function () { - return '# My Awesome Function Route'; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/my-awesome-async-function-route'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Function Route'); + await navigateToRoute(page, '/my-awesome-function-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); + }); + + test('rendering virtual routes specified as async functions', async ({ + page, + }) => { + const routes = { + '/my-awesome-async-function-route': async function () { + return '# My Awesome Function Route'; + }, + }; + + await docsifyInit({ + config: { + routes, + }, }); + + await navigateToRoute(page, '/my-awesome-async-function-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); }); + }); + + test.describe('Routes with Regex Matches', () => { + test('rendering virtual routes with regex matches', async ({ page }) => { + const routes = { + '/items/(.*)': '# Item Page', + }; - test.describe('Routes with regex matches', () => { - test('rendering virtual routes with regex matches', async ({ page }) => { - const routes = { - '/items/(.*)': '# Item Page', - }; + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/items/banana'); - await docsifyInit({ - config: { - routes, - }, - }); + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Item Page'); + }); - await navigateToRoute(page, '/items/banana'); + test('formatting regex matches into string virtual routes', async ({ + page, + }) => { + const routes = { + '/items/(.*)': '# Item Page ($1)', + }; - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Item Page'); + await docsifyInit({ + config: { + routes, + }, }); - test('formatting regex matches into string virtual routes', async ({ - page, - }) => { - const routes = { - '/items/(.*)': '# Item Page ($1)', - }; + await navigateToRoute(page, '/items/apple'); - await docsifyInit({ - config: { - routes, - }, - }); + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Item Page (apple)'); + }); - await navigateToRoute(page, '/items/apple'); + test('virtual route functions should get the route as first parameter', async ({ + page, + }) => { + const routes = { + '/pets/(.*)': function (route) { + return `# Route: /pets/dog`; + }, + }; - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Item Page (apple)'); + await docsifyInit({ + config: { + routes, + }, }); - test('virtual route functions should get the route as first parameter', async ({ - page, - }) => { - const routes = { - '/pets/(.*)': function (route) { - return `# Route: /pets/dog`; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/pets/dog'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Route: /pets/dog'); + await navigateToRoute(page, '/pets/dog'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Route: /pets/dog'); + }); + + test('virtual route functions should get the matched array as second parameter', async ({ + page, + }) => { + const routes = { + '/pets/(.*)': function (_, matched) { + return `# Pets Page (${matched[1]})`; + }, + }; + + await docsifyInit({ + config: { + routes, + }, }); - test('virtual route functions should get the matched array as second parameter', async ({ - page, - }) => { - const routes = { - '/pets/(.*)': function (_, matched) { - return `# Pets Page (${matched[1]})`; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/pets/cat'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Pets Page (cat)'); + await navigateToRoute(page, '/pets/cat'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Pets Page (cat)'); + }); + }); + + test.describe('Route Matching Specifics', () => { + test('routes should be exact match if no regex was passed', async ({ + page, + }) => { + const routes = { + '/my': '# Incorrect Route - only prefix', + '/route': '# Incorrect Route - only postfix', + '/my/route': '# Correct Route', + }; + + await docsifyInit({ + config: { + routes, + }, }); + + await navigateToRoute(page, '/my/route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Correct Route'); + }); + + test('if there are two routes that match, the first one should be taken', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': '# First Match', + '/multiple/(.*)': '# Second Match', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/multiple/matches'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('First Match'); + }); + + test('prefer virtual route over a real file, if a virtual route exists', async ({ + page, + }) => { + const routes = { + '/': '# Virtual Homepage', + }; + + await docsifyInit({ + markdown: { + homepage: '# Real File Homepage', + }, + config: { + routes, + }, + }); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Virtual Homepage'); + }); + + test('fallback to default routing if no route was matched', async ({ + page, + }) => { + const routes = { + '/a': '# A', + '/b': '# B', + '/c': '# C', + }; + + await docsifyInit({ + markdown: { + homepage: '# Real File Homepage', + }, + config: { + routes, + }, + }); + + await navigateToRoute(page, '/d'); + + const mainElm = page.locator('#main'); + await expect(mainElm).toContainText('404 - Not found'); + }); + + test('skip routes that returned a falsy value that is not a boolean', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': () => null, + '/multiple/(.*)': () => undefined, + '/multiple/.+': () => 0, + '/multiple/.*': () => '# Last Match', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/multiple/matches'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Last Match'); + }); + + test('abort virtual routes and not try the next one, if any matched route returned an explicit "false" boolean', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': () => false, + '/multiple/(.*)': () => "# You Shouldn't See Me", + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/multiple/matches'); + + const mainElm = page.locator('#main'); + await expect(mainElm).toContainText('404 - Not found'); + }); + + test('skip routes that are not a valid string or function', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': 123, + '/multiple/(.*)': false, + '/multiple/.+': null, + '/multiple/..+': [], + '/multiple/..*': {}, + '/multiple/.*': '# Last Match', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/multiple/matches'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Last Match'); }); - } -); + }); +}); From 9d816bc27f3c56e4a7d3c66362d9ee1b73c47fce Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Sun, 15 May 2022 03:11:51 +0300 Subject: [PATCH 06/22] added hack to fix config test on firefox --- test/e2e/configuration.test.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/e2e/configuration.test.js b/test/e2e/configuration.test.js index 8aa3c5f08..1ca4dd165 100644 --- a/test/e2e/configuration.test.js +++ b/test/e2e/configuration.test.js @@ -37,12 +37,26 @@ test.describe('Configuration options', () => { await expect(mainElm).toContainText('beforeEach'); }); - test('catchPluginErrors:false (throws uncaught errors)', async ({ page }) => { + test('catchPluginErrors:false (throws uncaught errors)', async ({ + page, + browserName, + }) => { let consoleMsg, errorMsg; page.on('console', msg => (consoleMsg = msg.text())); page.on('pageerror', err => (errorMsg = err.message)); + // firefox has some funky behavior with unhandled promise rejections. see related issue on playwright: https://github.com/microsoft/playwright/issues/14165 + if (browserName === 'firefox') { + page.on('domcontentloaded', () => + page.evaluate(() => + window.addEventListener('unhandledrejection', err => { + throw err.reason; + }) + ) + ); + } + await docsifyInit({ config: { catchPluginErrors: false, From fb084f871eb3d0760105d416dcbc3a395f51c13e Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Wed, 18 May 2022 00:06:03 +0300 Subject: [PATCH 07/22] removed formatting regex matches into string routes --- src/core/virtual-routes/index.js | 7 +- test/e2e/virtual-routes.test.js | 504 +++++++++++++++---------------- 2 files changed, 245 insertions(+), 266 deletions(-) diff --git a/src/core/virtual-routes/index.js b/src/core/virtual-routes/index.js index 3fa37439f..1263fc470 100644 --- a/src/core/virtual-routes/index.js +++ b/src/core/virtual-routes/index.js @@ -50,12 +50,7 @@ export function VirtualRoutes(Base) { const virtualRouteContentOrFn = virtualRoutes[virtualRoutePath]; if (typeof virtualRouteContentOrFn === 'string') { - const contents = virtualRouteContentOrFn.replace( - /\$(\d+)/g, - (_, index) => matched[parseInt(index, 10)] - ); - - return Promise.resolve(contents); + return Promise.resolve(virtualRouteContentOrFn); } else if (typeof virtualRouteContentOrFn === 'function') { return Promise.resolve() .then(() => virtualRouteContentOrFn(path, matched)) diff --git a/test/e2e/virtual-routes.test.js b/test/e2e/virtual-routes.test.js index 47c0cf7c1..48bef41cc 100644 --- a/test/e2e/virtual-routes.test.js +++ b/test/e2e/virtual-routes.test.js @@ -2,298 +2,282 @@ const docsifyInit = require('../helpers/docsify-init'); const { navigateToRoute } = require('../helpers/navigate'); const { test, expect } = require('./fixtures/docsify-init-fixture'); -test.describe('Virtual Routes - Generate Dynamic Content via Config', () => { - test.describe('Different Types of Virtual Routes', () => { - test('rendering virtual routes specified as string', async ({ page }) => { - const routes = { - '/my-awesome-route': '# My Awesome Route', - }; - - await docsifyInit({ - config: { - routes, - }, +test.describe.only( + 'Virtual Routes - Generate Dynamic Content via Config', + () => { + test.describe('Different Types of Virtual Routes', () => { + test('rendering virtual routes specified as string', async ({ page }) => { + const routes = { + '/my-awesome-route': '# My Awesome Route', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my-awesome-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Route'); }); - await navigateToRoute(page, '/my-awesome-route'); + test('rendering virtual routes specified as functions', async ({ + page, + }) => { + const routes = { + '/my-awesome-function-route': function () { + return '# My Awesome Function Route'; + }, + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my-awesome-function-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); + }); - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Route'); + test('rendering virtual routes specified as async functions', async ({ + page, + }) => { + const routes = { + '/my-awesome-async-function-route': async function () { + return '# My Awesome Function Route'; + }, + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my-awesome-async-function-route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); + }); }); - test('rendering virtual routes specified as functions', async ({ - page, - }) => { - const routes = { - '/my-awesome-function-route': function () { - return '# My Awesome Function Route'; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); + test.describe('Routes with Regex Matches', () => { + test('rendering virtual routes with regex matches', async ({ page }) => { + const routes = { + '/items/(.*)': '# Item Page', + }; - await navigateToRoute(page, '/my-awesome-function-route'); + await docsifyInit({ + config: { + routes, + }, + }); - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Function Route'); - }); + await navigateToRoute(page, '/items/banana'); - test('rendering virtual routes specified as async functions', async ({ - page, - }) => { - const routes = { - '/my-awesome-async-function-route': async function () { - return '# My Awesome Function Route'; - }, - }; - - await docsifyInit({ - config: { - routes, - }, + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Item Page'); }); - await navigateToRoute(page, '/my-awesome-async-function-route'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Function Route'); - }); - }); - - test.describe('Routes with Regex Matches', () => { - test('rendering virtual routes with regex matches', async ({ page }) => { - const routes = { - '/items/(.*)': '# Item Page', - }; - - await docsifyInit({ - config: { - routes, - }, + test('virtual route functions should get the route as first parameter', async ({ + page, + }) => { + const routes = { + '/pets/(.*)': function (route) { + return `# Route: /pets/dog`; + }, + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/pets/dog'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Route: /pets/dog'); }); - await navigateToRoute(page, '/items/banana'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Item Page'); - }); - - test('formatting regex matches into string virtual routes', async ({ - page, - }) => { - const routes = { - '/items/(.*)': '# Item Page ($1)', - }; - - await docsifyInit({ - config: { - routes, - }, + test('virtual route functions should get the matched array as second parameter', async ({ + page, + }) => { + const routes = { + '/pets/(.*)': function (_, matched) { + return `# Pets Page (${matched[1]})`; + }, + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/pets/cat'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Pets Page (cat)'); }); - - await navigateToRoute(page, '/items/apple'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Item Page (apple)'); }); - test('virtual route functions should get the route as first parameter', async ({ - page, - }) => { - const routes = { - '/pets/(.*)': function (route) { - return `# Route: /pets/dog`; - }, - }; - - await docsifyInit({ - config: { - routes, - }, + test.describe('Route Matching Specifics', () => { + test('routes should be exact match if no regex was passed', async ({ + page, + }) => { + const routes = { + '/my': '# Incorrect Route - only prefix', + '/route': '# Incorrect Route - only postfix', + '/my/route': '# Correct Route', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/my/route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Correct Route'); }); - await navigateToRoute(page, '/pets/dog'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Route: /pets/dog'); - }); + test('if there are two routes that match, the first one should be taken', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': '# First Match', + '/multiple/(.*)': '# Second Match', + }; - test('virtual route functions should get the matched array as second parameter', async ({ - page, - }) => { - const routes = { - '/pets/(.*)': function (_, matched) { - return `# Pets Page (${matched[1]})`; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); + await docsifyInit({ + config: { + routes, + }, + }); - await navigateToRoute(page, '/pets/cat'); + await navigateToRoute(page, '/multiple/matches'); - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Pets Page (cat)'); - }); - }); - - test.describe('Route Matching Specifics', () => { - test('routes should be exact match if no regex was passed', async ({ - page, - }) => { - const routes = { - '/my': '# Incorrect Route - only prefix', - '/route': '# Incorrect Route - only postfix', - '/my/route': '# Correct Route', - }; - - await docsifyInit({ - config: { - routes, - }, + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('First Match'); }); - await navigateToRoute(page, '/my/route'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Correct Route'); - }); - - test('if there are two routes that match, the first one should be taken', async ({ - page, - }) => { - const routes = { - '/multiple/(.+)': '# First Match', - '/multiple/(.*)': '# Second Match', - }; - - await docsifyInit({ - config: { - routes, - }, + test('prefer virtual route over a real file, if a virtual route exists', async ({ + page, + }) => { + const routes = { + '/': '# Virtual Homepage', + }; + + await docsifyInit({ + markdown: { + homepage: '# Real File Homepage', + }, + config: { + routes, + }, + }); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Virtual Homepage'); }); - await navigateToRoute(page, '/multiple/matches'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('First Match'); - }); - - test('prefer virtual route over a real file, if a virtual route exists', async ({ - page, - }) => { - const routes = { - '/': '# Virtual Homepage', - }; - - await docsifyInit({ - markdown: { - homepage: '# Real File Homepage', - }, - config: { - routes, - }, + test('fallback to default routing if no route was matched', async ({ + page, + }) => { + const routes = { + '/a': '# A', + '/b': '# B', + '/c': '# C', + }; + + await docsifyInit({ + markdown: { + homepage: '# Real File Homepage', + }, + config: { + routes, + }, + }); + + await navigateToRoute(page, '/d'); + + const mainElm = page.locator('#main'); + await expect(mainElm).toContainText('404 - Not found'); }); - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Virtual Homepage'); - }); - - test('fallback to default routing if no route was matched', async ({ - page, - }) => { - const routes = { - '/a': '# A', - '/b': '# B', - '/c': '# C', - }; - - await docsifyInit({ - markdown: { - homepage: '# Real File Homepage', - }, - config: { - routes, - }, + test('skip routes that returned a falsy value that is not a boolean', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': () => null, + '/multiple/(.*)': () => undefined, + '/multiple/.+': () => 0, + '/multiple/.*': () => '# Last Match', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/multiple/matches'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Last Match'); }); - await navigateToRoute(page, '/d'); - - const mainElm = page.locator('#main'); - await expect(mainElm).toContainText('404 - Not found'); - }); - - test('skip routes that returned a falsy value that is not a boolean', async ({ - page, - }) => { - const routes = { - '/multiple/(.+)': () => null, - '/multiple/(.*)': () => undefined, - '/multiple/.+': () => 0, - '/multiple/.*': () => '# Last Match', - }; - - await docsifyInit({ - config: { - routes, - }, - }); + test('abort virtual routes and not try the next one, if any matched route returned an explicit "false" boolean', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': () => false, + '/multiple/(.*)': () => "# You Shouldn't See Me", + }; - await navigateToRoute(page, '/multiple/matches'); + await docsifyInit({ + config: { + routes, + }, + }); - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Last Match'); - }); + await navigateToRoute(page, '/multiple/matches'); - test('abort virtual routes and not try the next one, if any matched route returned an explicit "false" boolean', async ({ - page, - }) => { - const routes = { - '/multiple/(.+)': () => false, - '/multiple/(.*)': () => "# You Shouldn't See Me", - }; - - await docsifyInit({ - config: { - routes, - }, + const mainElm = page.locator('#main'); + await expect(mainElm).toContainText('404 - Not found'); }); - await navigateToRoute(page, '/multiple/matches'); - - const mainElm = page.locator('#main'); - await expect(mainElm).toContainText('404 - Not found'); - }); - - test('skip routes that are not a valid string or function', async ({ - page, - }) => { - const routes = { - '/multiple/(.+)': 123, - '/multiple/(.*)': false, - '/multiple/.+': null, - '/multiple/..+': [], - '/multiple/..*': {}, - '/multiple/.*': '# Last Match', - }; - - await docsifyInit({ - config: { - routes, - }, + test('skip routes that are not a valid string or function', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': 123, + '/multiple/(.*)': false, + '/multiple/.+': null, + '/multiple/..+': [], + '/multiple/..*': {}, + '/multiple/.*': '# Last Match', + }; + + await docsifyInit({ + config: { + routes, + }, + }); + + await navigateToRoute(page, '/multiple/matches'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Last Match'); }); - - await navigateToRoute(page, '/multiple/matches'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Last Match'); }); - }); -}); + } +); From 1866fa2c2e5a160c49137b9d1ed9d1baa306bb26 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Wed, 18 May 2022 00:34:00 +0300 Subject: [PATCH 08/22] added support for "next" function --- .../{match-utils.js => exact-match.js} | 0 src/core/virtual-routes/index.js | 13 +- src/core/virtual-routes/next.js | 17 + test/e2e/virtual-routes.test.js | 491 +++++++++--------- 4 files changed, 274 insertions(+), 247 deletions(-) rename src/core/virtual-routes/{match-utils.js => exact-match.js} (100%) create mode 100644 src/core/virtual-routes/next.js diff --git a/src/core/virtual-routes/match-utils.js b/src/core/virtual-routes/exact-match.js similarity index 100% rename from src/core/virtual-routes/match-utils.js rename to src/core/virtual-routes/exact-match.js diff --git a/src/core/virtual-routes/index.js b/src/core/virtual-routes/index.js index 1263fc470..9e0adc9f1 100644 --- a/src/core/virtual-routes/index.js +++ b/src/core/virtual-routes/index.js @@ -1,4 +1,5 @@ -import { makeExactMatcher } from './match-utils'; +import { makeExactMatcher } from './exact-match'; +import { createNextFunction } from './next'; /** @typedef {import('../Docsify').Constructor} Constructor */ @@ -53,7 +54,15 @@ export function VirtualRoutes(Base) { return Promise.resolve(virtualRouteContentOrFn); } else if (typeof virtualRouteContentOrFn === 'function') { return Promise.resolve() - .then(() => virtualRouteContentOrFn(path, matched)) + .then(() => { + if (virtualRouteContentOrFn.length <= 2) { + return virtualRouteContentOrFn(path, matched); + } else { + const [resultPromise, next] = createNextFunction(); + virtualRouteContentOrFn(path, matched, next); + return resultPromise; + } + }) .then(contents => { if (typeof contents === 'string') { return contents; diff --git a/src/core/virtual-routes/next.js b/src/core/virtual-routes/next.js new file mode 100644 index 000000000..d560e1a38 --- /dev/null +++ b/src/core/virtual-routes/next.js @@ -0,0 +1,17 @@ +/** @typedef {(value: any) => void} NextFunction */ + +/** + * Creates a pair of a function and a promise. + * When the function is called, the promise is resolved with the value that was passed to the function. + * @returns {[Promise, NextFunction]} + */ +export function createNextFunction() { + let resolvePromise; + const promise = new Promise(res => (resolvePromise = res)); + + function next(value) { + resolvePromise(value); + } + + return [promise, next]; +} diff --git a/test/e2e/virtual-routes.test.js b/test/e2e/virtual-routes.test.js index 48bef41cc..c518c6706 100644 --- a/test/e2e/virtual-routes.test.js +++ b/test/e2e/virtual-routes.test.js @@ -2,282 +2,283 @@ const docsifyInit = require('../helpers/docsify-init'); const { navigateToRoute } = require('../helpers/navigate'); const { test, expect } = require('./fixtures/docsify-init-fixture'); -test.describe.only( - 'Virtual Routes - Generate Dynamic Content via Config', - () => { - test.describe('Different Types of Virtual Routes', () => { - test('rendering virtual routes specified as string', async ({ page }) => { - const routes = { - '/my-awesome-route': '# My Awesome Route', - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/my-awesome-route'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Route'); +test.describe('Virtual Routes - Generate Dynamic Content via Config', () => { + test.describe('Different Types of Virtual Routes', () => { + test('rendering virtual routes specified as string', async ({ page }) => { + const routes = { + '/my-awesome-route': '# My Awesome Route', + }; + + await docsifyInit({ + config: { + routes, + }, }); - test('rendering virtual routes specified as functions', async ({ - page, - }) => { - const routes = { - '/my-awesome-function-route': function () { - return '# My Awesome Function Route'; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/my-awesome-function-route'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Function Route'); - }); + await navigateToRoute(page, '/my-awesome-route'); - test('rendering virtual routes specified as async functions', async ({ - page, - }) => { - const routes = { - '/my-awesome-async-function-route': async function () { - return '# My Awesome Function Route'; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/my-awesome-async-function-route'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('My Awesome Function Route'); - }); + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Route'); }); - test.describe('Routes with Regex Matches', () => { - test('rendering virtual routes with regex matches', async ({ page }) => { - const routes = { - '/items/(.*)': '# Item Page', - }; + test('rendering virtual routes specified as functions', async ({ + page, + }) => { + const routes = { + '/my-awesome-function-route': function () { + return '# My Awesome Function Route'; + }, + }; + + await docsifyInit({ + config: { + routes, + }, + }); - await docsifyInit({ - config: { - routes, - }, - }); + await navigateToRoute(page, '/my-awesome-function-route'); - await navigateToRoute(page, '/items/banana'); + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); + }); - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Item Page'); + test('rendering virtual routes specified functions that use the "next" callback', async ({ + page, + }) => { + const routes = { + '/my-awesome-async-function-route': async function ( + route, + matched, + next + ) { + setTimeout(() => next('# My Awesome Function Route'), 100); + }, + }; + + await docsifyInit({ + config: { + routes, + }, }); - test('virtual route functions should get the route as first parameter', async ({ - page, - }) => { - const routes = { - '/pets/(.*)': function (route) { - return `# Route: /pets/dog`; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/pets/dog'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Route: /pets/dog'); - }); + await navigateToRoute(page, '/my-awesome-async-function-route'); - test('virtual route functions should get the matched array as second parameter', async ({ - page, - }) => { - const routes = { - '/pets/(.*)': function (_, matched) { - return `# Pets Page (${matched[1]})`; - }, - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/pets/cat'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Pets Page (cat)'); + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('My Awesome Function Route'); + }); + }); + + test.describe('Routes with Regex Matches', () => { + test('rendering virtual routes with regex matches', async ({ page }) => { + const routes = { + '/items/(.*)': '# Item Page', + }; + + await docsifyInit({ + config: { + routes, + }, }); + + await navigateToRoute(page, '/items/banana'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Item Page'); }); - test.describe('Route Matching Specifics', () => { - test('routes should be exact match if no regex was passed', async ({ - page, - }) => { - const routes = { - '/my': '# Incorrect Route - only prefix', - '/route': '# Incorrect Route - only postfix', - '/my/route': '# Correct Route', - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/my/route'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Correct Route'); + test('virtual route functions should get the route as first parameter', async ({ + page, + }) => { + const routes = { + '/pets/(.*)': function (route) { + return `# Route: /pets/dog`; + }, + }; + + await docsifyInit({ + config: { + routes, + }, }); - test('if there are two routes that match, the first one should be taken', async ({ - page, - }) => { - const routes = { - '/multiple/(.+)': '# First Match', - '/multiple/(.*)': '# Second Match', - }; + await navigateToRoute(page, '/pets/dog'); - await docsifyInit({ - config: { - routes, - }, - }); + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Route: /pets/dog'); + }); + + test('virtual route functions should get the matched array as second parameter', async ({ + page, + }) => { + const routes = { + '/pets/(.*)': function (_, matched) { + return `# Pets Page (${matched[1]})`; + }, + }; + + await docsifyInit({ + config: { + routes, + }, + }); - await navigateToRoute(page, '/multiple/matches'); + await navigateToRoute(page, '/pets/cat'); - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('First Match'); + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Pets Page (cat)'); + }); + }); + + test.describe('Route Matching Specifics', () => { + test('routes should be exact match if no regex was passed', async ({ + page, + }) => { + const routes = { + '/my': '# Incorrect Route - only prefix', + '/route': '# Incorrect Route - only postfix', + '/my/route': '# Correct Route', + }; + + await docsifyInit({ + config: { + routes, + }, }); - test('prefer virtual route over a real file, if a virtual route exists', async ({ - page, - }) => { - const routes = { - '/': '# Virtual Homepage', - }; - - await docsifyInit({ - markdown: { - homepage: '# Real File Homepage', - }, - config: { - routes, - }, - }); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Virtual Homepage'); + await navigateToRoute(page, '/my/route'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Correct Route'); + }); + + test('if there are two routes that match, the first one should be taken', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': '# First Match', + '/multiple/(.*)': '# Second Match', + }; + + await docsifyInit({ + config: { + routes, + }, }); - test('fallback to default routing if no route was matched', async ({ - page, - }) => { - const routes = { - '/a': '# A', - '/b': '# B', - '/c': '# C', - }; - - await docsifyInit({ - markdown: { - homepage: '# Real File Homepage', - }, - config: { - routes, - }, - }); - - await navigateToRoute(page, '/d'); - - const mainElm = page.locator('#main'); - await expect(mainElm).toContainText('404 - Not found'); + await navigateToRoute(page, '/multiple/matches'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('First Match'); + }); + + test('prefer virtual route over a real file, if a virtual route exists', async ({ + page, + }) => { + const routes = { + '/': '# Virtual Homepage', + }; + + await docsifyInit({ + markdown: { + homepage: '# Real File Homepage', + }, + config: { + routes, + }, }); - test('skip routes that returned a falsy value that is not a boolean', async ({ - page, - }) => { - const routes = { - '/multiple/(.+)': () => null, - '/multiple/(.*)': () => undefined, - '/multiple/.+': () => 0, - '/multiple/.*': () => '# Last Match', - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/multiple/matches'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Last Match'); + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Virtual Homepage'); + }); + + test('fallback to default routing if no route was matched', async ({ + page, + }) => { + const routes = { + '/a': '# A', + '/b': '# B', + '/c': '# C', + }; + + await docsifyInit({ + markdown: { + homepage: '# Real File Homepage', + }, + config: { + routes, + }, }); - test('abort virtual routes and not try the next one, if any matched route returned an explicit "false" boolean', async ({ - page, - }) => { - const routes = { - '/multiple/(.+)': () => false, - '/multiple/(.*)': () => "# You Shouldn't See Me", - }; + await navigateToRoute(page, '/d'); + + const mainElm = page.locator('#main'); + await expect(mainElm).toContainText('404 - Not found'); + }); - await docsifyInit({ - config: { - routes, - }, - }); + test('skip routes that returned a falsy value that is not a boolean', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': () => null, + '/multiple/(.*)': () => undefined, + '/multiple/.+': () => 0, + '/multiple/.*': () => '# Last Match', + }; + + await docsifyInit({ + config: { + routes, + }, + }); - await navigateToRoute(page, '/multiple/matches'); + await navigateToRoute(page, '/multiple/matches'); - const mainElm = page.locator('#main'); - await expect(mainElm).toContainText('404 - Not found'); + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Last Match'); + }); + + test('abort virtual routes and not try the next one, if any matched route returned an explicit "false" boolean', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': () => false, + '/multiple/(.*)': () => "# You Shouldn't See Me", + }; + + await docsifyInit({ + config: { + routes, + }, }); - test('skip routes that are not a valid string or function', async ({ - page, - }) => { - const routes = { - '/multiple/(.+)': 123, - '/multiple/(.*)': false, - '/multiple/.+': null, - '/multiple/..+': [], - '/multiple/..*': {}, - '/multiple/.*': '# Last Match', - }; - - await docsifyInit({ - config: { - routes, - }, - }); - - await navigateToRoute(page, '/multiple/matches'); - - const titleElm = page.locator('#main h1'); - await expect(titleElm).toContainText('Last Match'); + await navigateToRoute(page, '/multiple/matches'); + + const mainElm = page.locator('#main'); + await expect(mainElm).toContainText('404 - Not found'); + }); + + test('skip routes that are not a valid string or function', async ({ + page, + }) => { + const routes = { + '/multiple/(.+)': 123, + '/multiple/(.*)': false, + '/multiple/.+': null, + '/multiple/..+': [], + '/multiple/..*': {}, + '/multiple/.*': '# Last Match', + }; + + await docsifyInit({ + config: { + routes, + }, }); + + await navigateToRoute(page, '/multiple/matches'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Last Match'); }); - } -); + }); +}); From 8bf6890717eca73c51e708f84cffc4f27c690b79 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Wed, 18 May 2022 01:02:46 +0300 Subject: [PATCH 09/22] added docs --- docs/configuration.md | 82 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 8365a93d6..b5ceb9a11 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -680,6 +680,88 @@ window.$docsify = { }; ``` +## routes + +- Type: `Object` + +Define "virtual" routes that can provide content dynamically. A route is a map between the expected path, to either a string or a function. If the mapped value is a string, it is treated as markdown and parsed accordingly. If it is a function, it is expected to return markdown content. + +A route function receives up to three parameters: +1. `route` - the path of the route that was requested (e.g. `/bar/shalom`) +2. `matched` - the `RegExpMatchArray` that was matched by the route (e.g. for `/bar/(.+)`, you get `['/bar/shalom', 'shalom']`) +3. `next` - this is a callback that you may call when your route function is async + +```js +window.$docsify = { + routes: { + // Basic match w/ return string + '/foo': '# Custom Markdown', + + // RegEx match w/ synchronous function + '/bar/(.*)': function(route, matched) { + console.log(`Route match: ${matched[0]}`); + return '# Custom Markdown'; + }, + + // RegEx match w/ asynchronous function + '/baz/(.*)': function(route, matched, next) { + console.log(`Route match: ${matched[0]}`); + next('# Custom Markdown'); + } + } +} +``` + +Other than strings, route functions can return a falsy value (`null` \ `undefined`) to indicate that they ignore the current request: + +```js +window.$docsify = { + routes: { + // accepts everything other than dogs + '/pets/(.+)': function(route, matched) { + if (matched[0] === 'dogs') { + return null; + } else { + return 'I like all pets but dogs'; + } + } + + // accepts everything other than cats + '/pets/(.*)': async function(route, matched, next) { + if (matched[0] === 'cats') { + next(); + } else { + next('I like all pets but cats'); + } + } + } +} +``` + +Finally, if you have a specific path that has a real markdown file (and therefore should not be matched by your route), you can opt it out by returning an explicit `false` value: + +```js +window.$docsify = { + routes: { + // if you look up /pets/cats, docsify will skip all routes and look for "pets/cats.md" + '/pets/cats': function(route, matched) { + return false; + } + + // if you look up /pets/dogs, docsify will skip all routes and look for "pets/dogs.md" + '/pets/dogs': async function(route, matched, next) { + next(false); + } + + // but any other pet should generate dynamic content right here + '/pets/(.+)': function(route, matched) { + const pet = matched[0]; + return `your pet is ${pet} (but not a dog nor a cat)`; + } + } +} +``` + ## subMaxLevel - Type: `Number` From a022b3d292001b3be89e6953c850b992f375021f Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 00:32:30 +0300 Subject: [PATCH 10/22] navigate now supports both hash and history routerModes --- test/helpers/navigate.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/test/helpers/navigate.js b/test/helpers/navigate.js index e19e33c65..76a815787 100644 --- a/test/helpers/navigate.js +++ b/test/helpers/navigate.js @@ -1,12 +1,17 @@ /** - * Navigate to a specific hashtag route in the page, and wait for docsify to handle navigation. - * @param {import('playwright-core').Page} page - * @param {string} route + * Navigate to a specific route in the site + * @param {import('playwright-core').Page} page the playwright page instance from the test + * @param {string} route the route you want to navigate to + * @param {Object} opts additional options (optional) + * @param {'hash' | 'history'} opts.routerMode which router mode to use. Defaults to "hash" */ -async function navigateToRoute(page, route) { - await page.evaluate(r => (window.location.hash = r), route); - const mainElm = await page.waitForSelector('#main'); - await mainElm.waitForElementState('stable'); + +async function navigateToRoute(page, route, { routerMode = 'hash' } = {}) { + if (routerMode === 'hash') { + await page.evaluate(r => (window.location.hash = r), route); + } else { + await page.evaluate(r => window.history.pushState({ key: r }, '', r)); + } } module.exports.navigateToRoute = navigateToRoute; From 1447898ca48fe08e0fecb0d5d834fae3ee603a4a Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 00:45:59 +0300 Subject: [PATCH 11/22] waiting for networkidle in navigateToRoute helper --- test/helpers/navigate.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/helpers/navigate.js b/test/helpers/navigate.js index 76a815787..8bae537d0 100644 --- a/test/helpers/navigate.js +++ b/test/helpers/navigate.js @@ -12,6 +12,8 @@ async function navigateToRoute(page, route, { routerMode = 'hash' } = {}) { } else { await page.evaluate(r => window.history.pushState({ key: r }, '', r)); } + + await page.waitForLoadState('networkidle'); } module.exports.navigateToRoute = navigateToRoute; From e8a12c0685ebd3f862107e101ab862c5a0cbd9e9 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 01:38:41 +0300 Subject: [PATCH 12/22] promiseless implementation --- src/core/fetch/index.js | 60 +++++++++++++------------- src/core/virtual-routes/index.js | 72 ++++++++++++++++++-------------- src/core/virtual-routes/next.js | 18 ++++---- 3 files changed, 82 insertions(+), 68 deletions(-) diff --git a/src/core/fetch/index.js b/src/core/fetch/index.js index 5cf63b6c9..cb363bdb7 100644 --- a/src/core/fetch/index.js +++ b/src/core/fetch/index.js @@ -101,39 +101,39 @@ export function Fetch(Base) { // Current page is html this.isHTML = /\.html$/g.test(file); - // Load main content - const req = Promise.resolve() - .then(() => { - if (!this.isRemoteUrl) { - return this.matchVirtualRoute(path); - } else { - return null; - } - }) - .then(text => { - if (typeof text === 'string') { - return { - then(fn) { - fn(text, {}); - }, - }; + // create a handler that should be called if content was fetched successfully + const contentFetched = (text, opt) => { + this._renderMain( + text, + opt, + this._loadSideAndNav(path, qs, loadSidebar, cb) + ); + }; + + // and a handler that is called if content failed to fetch + const contentFailedToFetch = _ => { + this._fetchFallbackPage(path, qs, cb) || this._fetch404(file, qs, cb); + }; + + // attempt to fetch content from a virtual route, and fallback to fetching the actual file + if (!this.isRemoteUrl) { + this.matchVirtualRoute(path).then(contents => { + if (typeof contents === 'string') { + contentFetched(contents); } else { - return request(file + qs, true, requestHeaders); + request(file + qs, true, requestHeaders).then( + contentFetched, + contentFailedToFetch + ); } }); - - req.then( - (text, opt) => - this._renderMain( - text, - opt, - this._loadSideAndNav(path, qs, loadSidebar, cb) - ), - _ => { - this._fetchFallbackPage(path, qs, cb) || - this._fetch404(file, qs, cb); - } - ); + } else { + // if the requested url is not local, just fetch the file + request(file + qs, true, requestHeaders).then( + contentFetched, + contentFailedToFetch + ); + } // Load nav loadNavbar && diff --git a/src/core/virtual-routes/index.js b/src/core/virtual-routes/index.js index 9e0adc9f1..bc4eaa647 100644 --- a/src/core/virtual-routes/index.js +++ b/src/core/virtual-routes/index.js @@ -22,62 +22,72 @@ export function VirtualRoutes(Base) { /** * Attempts to match the given path with a virtual route. - * @param {string} path + * @param {string} path the path of the route to match * @returns {Promise} resolves to string if route was matched, otherwise null */ matchVirtualRoute(path) { const virtualRoutes = this.routes(); - const virtualRoutePaths = Object.keys(virtualRoutes); + let done = () => null; + /** - * This is a tail recursion that resolves to the first properly matched route, to itself or to null. - * Used because async\await is not supported, so for loops over promises are out of the question... - * @returns {Promise} + * This is a tail recursion that iterates over all the available routes. + * It can result in one of two ways: + * 1. Call itself (essentially reviewing the next route) + * 2. Call the "done" callback with the result (either the contents, or "null" if no match was found) */ function asyncMatchNextRoute() { const virtualRoutePath = virtualRoutePaths.shift(); if (!virtualRoutePath) { - return Promise.resolve(null); + return done(null); } const matcher = makeExactMatcher(virtualRoutePath); const matched = path.match(matcher); if (!matched) { - return Promise.resolve().then(asyncMatchNextRoute); + return asyncMatchNextRoute(); } const virtualRouteContentOrFn = virtualRoutes[virtualRoutePath]; if (typeof virtualRouteContentOrFn === 'string') { - return Promise.resolve(virtualRouteContentOrFn); - } else if (typeof virtualRouteContentOrFn === 'function') { - return Promise.resolve() - .then(() => { - if (virtualRouteContentOrFn.length <= 2) { - return virtualRouteContentOrFn(path, matched); - } else { - const [resultPromise, next] = createNextFunction(); - virtualRouteContentOrFn(path, matched, next); - return resultPromise; - } - }) - .then(contents => { - if (typeof contents === 'string') { - return contents; - } else if (contents === false) { - return null; - } else { - return asyncMatchNextRoute(); - } - }); - } else { - return Promise.resolve().then(asyncMatchNextRoute); + const contents = virtualRouteContentOrFn; + return done(contents); } + + if (typeof virtualRouteContentOrFn === 'function') { + const fn = virtualRouteContentOrFn; + + const [next, onNext] = createNextFunction(); + onNext(contents => { + if (typeof contents === 'string') { + return done(contents); + } else if (contents === false) { + return done(null); + } else { + return asyncMatchNextRoute(); + } + }); + + if (fn.length <= 2) { + const returnedValue = fn(path, matched); + return next(returnedValue); + } else { + return fn(path, matched, next); + } + } + + return asyncMatchNextRoute(); } - return asyncMatchNextRoute(); + return { + then: function (cb) { + done = cb; + asyncMatchNextRoute(); + }, + }; } }; } diff --git a/src/core/virtual-routes/next.js b/src/core/virtual-routes/next.js index d560e1a38..1b14904a1 100644 --- a/src/core/virtual-routes/next.js +++ b/src/core/virtual-routes/next.js @@ -1,17 +1,21 @@ +/** @typedef {((value: any) => void) => void} OnNext */ /** @typedef {(value: any) => void} NextFunction */ /** - * Creates a pair of a function and a promise. - * When the function is called, the promise is resolved with the value that was passed to the function. - * @returns {[Promise, NextFunction]} + * Creates a pair of a function and an event emitter. + * When the function is called, the event emitter calls the given callback with the value that was passed to the function. + * @returns {[NextFunction, OnNext]} */ export function createNextFunction() { - let resolvePromise; - const promise = new Promise(res => (resolvePromise = res)); + let storedCb = () => null; function next(value) { - resolvePromise(value); + storedCb(value); } - return [promise, next]; + function onNext(cb) { + storedCb = cb; + } + + return [next, onNext]; } From e81429df1403671ba1c8149717b904d0e15ba4f7 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 01:39:59 +0300 Subject: [PATCH 13/22] remove firefox workaround from catchPluginErrors test, since we no longer use promises --- test/e2e/configuration.test.js | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/test/e2e/configuration.test.js b/test/e2e/configuration.test.js index 1ca4dd165..8aa3c5f08 100644 --- a/test/e2e/configuration.test.js +++ b/test/e2e/configuration.test.js @@ -37,26 +37,12 @@ test.describe('Configuration options', () => { await expect(mainElm).toContainText('beforeEach'); }); - test('catchPluginErrors:false (throws uncaught errors)', async ({ - page, - browserName, - }) => { + test('catchPluginErrors:false (throws uncaught errors)', async ({ page }) => { let consoleMsg, errorMsg; page.on('console', msg => (consoleMsg = msg.text())); page.on('pageerror', err => (errorMsg = err.message)); - // firefox has some funky behavior with unhandled promise rejections. see related issue on playwright: https://github.com/microsoft/playwright/issues/14165 - if (browserName === 'firefox') { - page.on('domcontentloaded', () => - page.evaluate(() => - window.addEventListener('unhandledrejection', err => { - throw err.reason; - }) - ) - ); - } - await docsifyInit({ config: { catchPluginErrors: false, From f0be4ca227e538881e4dbae61e5ced162112ba57 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 01:53:32 +0300 Subject: [PATCH 14/22] updated docs --- docs/configuration.md | 55 +++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b5ceb9a11..3141f8173 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -687,8 +687,8 @@ window.$docsify = { Define "virtual" routes that can provide content dynamically. A route is a map between the expected path, to either a string or a function. If the mapped value is a string, it is treated as markdown and parsed accordingly. If it is a function, it is expected to return markdown content. A route function receives up to three parameters: -1. `route` - the path of the route that was requested (e.g. `/bar/shalom`) -2. `matched` - the `RegExpMatchArray` that was matched by the route (e.g. for `/bar/(.+)`, you get `['/bar/shalom', 'shalom']`) +1. `route` - the path of the route that was requested (e.g. `/bar/baz`) +2. `matched` - the [`RegExpMatchArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match) that was matched by the route (e.g. for `/bar/(.+)`, you get `['/bar/baz', 'baz']`) 3. `next` - this is a callback that you may call when your route function is async ```js @@ -699,14 +699,18 @@ window.$docsify = { // RegEx match w/ synchronous function '/bar/(.*)': function(route, matched) { - console.log(`Route match: ${matched[0]}`); return '# Custom Markdown'; }, // RegEx match w/ asynchronous function '/baz/(.*)': function(route, matched, next) { - console.log(`Route match: ${matched[0]}`); - next('# Custom Markdown'); + try { + // Async task(s)... + } catch (err) { + // ... + } finally { + next('# Custom Markdown'); + } } } } @@ -717,7 +721,7 @@ Other than strings, route functions can return a falsy value (`null` \ `undefine ```js window.$docsify = { routes: { - // accepts everything other than dogs + // accepts everything other than dogs (synchronous) '/pets/(.+)': function(route, matched) { if (matched[0] === 'dogs') { return null; @@ -726,18 +730,44 @@ window.$docsify = { } } - // accepts everything other than cats - '/pets/(.*)': async function(route, matched, next) { + // accepts everything other than cats (asynchronous) + '/pets/(.*)': function(route, matched, next) { if (matched[0] === 'cats') { next(); } else { - next('I like all pets but cats'); + try { + // Async task(s)... + } catch (err) { + // ... + } finally { + next('I like all pets but cats'); + } } } } } ``` +Do note that order matters! Routes are matched the same order that you declare them, so in cases where you have overlapping routes, you might want to list the more specific ones first: + +```js +window.$docsify = { + routes: { + // if you look up /pets/cats, this route is always matched first + '/pets/cats': function(route, matched) { + return 'This is a special page for cats!'; + } + + // and this route will match every other pet, but never cats, since it is the second route to be declared + '/pets/(.+)': function(route, matched) { + const pet = matched[0]; + return `your pet is ${pet} (but not a cat, so it doesn't get its own route!)`; + } + } +} +``` + + Finally, if you have a specific path that has a real markdown file (and therefore should not be matched by your route), you can opt it out by returning an explicit `false` value: ```js @@ -748,15 +778,10 @@ window.$docsify = { return false; } - // if you look up /pets/dogs, docsify will skip all routes and look for "pets/dogs.md" - '/pets/dogs': async function(route, matched, next) { - next(false); - } - // but any other pet should generate dynamic content right here '/pets/(.+)': function(route, matched) { const pet = matched[0]; - return `your pet is ${pet} (but not a dog nor a cat)`; + return `your pet is ${pet} (but not a cat)`; } } } From dbf45d4049780e7e9ca2e7d3949a406407a5f2d7 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 01:54:32 +0300 Subject: [PATCH 15/22] updated docs for "alias" as well --- docs/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 3141f8173..e92a96068 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,6 +48,8 @@ window.$docsify = { }; ``` +Do note that order matters! If a route can be matched by multiple aliases, the one you declared first takes precedence. + ## auto2top - Type: `Boolean` From dfbf77f0cbe247920ce203f126949eaab694e3de Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 01:59:31 +0300 Subject: [PATCH 16/22] minor rephrasing --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index e92a96068..ba2ceae3a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -750,7 +750,7 @@ window.$docsify = { } ``` -Do note that order matters! Routes are matched the same order that you declare them, so in cases where you have overlapping routes, you might want to list the more specific ones first: +Do note that order matters! Routes are matched the same order you declare them in, which means that in cases where you have overlapping routes, you might want to list the more specific ones first: ```js window.$docsify = { From a245a2bb2f25998e154fb5ea7b1520a5d3f45cf7 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 19:53:08 +0300 Subject: [PATCH 17/22] removed non-legacy code from exact-match; updated navigateToRoute helper to infer router mode from page --- src/core/virtual-routes/exact-match.js | 7 +++---- test/helpers/navigate.js | 12 ++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/core/virtual-routes/exact-match.js b/src/core/virtual-routes/exact-match.js index 2f38cb592..9dea4a22c 100644 --- a/src/core/virtual-routes/exact-match.js +++ b/src/core/virtual-routes/exact-match.js @@ -4,12 +4,11 @@ * @returns {string} */ export function makeExactMatcher(matcher) { - const matcherWithBeginningOfInput = matcher.startsWith('^') - ? matcher - : `^${matcher}`; + const matcherWithBeginningOfInput = + matcher.slice(0, 1) === '^' ? matcher : `^${matcher}`; const matcherWithBeginningAndEndOfInput = - matcherWithBeginningOfInput.endsWith('$') + matcherWithBeginningOfInput.slice(-1) === '$' ? matcherWithBeginningOfInput : `${matcherWithBeginningOfInput}$`; diff --git a/test/helpers/navigate.js b/test/helpers/navigate.js index 8bae537d0..8155f3ea9 100644 --- a/test/helpers/navigate.js +++ b/test/helpers/navigate.js @@ -2,15 +2,15 @@ * Navigate to a specific route in the site * @param {import('playwright-core').Page} page the playwright page instance from the test * @param {string} route the route you want to navigate to - * @param {Object} opts additional options (optional) - * @param {'hash' | 'history'} opts.routerMode which router mode to use. Defaults to "hash" */ -async function navigateToRoute(page, route, { routerMode = 'hash' } = {}) { - if (routerMode === 'hash') { - await page.evaluate(r => (window.location.hash = r), route); - } else { +async function navigateToRoute(page, route) { + const routerMode = await page.evaluate(() => window.$docsify.routerMode); + + if (routerMode === 'history') { await page.evaluate(r => window.history.pushState({ key: r }, '', r)); + } else { + await page.evaluate(r => (window.location.hash = r), route); } await page.waitForLoadState('networkidle'); From 4277cc59ca9e5e2d757c457c01d2f064b61de846 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 21:05:41 +0300 Subject: [PATCH 18/22] moved endsWith from router utils to general utils; added startsWith util; refactored makeExactMatcher to use both --- src/core/router/history/hash.js | 3 ++- src/core/router/util.js | 4 ---- src/core/util/str.js | 7 +++++++ src/core/virtual-routes/exact-match.js | 17 +++++++++++------ 4 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 src/core/util/str.js diff --git a/src/core/router/history/hash.js b/src/core/router/history/hash.js index cf948683b..abb060f2f 100644 --- a/src/core/router/history/hash.js +++ b/src/core/router/history/hash.js @@ -1,6 +1,7 @@ import { noop } from '../../util/core'; import { on } from '../../util/dom'; -import { parseQuery, cleanPath, replaceSlug, endsWith } from '../util'; +import { endsWith } from '../../util/str'; +import { parseQuery, cleanPath, replaceSlug } from '../util'; import { History } from './base'; function replaceHash(path) { diff --git a/src/core/router/util.js b/src/core/router/util.js index 881e44ab4..b4a85d89e 100644 --- a/src/core/router/util.js +++ b/src/core/router/util.js @@ -113,7 +113,3 @@ export function getPath(...args) { export const replaceSlug = cached(path => { return path.replace('#', '?id='); }); - -export function endsWith(str, suffix) { - return str.indexOf(suffix, str.length - suffix.length) !== -1; -} diff --git a/src/core/util/str.js b/src/core/util/str.js new file mode 100644 index 000000000..f8b8ec4c6 --- /dev/null +++ b/src/core/util/str.js @@ -0,0 +1,7 @@ +export function startsWith(str, prefix) { + return str.indexOf(prefix) === 0; +} + +export function endsWith(str, suffix) { + return str.indexOf(suffix, str.length - suffix.length) !== -1; +} diff --git a/src/core/virtual-routes/exact-match.js b/src/core/virtual-routes/exact-match.js index 9dea4a22c..2304a7eb0 100644 --- a/src/core/virtual-routes/exact-match.js +++ b/src/core/virtual-routes/exact-match.js @@ -1,16 +1,21 @@ +import { startsWith, endsWith } from '../util/str'; + /** * Adds beginning of input (^) and end of input ($) assertions if needed into a regex string * @param {string} matcher the string to match * @returns {string} */ export function makeExactMatcher(matcher) { - const matcherWithBeginningOfInput = - matcher.slice(0, 1) === '^' ? matcher : `^${matcher}`; + const matcherWithBeginningOfInput = startsWith(matcher, '^') + ? matcher + : `^${matcher}`; - const matcherWithBeginningAndEndOfInput = - matcherWithBeginningOfInput.slice(-1) === '$' - ? matcherWithBeginningOfInput - : `${matcherWithBeginningOfInput}$`; + const matcherWithBeginningAndEndOfInput = endsWith( + matcherWithBeginningOfInput, + '$' + ) + ? matcherWithBeginningOfInput + : `${matcherWithBeginningOfInput}$`; return matcherWithBeginningAndEndOfInput; } From 4bd7ac8fd1577963c392b3f13e8015997240bd03 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 21:25:58 +0300 Subject: [PATCH 19/22] updated docs per feedback --- docs/configuration.md | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ba2ceae3a..8be113fff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,7 @@ The config can also be defined as a function, in which case the first argument i - Type: `Object` Set the route alias. You can freely manage routing rules. Supports RegExp. +Do note that order matters! If a route can be matched by multiple aliases, the one you declared first takes precedence. ```js window.$docsify = { @@ -48,8 +49,6 @@ window.$docsify = { }; ``` -Do note that order matters! If a route can be matched by multiple aliases, the one you declared first takes precedence. - ## auto2top - Type: `Boolean` @@ -693,6 +692,8 @@ A route function receives up to three parameters: 2. `matched` - the [`RegExpMatchArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match) that was matched by the route (e.g. for `/bar/(.+)`, you get `['/bar/baz', 'baz']`) 3. `next` - this is a callback that you may call when your route function is async +Do note that order matters! Routes are matched the same order you declare them in, which means that in cases where you have overlapping routes, you might want to list the more specific ones first. + ```js window.$docsify = { routes: { @@ -750,26 +751,6 @@ window.$docsify = { } ``` -Do note that order matters! Routes are matched the same order you declare them in, which means that in cases where you have overlapping routes, you might want to list the more specific ones first: - -```js -window.$docsify = { - routes: { - // if you look up /pets/cats, this route is always matched first - '/pets/cats': function(route, matched) { - return 'This is a special page for cats!'; - } - - // and this route will match every other pet, but never cats, since it is the second route to be declared - '/pets/(.+)': function(route, matched) { - const pet = matched[0]; - return `your pet is ${pet} (but not a cat, so it doesn't get its own route!)`; - } - } -} -``` - - Finally, if you have a specific path that has a real markdown file (and therefore should not be matched by your route), you can opt it out by returning an explicit `false` value: ```js From ebfc2351284ab63f1a2d75f15b62336e5a1d1f36 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Thu, 19 May 2022 23:47:17 +0300 Subject: [PATCH 20/22] moved navigateToRoute helper into the virtual-routes test file --- test/e2e/virtual-routes.test.js | 11 ++++++++++- test/helpers/navigate.js | 19 ------------------- 2 files changed, 10 insertions(+), 20 deletions(-) delete mode 100644 test/helpers/navigate.js diff --git a/test/e2e/virtual-routes.test.js b/test/e2e/virtual-routes.test.js index c518c6706..7b808ffa2 100644 --- a/test/e2e/virtual-routes.test.js +++ b/test/e2e/virtual-routes.test.js @@ -1,5 +1,4 @@ const docsifyInit = require('../helpers/docsify-init'); -const { navigateToRoute } = require('../helpers/navigate'); const { test, expect } = require('./fixtures/docsify-init-fixture'); test.describe('Virtual Routes - Generate Dynamic Content via Config', () => { @@ -282,3 +281,13 @@ test.describe('Virtual Routes - Generate Dynamic Content via Config', () => { }); }); }); + +/** + * Navigate to a specific route in the site + * @param {import('playwright-core').Page} page the playwright page instance from the test + * @param {string} route the route you want to navigate to + */ +async function navigateToRoute(page, route) { + await page.evaluate(r => (window.location.hash = r), route); + await page.waitForLoadState('networkidle'); +} diff --git a/test/helpers/navigate.js b/test/helpers/navigate.js deleted file mode 100644 index 8155f3ea9..000000000 --- a/test/helpers/navigate.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Navigate to a specific route in the site - * @param {import('playwright-core').Page} page the playwright page instance from the test - * @param {string} route the route you want to navigate to - */ - -async function navigateToRoute(page, route) { - const routerMode = await page.evaluate(() => window.$docsify.routerMode); - - if (routerMode === 'history') { - await page.evaluate(r => window.history.pushState({ key: r }, '', r)); - } else { - await page.evaluate(r => (window.location.hash = r), route); - } - - await page.waitForLoadState('networkidle'); -} - -module.exports.navigateToRoute = navigateToRoute; From bea730842506d47dffd7d8af13808572120baa49 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Fri, 20 May 2022 00:19:40 +0300 Subject: [PATCH 21/22] moved navigateToRoute to top of file --- test/e2e/virtual-routes.test.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/e2e/virtual-routes.test.js b/test/e2e/virtual-routes.test.js index 7b808ffa2..21636d159 100644 --- a/test/e2e/virtual-routes.test.js +++ b/test/e2e/virtual-routes.test.js @@ -1,6 +1,16 @@ const docsifyInit = require('../helpers/docsify-init'); const { test, expect } = require('./fixtures/docsify-init-fixture'); +/** + * Navigate to a specific route in the site + * @param {import('playwright-core').Page} page the playwright page instance from the test + * @param {string} route the route you want to navigate to + */ +async function navigateToRoute(page, route) { + await page.evaluate(r => (window.location.hash = r), route); + await page.waitForLoadState('networkidle'); +} + test.describe('Virtual Routes - Generate Dynamic Content via Config', () => { test.describe('Different Types of Virtual Routes', () => { test('rendering virtual routes specified as string', async ({ page }) => { @@ -281,13 +291,3 @@ test.describe('Virtual Routes - Generate Dynamic Content via Config', () => { }); }); }); - -/** - * Navigate to a specific route in the site - * @param {import('playwright-core').Page} page the playwright page instance from the test - * @param {string} route the route you want to navigate to - */ -async function navigateToRoute(page, route) { - await page.evaluate(r => (window.location.hash = r), route); - await page.waitForLoadState('networkidle'); -} From c748b33230ea2fcf116078737a637fa61b59f741 Mon Sep 17 00:00:00 2001 From: Roy Sommer Date: Fri, 20 May 2022 23:23:25 +0300 Subject: [PATCH 22/22] updated docs per pr comments --- docs/configuration.md | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8be113fff..3fdf2a61c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -707,13 +707,14 @@ window.$docsify = { // RegEx match w/ asynchronous function '/baz/(.*)': function(route, matched, next) { - try { - // Async task(s)... - } catch (err) { - // ... - } finally { - next('# Custom Markdown'); - } + // Requires `fetch` polyfill for legacy browsers (https://github.github.io/fetch/) + fetch('/api/users?id=12345') + .then(function(response) { + next('# Custom Markdown'); + }) + .catch(function(err) { + // Handle error... + }); } } } @@ -738,13 +739,8 @@ window.$docsify = { if (matched[0] === 'cats') { next(); } else { - try { - // Async task(s)... - } catch (err) { - // ... - } finally { - next('I like all pets but cats'); - } + // Async task(s)... + next('I like all pets but cats'); } } }