From 7a0aa86a520b0b1b71916f23475023159f5535af Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Thu, 3 Sep 2026 11:09:11 +0800 Subject: [PATCH] feat(version-check): implement version checking for Docsify script URLs --- src/core/config.js | 21 ++++--- src/core/module.js | 4 ++ src/core/script.js | 24 ++++++++ src/core/version-check.js | 101 ++++++++++++++++++++++++++++++++ test/e2e/example.test.js | 24 +++++++- test/helpers/docsify-init.js | 2 +- test/unit/version-check.test.js | 94 +++++++++++++++++++++++++++++ 7 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 src/core/script.js create mode 100644 src/core/version-check.js create mode 100644 test/unit/version-check.test.js diff --git a/src/core/config.js b/src/core/config.js index 4df2e29f30..7372baa3a6 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -1,10 +1,13 @@ import { stripIndent } from 'common-tags'; +import { getDocsifyModuleUrl, getDocsifyScript } from './script.js'; import { hyphenate, isPrimitive } from './util/core.js'; +import { + isDocsifyScriptUrl, + warnIfUnpinnedDocsifyVersion, +} from './version-check.js'; /** @import { Docsify } from './Docsify.js' */ /** @import { Hooks } from './init/lifecycle.js' */ -const currentScript = document.currentScript; - const defaultDocsifyConfig = () => ({ alias: /** @type {Record} */ ({}), auto2top: false, @@ -158,11 +161,15 @@ export default function (vm, config = {}) { ); } - const script = - currentScript || - Array.from(document.getElementsByTagName('script')).filter(n => - /docsify\./.test(n.src), - )[0]; + const moduleUrl = getDocsifyModuleUrl(); + const script = getDocsifyScript(); + const scriptUrl = moduleUrl + ? isDocsifyScriptUrl(moduleUrl) + ? moduleUrl + : undefined + : script?.src; + + warnIfUnpinnedDocsifyVersion(scriptUrl); if (script) { for (const prop of /** @type {(keyof DocsifyConfig)[]} */ ( diff --git a/src/core/module.js b/src/core/module.js index 4206ebc246..84fd5377c4 100644 --- a/src/core/module.js +++ b/src/core/module.js @@ -1 +1,5 @@ +import { setDocsifyModuleUrl } from './script.js'; + +setDocsifyModuleUrl(import.meta.url); + export * from './Docsify.js'; diff --git a/src/core/script.js b/src/core/script.js new file mode 100644 index 0000000000..e597afa9f7 --- /dev/null +++ b/src/core/script.js @@ -0,0 +1,24 @@ +const currentScript = /** @type {HTMLScriptElement | null} */ ( + document.currentScript +); + +/** @type {string | undefined} */ +let moduleUrl; + +export function getDocsifyScript() { + return ( + currentScript || + Array.from(document.getElementsByTagName('script')).find(script => + /docsify\./.test(script.src), + ) + ); +} + +export function getDocsifyModuleUrl() { + return moduleUrl; +} + +/** @param {string} url */ +export function setDocsifyModuleUrl(url) { + moduleUrl = url; +} diff --git a/src/core/version-check.js b/src/core/version-check.js new file mode 100644 index 0000000000..bd6404f3b4 --- /dev/null +++ b/src/core/version-check.js @@ -0,0 +1,101 @@ +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-]+)*)?`; +const VERSION_VALUE = new RegExp(`^${VERSION}$`); +const VERSION_PATHS = [ + new RegExp(`(?:^|/)docsify@${VERSION}(?:/|$)`, 'i'), + new RegExp(`(?:^|/)docsify/${VERSION}(?:/|$)`, 'i'), + new RegExp( + `(?:^|/)docsify(?:\\.module)?[-.]${VERSION}(?:\\.min)?\\.js$`, + 'i', + ), + new RegExp( + `(?:^|/)docsify[-.]${VERSION}(?:\\.module)?(?:\\.min)?\\.js$`, + 'i', + ), +]; + +let hasWarned = false; + +/** @param {string} value */ +function parseUrl(value) { + try { + return new URL(value, 'https://docsify.js.org'); + } catch { + return null; + } +} + +/** @param {string} pathname */ +function decodePathname(pathname) { + try { + return decodeURIComponent(pathname); + } catch { + return pathname; + } +} + +/** @param {string} scriptUrl */ +export function hasPinnedDocsifyVersion(scriptUrl) { + const url = parseUrl(scriptUrl); + + if (!url) { + return false; + } + + if ( + ['v', 'version'].some(param => + url.searchParams.getAll(param).some(value => VERSION_VALUE.test(value)), + ) + ) { + return true; + } + + const pathname = decodePathname(url.pathname); + + return VERSION_PATHS.some(pattern => pattern.test(pathname)); +} + +/** @param {string} scriptUrl */ +export function isDocsifyScriptUrl(scriptUrl) { + const url = parseUrl(scriptUrl); + + if (!url) { + return false; + } + + const pathname = decodePathname(url.pathname); + const filename = pathname.split('/').pop() || ''; + + if (/^docsify(?:[.@_-].*)?\.js$/i.test(filename)) { + return true; + } + + switch (url.hostname) { + case 'cdn.jsdelivr.net': + return /^\/(?:npm\/docsify|gh\/docsifyjs\/docsify)(?:@|\/)/i.test( + pathname, + ); + case 'unpkg.com': + return /^\/docsify(?:@|\/)/i.test(pathname); + case 'cdn.bootcdn.net': + case 'cdnjs.cloudflare.com': + return /^\/ajax\/libs\/docsify\//i.test(pathname); + default: + return false; + } +} + +/** @param {string | undefined} scriptUrl */ +export function warnIfUnpinnedDocsifyVersion(scriptUrl) { + if (!scriptUrl || hasWarned || hasPinnedDocsifyVersion(scriptUrl)) { + return; + } + + hasWarned = true; + + // eslint-disable-next-line no-console + console.error( + `[Docsify] Unpinned version detected in the Docsify script URL: ${scriptUrl}\n` + + 'This site WILL BREAK when that URL begins serving a future major version and may break unexpectedly on minor or patch updates. ' + + 'Pin Docsify to a version in the URL (for example, docsify@5.0.0).', + ); +} diff --git a/test/e2e/example.test.js b/test/e2e/example.test.js index bf69139c02..e3b8386e85 100644 --- a/test/e2e/example.test.js +++ b/test/e2e/example.test.js @@ -2,6 +2,28 @@ import docsifyInit from '../helpers/docsify-init.js'; import { test, expect } from './fixtures/docsify-init-fixture.js'; test.describe('Creating a Docsify site (e2e tests in Playwright)', () => { + test('warns once when the Docsify script URL is not versioned', async ({ + page, + }) => { + const errors = []; + + page.on('console', message => { + if ( + message.type() === 'error' && + message.text().includes('[Docsify] Unpinned version') + ) { + errors.push(message.text()); + } + }); + + await page.setContent('
'); + await page.addScriptTag({ url: '/dist/docsify.js' }); + await page.locator('#main').waitFor(); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('This site WILL BREAK'); + }); + test('manual docsify site using playwright methods', async ({ page }) => { // Add docsify target element await page.setContent('
'); @@ -18,7 +40,7 @@ test.describe('Creating a Docsify site (e2e tests in Playwright)', () => { await page.addStyleTag({ url: '/dist/themes/core.css' }); // Inject docsify.js - await page.addScriptTag({ url: '/dist/docsify.js' }); + await page.addScriptTag({ url: '/dist/docsify.js?v=5.0.0' }); // Wait for docsify to initialize await page.locator('#main').waitFor(); diff --git a/test/helpers/docsify-init.js b/test/helpers/docsify-init.js index 2c7f23933d..c76f183524 100644 --- a/test/helpers/docsify-init.js +++ b/test/helpers/docsify-init.js @@ -8,7 +8,7 @@ import { waitForSelector } from './wait-for.js'; const mock = _mock.default; const docsifyPATH = '../../dist/docsify.js'; // JSDOM -const docsifyURL = '/dist/docsify.js'; // Playwright +const docsifyURL = '/dist/docsify.js?v=5.0.0'; // Playwright /** * Jest / Playwright helper for creating custom docsify test sites diff --git a/test/unit/version-check.test.js b/test/unit/version-check.test.js new file mode 100644 index 0000000000..6826c82c13 --- /dev/null +++ b/test/unit/version-check.test.js @@ -0,0 +1,94 @@ +import { jest } from '@jest/globals'; +import { + hasPinnedDocsifyVersion, + isDocsifyScriptUrl, + warnIfUnpinnedDocsifyVersion, +} from '../../src/core/version-check.js'; + +describe('Docsify script version check', () => { + test.each([ + 'https://cdn.jsdelivr.net/npm/docsify@5.0.0/dist/docsify.js', + 'https://unpkg.com/docsify@5.0.0/dist/docsify.js', + 'https://cdn.bootcdn.net/ajax/libs/docsify/5.0.0/docsify.js', + 'https://cdnjs.cloudflare.com/ajax/libs/docsify/5.0.0/docsify.js', + 'https://cdn.jsdelivr.net/npm/docsify@5/dist/docsify.module.js', + 'https://unpkg.com/docsify@5.0/dist/docsify.module.min.js', + 'https://unpkg.com/docsify@5.0.0-rc.1/dist/docsify.module.js', + '/assets/docsify-5.0.0.js', + '/assets/docsify.5.0.0.min.js', + '/assets/docsify-5.0.0.module.min.js', + '/docsify/5.0.0/docsify.js', + '/assets/docsify.js?v=5', + '/assets/docsify.js?version=5.0.0', + ])('recognizes a pinned version in %s', scriptUrl => { + expect(hasPinnedDocsifyVersion(scriptUrl)).toBe(true); + }); + + test.each([ + 'https://cdn.jsdelivr.net/npm/docsify/dist/docsify.js', + 'https://unpkg.com/docsify@latest/dist/docsify.js', + 'https://cdn.bootcdn.net/ajax/libs/docsify/latest/docsify.js', + 'https://cdnjs.cloudflare.com/ajax/libs/docsify/2026/docsify.js', + '/assets/docsify.js', + '/2026/assets/docsify.js', + '/assets/docsify-a1b2c3.js', + '/assets/docsify.js?cache=5.0.0', + '/assets/docsify.js?v=20260903', + '/assets/docsify.js#version=5.0.0', + 'not a valid URL%', + ])( + 'does not mistake an unpinned URL for a pinned version in %s', + scriptUrl => { + expect(hasPinnedDocsifyVersion(scriptUrl)).toBe(false); + }, + ); + + test.each([ + 'https://cdn.jsdelivr.net/npm/docsify/dist/docsify.module.js', + 'https://unpkg.com/docsify/dist/module.js', + 'https://cdn.bootcdn.net/ajax/libs/docsify/5.0.0/module.js', + 'https://cdnjs.cloudflare.com/ajax/libs/docsify/5.0.0/module.js', + '/assets/docsify.module.js', + ])('recognizes a Docsify ESM distribution URL in %s', scriptUrl => { + expect(isDocsifyScriptUrl(scriptUrl)).toBe(true); + }); + + test.each([ + '/assets/app.js', + '/assets/vendor.js?v=5.0.0', + 'file:///project/docsify/src/core/module.js', + ])('ignores a non-Docsify application bundle URL in %s', scriptUrl => { + expect(isDocsifyScriptUrl(scriptUrl)).toBe(false); + }); + + test('does not warn without a URL or for a pinned URL', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + warnIfUnpinnedDocsifyVersion(); + warnIfUnpinnedDocsifyVersion( + 'https://cdn.jsdelivr.net/npm/docsify@5.0.0/dist/docsify.js', + ); + + expect(consoleError).not.toHaveBeenCalled(); + }); + + test('emits one forceful error for an unpinned URL', () => { + const scriptUrl = 'https://cdn.jsdelivr.net/npm/docsify/dist/docsify.js'; + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + warnIfUnpinnedDocsifyVersion(scriptUrl); + warnIfUnpinnedDocsifyVersion(scriptUrl); + + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('This site WILL BREAK'), + ); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining(scriptUrl), + ); + }); +});