Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Virtual Routes Support #1799

Merged
merged 22 commits into from
May 23, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ba998e8
add first test
illBeRoy May 14, 2022
7c92837
new VirtualRoutes mixin that handles routes. fetch tries to use Virtu…
illBeRoy May 14, 2022
7a2d0d0
cover all basic use cases
illBeRoy May 14, 2022
3f359ed
regex matching in routes
illBeRoy May 14, 2022
53c507f
covered all virtual routes tests
illBeRoy May 14, 2022
9d816bc
added hack to fix config test on firefox
illBeRoy May 15, 2022
fb084f8
removed formatting regex matches into string routes
illBeRoy May 17, 2022
1866fa2
added support for "next" function
illBeRoy May 17, 2022
8bf6890
added docs
illBeRoy May 17, 2022
a022b3d
navigate now supports both hash and history routerModes
illBeRoy May 18, 2022
1447898
waiting for networkidle in navigateToRoute helper
illBeRoy May 18, 2022
e8a12c0
promiseless implementation
illBeRoy May 18, 2022
e81429d
remove firefox workaround from catchPluginErrors test, since we no lo…
illBeRoy May 18, 2022
f0be4ca
updated docs
illBeRoy May 18, 2022
dbf45d4
updated docs for "alias" as well
illBeRoy May 18, 2022
dfbf77f
minor rephrasing
illBeRoy May 18, 2022
a245a2b
removed non-legacy code from exact-match; updated navigateToRoute hel…
illBeRoy May 19, 2022
4277cc5
moved endsWith from router utils to general utils; added startsWith u…
illBeRoy May 19, 2022
4bd7ac8
updated docs per feedback
illBeRoy May 19, 2022
ebfc235
moved navigateToRoute helper into the virtual-routes test file
illBeRoy May 19, 2022
bea7308
moved navigateToRoute to top of file
illBeRoy May 19, 2022
c748b33
updated docs per pr comments
illBeRoy May 20, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
Expand Down
82 changes: 82 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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']`)
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
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]}`);
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
return '# Custom Markdown';
},

// RegEx match w/ asynchronous function
'/baz/(.*)': function(route, matched, next) {
console.log(`Route match: ${matched[0]}`);
next('# Custom Markdown');
}
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
}
}
```

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
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
'/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');
}
}
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
}
}
```

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:
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved

```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);
}

jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
// 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`
Expand Down
6 changes: 5 additions & 1 deletion src/core/Docsify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand Down
1 change: 1 addition & 0 deletions src/core/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default function (vm) {
notFoundPage: true,
relativePath: false,
repo: '',
routes: {},
routerMode: 'hash',
subMaxLevel: 0,
themeColor: '',
Expand Down
21 changes: 20 additions & 1 deletion src/core/fetch/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
.then(() => {
if (!this.isRemoteUrl) {
return this.matchVirtualRoute(path);
} 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(
Expand Down
17 changes: 17 additions & 0 deletions src/core/virtual-routes/exact-match.js
Original file line number Diff line number Diff line change
@@ -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('^')
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
? matcher
: `^${matcher}`;

const matcherWithBeginningAndEndOfInput =
matcherWithBeginningOfInput.endsWith('$')
jhildenbiddle marked this conversation as resolved.
Show resolved Hide resolved
? matcherWithBeginningOfInput
: `${matcherWithBeginningOfInput}$`;

return matcherWithBeginningAndEndOfInput;
}
83 changes: 83 additions & 0 deletions src/core/virtual-routes/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { makeExactMatcher } from './exact-match';
import { createNextFunction } from './next';

/** @typedef {import('../Docsify').Constructor} Constructor */

/** @typedef {Record<string, string | VirtualRouteHandler>} VirtualRoutesMap */
/** @typedef {(route: string, match: RegExpMatchArray | null) => string | void | Promise<string | void> } 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<string | null>} resolves to string if route was matched, otherwise null
*/
matchVirtualRoute(path) {
const virtualRoutes = this.routes();

const virtualRoutePaths = Object.keys(virtualRoutes);

/**
* 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<string | null>}
*/
function asyncMatchNextRoute() {
const virtualRoutePath = virtualRoutePaths.shift();
if (!virtualRoutePath) {
return Promise.resolve(null);
}

const matcher = makeExactMatcher(virtualRoutePath);
const matched = path.match(matcher);

if (!matched) {
return Promise.resolve().then(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);
}
}

return asyncMatchNextRoute();
}
};
}
17 changes: 17 additions & 0 deletions src/core/virtual-routes/next.js
Original file line number Diff line number Diff line change
@@ -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];
}
16 changes: 15 additions & 1 deletion test/e2e/configuration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading