diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1f6971a3..39df2b0d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -8,7 +8,7 @@ export default defineConfig({ description: 'Vite & Vue powered static site generator.', lastUpdated: true, - cleanUrls: true, + cleanUrls: 'with-trailing-slash', themeConfig: { nav: nav(), diff --git a/docs/config/app-configs.md b/docs/config/app-configs.md index 5135023d..fcbe1863 100644 --- a/docs/config/app-configs.md +++ b/docs/config/app-configs.md @@ -175,18 +175,26 @@ export default { ## cleanUrls -- Type: `boolean` -- Default: `false` +- Type: `"off" | "with-trailing-slash" | "without-trailing-slash"` +- Default: `"off"` -When set to `true`, page `foo/bar.md` is generated into `foo/bar/index.html` instead of `foo/bar.html`. This gives URL location look like `foo/bar` instead of `foo/bar.html`. +When set to `"off"`, page `foo/bar.md` is generated into `foo/bar.html`. -Also work in MPA mode. +When set to `"with-trailing-slash"` or `"without-trailing-slash"`, page `foo/bar.md` is generated into `foo/bar/index.html`. -Note: `404.md` page is kept transforming to `404.html` for hosting services. +When set to `"with-trailing-slash"`, URLs will be `foo/bar/`. +When set to `"without-trailing-slash"`, URLs will be `foo/bar`. + +Notes: +- `404.md` page is kept transforming to `404.html` for hosting services. +- Also work in MPA mode. ```ts export default { - cleanUrls: true + cleanUrls: "without-trailing-slash" } ``` +### Hosting on Netlify + +Always use `"off"` or `"with-trailing-slash"` when hosted on Netlify. diff --git a/src/client/app/router.ts b/src/client/app/router.ts index f6168978..88d5f722 100644 --- a/src/client/app/router.ts +++ b/src/client/app/router.ts @@ -42,11 +42,23 @@ export function createRouter( function go(href: string = inBrowser ? location.href : '/') { // ensure correct deep link so page refresh lands on correct files. const url = new URL(href, fakeHost) - // ensure correct deep link so page refresh lands on correct files. - if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) { - url.pathname += siteDataRef.value.cleanUrls ? '' : '.html' + if (siteDataRef.value.cleanUrls === 'off') { + // No clean URLs + // Let's add ".html" if missing + if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) { + url.pathname += '.html' + href = url.pathname + url.search + url.hash + } + } else if ( + siteDataRef.value.cleanUrls === 'with-trailing-slash' && + !url.pathname.endsWith('/') + ) { + // Clean URLs with trailing slash + // Let's add missing slashes + url.pathname += '/' href = url.pathname + url.search + url.hash } + if (inBrowser) { // save scroll position before changing url history.replaceState({ scrollPosition: window.scrollY }, document.title) diff --git a/src/client/theme-default/support/utils.ts b/src/client/theme-default/support/utils.ts index 07ba1ebe..1521abee 100644 --- a/src/client/theme-default/support/utils.ts +++ b/src/client/theme-default/support/utils.ts @@ -1,5 +1,6 @@ import { ref } from 'vue' import { withBase } from 'vitepress' +import { cleanUrlsOptions } from '../../../../types/shared' export const HASH_RE = /#.*$/ export const EXT_RE = /(index)?\.(md|html)$/ @@ -69,7 +70,10 @@ export function normalize(path: string): string { return decodeURI(path).replace(HASH_RE, '').replace(EXT_RE, '') } -export function normalizeLink(url: string, cleanUrls: boolean = false): string { +export function normalizeLink( + url: string, + cleanUrls: cleanUrlsOptions = 'off' +): string { if (isExternal(url)) { return url } @@ -81,7 +85,11 @@ export function normalizeLink(url: string, cleanUrls: boolean = false): string { ? url : `${pathname.replace( /(\.md)?$/, - cleanUrls ? '' : '.html' + cleanUrls === 'off' + ? '.html' + : cleanUrls === 'with-trailing-slash' + ? '/' + : '' )}${search}${hash}` return withBase(normalizedPath) diff --git a/src/node/build/render.ts b/src/node/build/render.ts index 56b9368a..113a86e3 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -7,6 +7,7 @@ import { RollupOutput, OutputChunk, OutputAsset } from 'rollup' import { HeadConfig, PageData, createTitle, notFoundPageData } from '../shared' import { slash } from '../utils/slash' import { SiteConfig, resolveSiteDataByRoute } from '../config' +import { cleanUrlsOptions } from '../../../types/shared' export async function renderPage( config: SiteConfig, @@ -158,12 +159,18 @@ export async function renderPage( await fs.writeFile(htmlFileName, html) } -function transformHTMLFileName(page: string, shouldCleanUrls: boolean): string { +function transformHTMLFileName( + page: string, + shouldCleanUrls: cleanUrlsOptions +): string { if (page === 'index.md' || page.endsWith('/index.md') || page === '404.md') { return page.replace(/\.md$/, '.html') } - return page.replace(/\.md$/, shouldCleanUrls ? '/index.html' : '.html') + return page.replace( + /\.md$/, + shouldCleanUrls !== 'off' ? '/index.html' : '.html' + ) } function resolvePageImports( diff --git a/src/node/config.ts b/src/node/config.ts index 93c4f275..cc936e6e 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -21,6 +21,7 @@ import { import { resolveAliases, DEFAULT_THEME_PATH } from './alias' import { MarkdownOptions } from './markdown/markdown' import _debug from 'debug' +import { cleanUrlsOptions } from '../../types/shared' export { resolveSiteDataByRoute } from './shared' @@ -77,7 +78,7 @@ export interface UserConfig { * Also generate static files as `foo/index.html` insted of `foo.html`. * @default false */ - cleanUrls?: boolean + cleanUrls?: cleanUrlsOptions } export type RawConfigExports = @@ -105,7 +106,7 @@ export interface SiteConfig tempDir: string alias: AliasOptions pages: string[] - cleanUrls: boolean + cleanUrls: cleanUrlsOptions } const resolve = (root: string, file: string) => @@ -175,7 +176,7 @@ export async function resolveConfig( shouldPreload: userConfig.shouldPreload, mpa: !!userConfig.mpa, ignoreDeadLinks: userConfig.ignoreDeadLinks, - cleanUrls: !!userConfig.cleanUrls + cleanUrls: userConfig.cleanUrls || 'off' } return config @@ -280,7 +281,7 @@ export async function resolveSiteData( locales: userConfig.locales || {}, langs: createLangDictionary(userConfig), scrollOffset: userConfig.scrollOffset || 90, - cleanUrls: userConfig.cleanUrls || false + cleanUrls: userConfig.cleanUrls || 'off' } } diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 26f8ba1e..5df8040b 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -18,6 +18,7 @@ import anchor from 'markdown-it-anchor' import attrs from 'markdown-it-attrs' import emoji from 'markdown-it-emoji' import toc from 'markdown-it-toc-done-right' +import { cleanUrlsOptions } from '../../../types/shared' export type ThemeOptions = Theme | { light: Theme; dark: Theme } @@ -57,7 +58,7 @@ export const createMarkdownRenderer = async ( srcDir: string, options: MarkdownOptions = {}, base = '/', - cleanUrls = false + cleanUrls: cleanUrlsOptions = 'off' ): Promise => { const md = MarkdownIt({ html: true, diff --git a/src/node/markdown/plugins/link.ts b/src/node/markdown/plugins/link.ts index 452dbe7c..1666e25f 100644 --- a/src/node/markdown/plugins/link.ts +++ b/src/node/markdown/plugins/link.ts @@ -6,6 +6,7 @@ import MarkdownIt from 'markdown-it' import { MarkdownRenderer } from '../markdown' import { URL } from 'url' import { EXTERNAL_URL_RE } from '../../shared' +import { cleanUrlsOptions } from '../../../../types/shared' const indexRE = /(^|.*\/)index.md(#?.*)$/i @@ -13,7 +14,7 @@ export const linkPlugin = ( md: MarkdownIt, externalAttrs: Record, base: string, - shouldCleanUrls: boolean + shouldCleanUrls: cleanUrlsOptions ) => { md.renderer.rules.link_open = (tokens, idx, options, env, self) => { const token = tokens[idx] @@ -51,7 +52,10 @@ export const linkPlugin = ( return self.renderToken(tokens, idx, options) } - function normalizeHref(hrefAttr: [string, string], shouldCleanUrls: boolean) { + function normalizeHref( + hrefAttr: [string, string], + shouldCleanUrls: cleanUrlsOptions + ) { let url = hrefAttr[1] const indexMatch = url.match(indexRE) @@ -62,11 +66,18 @@ export const linkPlugin = ( let cleanUrl = url.replace(/[?#].*$/, '').replace(/\?.*$/, '') // transform foo.md -> foo[.html] if (cleanUrl.endsWith('.md')) { - cleanUrl = cleanUrl.replace(/\.md$/, shouldCleanUrls ? '' : '.html') + cleanUrl = cleanUrl.replace( + /\.md$/, + shouldCleanUrls === 'off' + ? '.html' + : shouldCleanUrls === 'with-trailing-slash' + ? '/' + : '' + ) } // transform ./foo -> ./foo[.html] if ( - !shouldCleanUrls && + shouldCleanUrls === 'off' && !cleanUrl.endsWith('.html') && !cleanUrl.endsWith('/') ) { diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 01dd1f2b..f2c40185 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -9,6 +9,7 @@ import { deeplyParseHeader } from './utils/parseHeader' import { getGitTimestamp } from './utils/getGitTimestamp' import { createMarkdownRenderer, MarkdownOptions } from './markdown/markdown' import _debug from 'debug' +import { cleanUrlsOptions } from '../../types/shared' const debug = _debug('vitepress:md') const cache = new LRUCache({ max: 1024 }) @@ -29,7 +30,7 @@ export async function createMarkdownToVueRenderFn( isBuild = false, base = '/', includeLastUpdatedData = false, - cleanUrls: boolean = false + cleanUrls: cleanUrlsOptions = 'off' ) { const md = await createMarkdownRenderer(srcDir, options, base, cleanUrls) diff --git a/types/shared.d.ts b/types/shared.d.ts index 3ff68c91..fe1b6477 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -18,6 +18,11 @@ export interface Header { slug: string } +export type cleanUrlsOptions = + | 'off' + | 'with-trailing-slash' + | 'without-trailing-slash' + export interface SiteData { base: string @@ -57,7 +62,7 @@ export interface SiteData { label: string } > - cleanUrls: boolean + cleanUrls: cleanUrlsOptions } export type HeadConfig =