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/docs/configuration.md b/docs/configuration.md index 8365a93d6..3fdf2a61c 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 = { @@ -680,6 +681,91 @@ 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/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 + +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: { + // Basic match w/ return string + '/foo': '# Custom Markdown', + + // RegEx match w/ synchronous function + '/bar/(.*)': function(route, matched) { + return '# Custom Markdown'; + }, + + // RegEx match w/ asynchronous function + '/baz/(.*)': function(route, matched, next) { + // 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... + }); + } + } +} +``` + +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 (synchronous) + '/pets/(.+)': function(route, matched) { + if (matched[0] === 'dogs') { + return null; + } else { + return 'I like all pets but dogs'; + } + } + + // accepts everything other than cats (asynchronous) + '/pets/(.*)': function(route, matched, next) { + if (matched[0] === 'cats') { + next(); + } else { + // Async task(s)... + 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; + } + + // 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 cat)`; + } + } +} +``` + ## subMaxLevel - Type: `Number` 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..cb363bdb7 100644 --- a/src/core/fetch/index.js +++ b/src/core/fetch/index.js @@ -96,25 +96,44 @@ 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 - req.then( - (text, opt) => - this._renderMain( - text, - opt, - this._loadSideAndNav(path, qs, loadSidebar, cb) - ), - _ => { - this._fetchFallbackPage(path, qs, cb) || - this._fetch404(file, qs, cb); - } - ); + // 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 { + request(file + qs, true, requestHeaders).then( + contentFetched, + contentFailedToFetch + ); + } + }); + } 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/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 new file mode 100644 index 000000000..2304a7eb0 --- /dev/null +++ b/src/core/virtual-routes/exact-match.js @@ -0,0 +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 = startsWith(matcher, '^') + ? matcher + : `^${matcher}`; + + const matcherWithBeginningAndEndOfInput = endsWith( + matcherWithBeginningOfInput, + '$' + ) + ? matcherWithBeginningOfInput + : `${matcherWithBeginningOfInput}$`; + + return matcherWithBeginningAndEndOfInput; +} diff --git a/src/core/virtual-routes/index.js b/src/core/virtual-routes/index.js new file mode 100644 index 000000000..bc4eaa647 --- /dev/null +++ b/src/core/virtual-routes/index.js @@ -0,0 +1,93 @@ +import { makeExactMatcher } from './exact-match'; +import { createNextFunction } from './next'; + +/** @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 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 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 done(null); + } + + const matcher = makeExactMatcher(virtualRoutePath); + const matched = path.match(matcher); + + if (!matched) { + return asyncMatchNextRoute(); + } + + const virtualRouteContentOrFn = virtualRoutes[virtualRoutePath]; + + if (typeof virtualRouteContentOrFn === 'string') { + 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 { + then: function (cb) { + done = cb; + asyncMatchNextRoute(); + }, + }; + } + }; +} diff --git a/src/core/virtual-routes/next.js b/src/core/virtual-routes/next.js new file mode 100644 index 000000000..1b14904a1 --- /dev/null +++ b/src/core/virtual-routes/next.js @@ -0,0 +1,21 @@ +/** @typedef {((value: any) => void) => void} OnNext */ +/** @typedef {(value: any) => void} 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 storedCb = () => null; + + function next(value) { + storedCb(value); + } + + function onNext(cb) { + storedCb = cb; + } + + return [next, onNext]; +} diff --git a/test/e2e/virtual-routes.test.js b/test/e2e/virtual-routes.test.js new file mode 100644 index 000000000..21636d159 --- /dev/null +++ b/test/e2e/virtual-routes.test.js @@ -0,0 +1,293 @@ +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 }) => { + 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('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'); + }); + + 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, + }, + }); + + 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, + }, + }); + + await navigateToRoute(page, '/items/banana'); + + const titleElm = page.locator('#main h1'); + await expect(titleElm).toContainText('Item Page'); + }); + + 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'); + }); + + 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)'); + }); + }); + + 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'); + }); + }); +});