Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 14 additions & 7 deletions src/core/config.js
Original file line number Diff line number Diff line change
@@ -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<string, string>} */ ({}),
auto2top: false,
Expand Down Expand Up @@ -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)[]} */ (
Expand Down
4 changes: 4 additions & 0 deletions src/core/module.js
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
import { setDocsifyModuleUrl } from './script.js';

setDocsifyModuleUrl(import.meta.url);

export * from './Docsify.js';
24 changes: 24 additions & 0 deletions src/core/script.js
Original file line number Diff line number Diff line change
@@ -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;
}
101 changes: 101 additions & 0 deletions src/core/version-check.js
Original file line number Diff line number Diff line change
@@ -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).',
);
}
24 changes: 23 additions & 1 deletion test/e2e/example.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div id="app"></div>');
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('<div id="app"></div>');
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion test/helpers/docsify-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions test/unit/version-check.test.js
Original file line number Diff line number Diff line change
@@ -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),
);
});
});