Skip to content

Commit 7a0aa86

Browse files
committed
feat(version-check): implement version checking for Docsify script URLs
1 parent e5ccdfb commit 7a0aa86

7 files changed

Lines changed: 261 additions & 9 deletions

File tree

src/core/config.js

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { stripIndent } from 'common-tags';
2+
import { getDocsifyModuleUrl, getDocsifyScript } from './script.js';
23
import { hyphenate, isPrimitive } from './util/core.js';
4+
import {
5+
isDocsifyScriptUrl,
6+
warnIfUnpinnedDocsifyVersion,
7+
} from './version-check.js';
38
/** @import { Docsify } from './Docsify.js' */
49
/** @import { Hooks } from './init/lifecycle.js' */
510

6-
const currentScript = document.currentScript;
7-
811
const defaultDocsifyConfig = () => ({
912
alias: /** @type {Record<string, string>} */ ({}),
1013
auto2top: false,
@@ -158,11 +161,15 @@ export default function (vm, config = {}) {
158161
);
159162
}
160163

161-
const script =
162-
currentScript ||
163-
Array.from(document.getElementsByTagName('script')).filter(n =>
164-
/docsify\./.test(n.src),
165-
)[0];
164+
const moduleUrl = getDocsifyModuleUrl();
165+
const script = getDocsifyScript();
166+
const scriptUrl = moduleUrl
167+
? isDocsifyScriptUrl(moduleUrl)
168+
? moduleUrl
169+
: undefined
170+
: script?.src;
171+
172+
warnIfUnpinnedDocsifyVersion(scriptUrl);
166173

167174
if (script) {
168175
for (const prop of /** @type {(keyof DocsifyConfig)[]} */ (

src/core/module.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1+
import { setDocsifyModuleUrl } from './script.js';
2+
3+
setDocsifyModuleUrl(import.meta.url);
4+
15
export * from './Docsify.js';

src/core/script.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const currentScript = /** @type {HTMLScriptElement | null} */ (
2+
document.currentScript
3+
);
4+
5+
/** @type {string | undefined} */
6+
let moduleUrl;
7+
8+
export function getDocsifyScript() {
9+
return (
10+
currentScript ||
11+
Array.from(document.getElementsByTagName('script')).find(script =>
12+
/docsify\./.test(script.src),
13+
)
14+
);
15+
}
16+
17+
export function getDocsifyModuleUrl() {
18+
return moduleUrl;
19+
}
20+
21+
/** @param {string} url */
22+
export function setDocsifyModuleUrl(url) {
23+
moduleUrl = url;
24+
}

src/core/version-check.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
const VERSION = String.raw`v?(?:0|[1-9]\d?)(?:\.(?:0|[1-9]\d*)){0,2}(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`;
2+
const VERSION_VALUE = new RegExp(`^${VERSION}$`);
3+
const VERSION_PATHS = [
4+
new RegExp(`(?:^|/)docsify@${VERSION}(?:/|$)`, 'i'),
5+
new RegExp(`(?:^|/)docsify/${VERSION}(?:/|$)`, 'i'),
6+
new RegExp(
7+
`(?:^|/)docsify(?:\\.module)?[-.]${VERSION}(?:\\.min)?\\.js$`,
8+
'i',
9+
),
10+
new RegExp(
11+
`(?:^|/)docsify[-.]${VERSION}(?:\\.module)?(?:\\.min)?\\.js$`,
12+
'i',
13+
),
14+
];
15+
16+
let hasWarned = false;
17+
18+
/** @param {string} value */
19+
function parseUrl(value) {
20+
try {
21+
return new URL(value, 'https://docsify.js.org');
22+
} catch {
23+
return null;
24+
}
25+
}
26+
27+
/** @param {string} pathname */
28+
function decodePathname(pathname) {
29+
try {
30+
return decodeURIComponent(pathname);
31+
} catch {
32+
return pathname;
33+
}
34+
}
35+
36+
/** @param {string} scriptUrl */
37+
export function hasPinnedDocsifyVersion(scriptUrl) {
38+
const url = parseUrl(scriptUrl);
39+
40+
if (!url) {
41+
return false;
42+
}
43+
44+
if (
45+
['v', 'version'].some(param =>
46+
url.searchParams.getAll(param).some(value => VERSION_VALUE.test(value)),
47+
)
48+
) {
49+
return true;
50+
}
51+
52+
const pathname = decodePathname(url.pathname);
53+
54+
return VERSION_PATHS.some(pattern => pattern.test(pathname));
55+
}
56+
57+
/** @param {string} scriptUrl */
58+
export function isDocsifyScriptUrl(scriptUrl) {
59+
const url = parseUrl(scriptUrl);
60+
61+
if (!url) {
62+
return false;
63+
}
64+
65+
const pathname = decodePathname(url.pathname);
66+
const filename = pathname.split('/').pop() || '';
67+
68+
if (/^docsify(?:[.@_-].*)?\.js$/i.test(filename)) {
69+
return true;
70+
}
71+
72+
switch (url.hostname) {
73+
case 'cdn.jsdelivr.net':
74+
return /^\/(?:npm\/docsify|gh\/docsifyjs\/docsify)(?:@|\/)/i.test(
75+
pathname,
76+
);
77+
case 'unpkg.com':
78+
return /^\/docsify(?:@|\/)/i.test(pathname);
79+
case 'cdn.bootcdn.net':
80+
case 'cdnjs.cloudflare.com':
81+
return /^\/ajax\/libs\/docsify\//i.test(pathname);
82+
default:
83+
return false;
84+
}
85+
}
86+
87+
/** @param {string | undefined} scriptUrl */
88+
export function warnIfUnpinnedDocsifyVersion(scriptUrl) {
89+
if (!scriptUrl || hasWarned || hasPinnedDocsifyVersion(scriptUrl)) {
90+
return;
91+
}
92+
93+
hasWarned = true;
94+
95+
// eslint-disable-next-line no-console
96+
console.error(
97+
`[Docsify] Unpinned version detected in the Docsify script URL: ${scriptUrl}\n` +
98+
'This site WILL BREAK when that URL begins serving a future major version and may break unexpectedly on minor or patch updates. ' +
99+
'Pin Docsify to a version in the URL (for example, docsify@5.0.0).',
100+
);
101+
}

test/e2e/example.test.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@ import docsifyInit from '../helpers/docsify-init.js';
22
import { test, expect } from './fixtures/docsify-init-fixture.js';
33

44
test.describe('Creating a Docsify site (e2e tests in Playwright)', () => {
5+
test('warns once when the Docsify script URL is not versioned', async ({
6+
page,
7+
}) => {
8+
const errors = [];
9+
10+
page.on('console', message => {
11+
if (
12+
message.type() === 'error' &&
13+
message.text().includes('[Docsify] Unpinned version')
14+
) {
15+
errors.push(message.text());
16+
}
17+
});
18+
19+
await page.setContent('<div id="app"></div>');
20+
await page.addScriptTag({ url: '/dist/docsify.js' });
21+
await page.locator('#main').waitFor();
22+
23+
expect(errors).toHaveLength(1);
24+
expect(errors[0]).toContain('This site WILL BREAK');
25+
});
26+
527
test('manual docsify site using playwright methods', async ({ page }) => {
628
// Add docsify target element
729
await page.setContent('<div id="app"></div>');
@@ -18,7 +40,7 @@ test.describe('Creating a Docsify site (e2e tests in Playwright)', () => {
1840
await page.addStyleTag({ url: '/dist/themes/core.css' });
1941

2042
// Inject docsify.js
21-
await page.addScriptTag({ url: '/dist/docsify.js' });
43+
await page.addScriptTag({ url: '/dist/docsify.js?v=5.0.0' });
2244

2345
// Wait for docsify to initialize
2446
await page.locator('#main').waitFor();

test/helpers/docsify-init.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { waitForSelector } from './wait-for.js';
88

99
const mock = _mock.default;
1010
const docsifyPATH = '../../dist/docsify.js'; // JSDOM
11-
const docsifyURL = '/dist/docsify.js'; // Playwright
11+
const docsifyURL = '/dist/docsify.js?v=5.0.0'; // Playwright
1212

1313
/**
1414
* Jest / Playwright helper for creating custom docsify test sites

test/unit/version-check.test.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { jest } from '@jest/globals';
2+
import {
3+
hasPinnedDocsifyVersion,
4+
isDocsifyScriptUrl,
5+
warnIfUnpinnedDocsifyVersion,
6+
} from '../../src/core/version-check.js';
7+
8+
describe('Docsify script version check', () => {
9+
test.each([
10+
'https://cdn.jsdelivr.net/npm/docsify@5.0.0/dist/docsify.js',
11+
'https://unpkg.com/docsify@5.0.0/dist/docsify.js',
12+
'https://cdn.bootcdn.net/ajax/libs/docsify/5.0.0/docsify.js',
13+
'https://cdnjs.cloudflare.com/ajax/libs/docsify/5.0.0/docsify.js',
14+
'https://cdn.jsdelivr.net/npm/docsify@5/dist/docsify.module.js',
15+
'https://unpkg.com/docsify@5.0/dist/docsify.module.min.js',
16+
'https://unpkg.com/docsify@5.0.0-rc.1/dist/docsify.module.js',
17+
'/assets/docsify-5.0.0.js',
18+
'/assets/docsify.5.0.0.min.js',
19+
'/assets/docsify-5.0.0.module.min.js',
20+
'/docsify/5.0.0/docsify.js',
21+
'/assets/docsify.js?v=5',
22+
'/assets/docsify.js?version=5.0.0',
23+
])('recognizes a pinned version in %s', scriptUrl => {
24+
expect(hasPinnedDocsifyVersion(scriptUrl)).toBe(true);
25+
});
26+
27+
test.each([
28+
'https://cdn.jsdelivr.net/npm/docsify/dist/docsify.js',
29+
'https://unpkg.com/docsify@latest/dist/docsify.js',
30+
'https://cdn.bootcdn.net/ajax/libs/docsify/latest/docsify.js',
31+
'https://cdnjs.cloudflare.com/ajax/libs/docsify/2026/docsify.js',
32+
'/assets/docsify.js',
33+
'/2026/assets/docsify.js',
34+
'/assets/docsify-a1b2c3.js',
35+
'/assets/docsify.js?cache=5.0.0',
36+
'/assets/docsify.js?v=20260903',
37+
'/assets/docsify.js#version=5.0.0',
38+
'not a valid URL%',
39+
])(
40+
'does not mistake an unpinned URL for a pinned version in %s',
41+
scriptUrl => {
42+
expect(hasPinnedDocsifyVersion(scriptUrl)).toBe(false);
43+
},
44+
);
45+
46+
test.each([
47+
'https://cdn.jsdelivr.net/npm/docsify/dist/docsify.module.js',
48+
'https://unpkg.com/docsify/dist/module.js',
49+
'https://cdn.bootcdn.net/ajax/libs/docsify/5.0.0/module.js',
50+
'https://cdnjs.cloudflare.com/ajax/libs/docsify/5.0.0/module.js',
51+
'/assets/docsify.module.js',
52+
])('recognizes a Docsify ESM distribution URL in %s', scriptUrl => {
53+
expect(isDocsifyScriptUrl(scriptUrl)).toBe(true);
54+
});
55+
56+
test.each([
57+
'/assets/app.js',
58+
'/assets/vendor.js?v=5.0.0',
59+
'file:///project/docsify/src/core/module.js',
60+
])('ignores a non-Docsify application bundle URL in %s', scriptUrl => {
61+
expect(isDocsifyScriptUrl(scriptUrl)).toBe(false);
62+
});
63+
64+
test('does not warn without a URL or for a pinned URL', () => {
65+
const consoleError = jest
66+
.spyOn(console, 'error')
67+
.mockImplementation(() => {});
68+
69+
warnIfUnpinnedDocsifyVersion();
70+
warnIfUnpinnedDocsifyVersion(
71+
'https://cdn.jsdelivr.net/npm/docsify@5.0.0/dist/docsify.js',
72+
);
73+
74+
expect(consoleError).not.toHaveBeenCalled();
75+
});
76+
77+
test('emits one forceful error for an unpinned URL', () => {
78+
const scriptUrl = 'https://cdn.jsdelivr.net/npm/docsify/dist/docsify.js';
79+
const consoleError = jest
80+
.spyOn(console, 'error')
81+
.mockImplementation(() => {});
82+
83+
warnIfUnpinnedDocsifyVersion(scriptUrl);
84+
warnIfUnpinnedDocsifyVersion(scriptUrl);
85+
86+
expect(consoleError).toHaveBeenCalledTimes(1);
87+
expect(consoleError).toHaveBeenCalledWith(
88+
expect.stringContaining('This site WILL BREAK'),
89+
);
90+
expect(consoleError).toHaveBeenCalledWith(
91+
expect.stringContaining(scriptUrl),
92+
);
93+
});
94+
});

0 commit comments

Comments
 (0)