diff --git a/docs/guide/markdown.md b/docs/guide/markdown.md index 342904e5..11c60ff7 100644 --- a/docs/guide/markdown.md +++ b/docs/guide/markdown.md @@ -46,7 +46,7 @@ Pages and internal links get generated with the `.html` suffix by default. ### External Links -Outbound links automatically get `target="_blank" rel="noopener noreferrer"`: +Outbound links automatically get `target="_blank" rel="noreferrer"`: - [vuejs.org](https://vuejs.org) - [VitePress on GitHub](https://github.com/vuejs/vitepress) diff --git a/src/client/app/composables/head.ts b/src/client/app/composables/head.ts index 91a46d21..d74d6b5c 100644 --- a/src/client/app/composables/head.ts +++ b/src/client/app/composables/head.ts @@ -1,5 +1,5 @@ import { watchEffect, Ref } from 'vue' -import { HeadConfig, SiteData, createTitle } from '../../shared' +import { HeadConfig, SiteData, createTitle, mergeHead } from '../../shared' import { Route } from '../router' export function useUpdateHead(route: Route, siteDataByRouteRef: Ref) { @@ -14,50 +14,20 @@ export function useUpdateHead(route: Route, siteDataByRouteRef: Ref) { return } - const newEls: HTMLElement[] = [] - const commonLength = Math.min(managedHeadTags.length, newTags.length) - for (let i = 0; i < commonLength; i++) { - let el = managedHeadTags[i] - const [tag, attrs, innerHTML = ''] = newTags[i] - if (el.tagName.toLocaleLowerCase() === tag) { - for (const key in attrs) { - if (el.getAttribute(key) !== attrs[key]) { - el.setAttribute(key, attrs[key]) - } - } - for (let i = 0; i < el.attributes.length; i++) { - const name = el.attributes[i].name - if (!(name in attrs)) { - el.removeAttribute(name) - } - } - if (el.innerHTML !== innerHTML) { - el.innerHTML = innerHTML - } - } else { - document.head.removeChild(el) - el = createHeadElement(newTags[i]) - document.head.append(el) - } - newEls.push(el) - } - - managedHeadTags - .slice(commonLength) - .forEach((el) => document.head.removeChild(el)) - newTags.slice(commonLength).forEach((headConfig) => { + managedHeadTags.forEach((el) => document.head.removeChild(el)) + managedHeadTags = [] + newTags.forEach((headConfig) => { const el = createHeadElement(headConfig) document.head.appendChild(el) - newEls.push(el) + managedHeadTags.push(el) }) - managedHeadTags = newEls } watchEffect(() => { const pageData = route.data const siteData = siteDataByRouteRef.value const pageDescription = pageData && pageData.description - const frontmatterHead = pageData && pageData.frontmatter.head + const frontmatterHead = (pageData && pageData.frontmatter.head) || [] // update title and description document.title = createTitle(siteData, pageData) @@ -66,11 +36,9 @@ export function useUpdateHead(route: Route, siteDataByRouteRef: Ref) { .querySelector(`meta[name=description]`)! .setAttribute('content', pageDescription || siteData.description) - updateHeadTags([ - // site head can only change during dev - ...(import.meta.env.DEV ? siteData.head : []), - ...(frontmatterHead ? filterOutHeadDescription(frontmatterHead) : []) - ]) + updateHeadTags( + mergeHead(siteData.head, filterOutHeadDescription(frontmatterHead)) + ) }) } diff --git a/src/client/theme-default/components/VPButton.vue b/src/client/theme-default/components/VPButton.vue index 714f3e0c..12d0a44a 100644 --- a/src/client/theme-default/components/VPButton.vue +++ b/src/client/theme-default/components/VPButton.vue @@ -34,7 +34,7 @@ const component = computed(() => { :class="classes" :href="href ? normalizeLink(href) : undefined" :target="isExternal ? '_blank' : undefined" - :rel="isExternal ? 'noopener noreferrer' : undefined" + :rel="isExternal ? 'noreferrer' : undefined" > {{ text }} diff --git a/src/client/theme-default/components/VPLink.vue b/src/client/theme-default/components/VPLink.vue index 3984f811..1a28c273 100644 --- a/src/client/theme-default/components/VPLink.vue +++ b/src/client/theme-default/components/VPLink.vue @@ -19,7 +19,7 @@ const isExternal = computed(() => props.href && EXTERNAL_URL_RE.test(props.href) :class="{ link: href }" :href="href ? normalizeLink(href) : undefined" :target="isExternal ? '_blank' : undefined" - :rel="isExternal ? 'noopener noreferrer' : undefined" + :rel="isExternal ? 'noreferrer' : undefined" > diff --git a/src/client/theme-default/components/VPSidebar.vue b/src/client/theme-default/components/VPSidebar.vue index 7602d68b..ffa3047c 100644 --- a/src/client/theme-default/components/VPSidebar.vue +++ b/src/client/theme-default/components/VPSidebar.vue @@ -83,7 +83,6 @@ watchPostEffect(async () => { padding-bottom: 128px; width: var(--vp-sidebar-width); max-width: 100%; - width: var(--vp-sidebar-width); background-color: var(--vp-c-bg-alt); opacity: 1; visibility: visible; @@ -94,7 +93,7 @@ watchPostEffect(async () => { @media (min-width: 1440px) { .VPSidebar { - padding-left: calc((100% - (var(--vp-layout-max-width) - 64px)) / 2); + padding-left: max(32px, calc((100% - (var(--vp-layout-max-width) - 64px)) / 2)); width: calc((100% - (var(--vp-layout-max-width) - 64px)) / 2 + var(--vp-sidebar-width) - 32px); } } diff --git a/src/client/theme-default/components/VPSocialLink.vue b/src/client/theme-default/components/VPSocialLink.vue index 728a05ae..2628ae2a 100644 --- a/src/client/theme-default/components/VPSocialLink.vue +++ b/src/client/theme-default/components/VPSocialLink.vue @@ -19,7 +19,7 @@ const svg = computed(() => { class="VPSocialLink" :href="link" target="_blank" - rel="noopener noreferrer" + rel="noopener" v-html="svg" > diff --git a/src/client/theme-default/composables/outline.ts b/src/client/theme-default/composables/outline.ts index 698aeda6..355e9bc4 100644 --- a/src/client/theme-default/composables/outline.ts +++ b/src/client/theme-default/composables/outline.ts @@ -105,7 +105,7 @@ export function useActiveAnchor( const scrollY = window.scrollY const innerHeight = window.innerHeight const offsetHeight = document.body.offsetHeight - const isBottom = scrollY + innerHeight === offsetHeight + const isBottom = Math.abs(scrollY + innerHeight - offsetHeight) < 1 // page bottom - highlight last one if (anchors.length && isBottom) { diff --git a/src/client/theme-default/composables/sidebar.ts b/src/client/theme-default/composables/sidebar.ts index 7baedde4..123fe9cc 100644 --- a/src/client/theme-default/composables/sidebar.ts +++ b/src/client/theme-default/composables/sidebar.ts @@ -10,7 +10,8 @@ export function useSidebar() { const sidebar = computed(() => { const sidebarConfig = theme.value.sidebar - return sidebarConfig ? getSidebar(sidebarConfig, route.path) : [] + const relativePath = route.data.relativePath + return sidebarConfig ? getSidebar(sidebarConfig, relativePath) : [] }) const hasSidebar = computed(() => { diff --git a/src/client/theme-default/styles/components/vp-doc.css b/src/client/theme-default/styles/components/vp-doc.css index 29128d3a..c8c64a94 100644 --- a/src/client/theme-default/styles/components/vp-doc.css +++ b/src/client/theme-default/styles/components/vp-doc.css @@ -222,7 +222,7 @@ .vp-doc .custom-block div[class*='language-'] code { font-weight: 400; - background-color: var(--vp-code-block-bg); + background-color: transparent; } /** diff --git a/src/node/build/build.ts b/src/node/build/build.ts index d8c34f3f..5936a61f 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -87,5 +87,7 @@ export async function build( await fs.remove(siteConfig.tempDir) } + await siteConfig.buildEnd?.(siteConfig) + console.log(`build complete in ${((Date.now() - start) / 1000).toFixed(2)}s.`) } diff --git a/src/node/build/render.ts b/src/node/build/render.ts index c4330e3e..a03729e5 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -5,7 +5,13 @@ import { pathToFileURL } from 'url' import escape from 'escape-html' import { normalizePath, transformWithEsbuild } from 'vite' import { RollupOutput, OutputChunk, OutputAsset } from 'rollup' -import { HeadConfig, PageData, createTitle, notFoundPageData } from '../shared' +import { + HeadConfig, + PageData, + createTitle, + notFoundPageData, + mergeHead +} from '../shared' import { slash } from '../utils/slash' import { SiteConfig, resolveSiteDataByRoute } from '../config' @@ -115,10 +121,10 @@ export async function renderPage( const title: string = createTitle(siteData, pageData) const description: string = pageData.description || siteData.description - const head = [ - ...siteData.head, - ...filterOutHeadDescription(pageData.frontmatter.head) - ] + const head = mergeHead( + siteData.head, + filterOutHeadDescription(pageData.frontmatter.head) + ) let inlinedScript = '' if (config.mpa && result) { diff --git a/src/node/config.ts b/src/node/config.ts index 7560020e..1fac1d1b 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -71,6 +71,12 @@ export interface UserConfig { * @default false */ ignoreDeadLinks?: boolean + + /** + * Build end hook: called when SSG finish. + * @param siteConfig The resolved configuration. + */ + buildEnd?: (siteConfig: SiteConfig) => Promise } export type RawConfigExports = @@ -88,6 +94,7 @@ export interface SiteConfig | 'mpa' | 'lastUpdated' | 'ignoreDeadLinks' + | 'buildEnd' > { root: string srcDir: string @@ -166,7 +173,8 @@ export async function resolveConfig( vite: userConfig.vite, shouldPreload: userConfig.shouldPreload, mpa: !!userConfig.mpa, - ignoreDeadLinks: userConfig.ignoreDeadLinks + ignoreDeadLinks: userConfig.ignoreDeadLinks, + buildEnd: userConfig.buildEnd } return config @@ -277,7 +285,7 @@ function resolveSiteDataHead(userConfig?: UserConfig): HeadConfig[] { if (userConfig?.appearance ?? true) { head.push([ 'script', - {}, + { id: 'check-dark-light' }, ` ;(() => { const saved = localStorage.getItem('${APPEARANCE_KEY}') diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 77276c62..7582f47f 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -78,7 +78,7 @@ export const createMarkdownRenderer = async ( linkPlugin, { target: '_blank', - rel: 'noopener noreferrer', + rel: 'noreferrer', ...options.externalLinks }, base diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index bc82adb5..9f050cd9 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -83,7 +83,7 @@ export async function createMarkdownToVueRenderFn( `\n(!) Found dead link ${c.cyan(url)} in file ${c.white( c.dim(file) )}\nIf it is intended, you can use:\n ${c.cyan( - `${url}` + `${url}` )}` ) ) diff --git a/src/node/serve/serve.ts b/src/node/serve/serve.ts index 6b81e306..c099b3d8 100644 --- a/src/node/serve/serve.ts +++ b/src/node/serve/serve.ts @@ -1,3 +1,5 @@ +import fs from 'fs' +import path from 'path' import sirv from 'sirv' import compression from 'compression' import polka from 'polka' @@ -26,14 +28,21 @@ export async function serve(options: ServeOptions = {}) { const site = await resolveConfig(options.root, 'serve', 'production') const base = trimChar(options?.base ?? site?.site?.base ?? '', '/') + const notAnAsset = (pathname: string) => !pathname.includes('/assets/') + const notFound = fs.readFileSync(path.resolve(site.outDir, './404.html')) + const onNoMatch: polka.Options['onNoMatch'] = (req, res) => { + res.statusCode = 404 + if (notAnAsset(req.path)) res.write(notFound.toString()) + res.end() + } + const compress = compression() const serve = sirv(site.outDir, { etag: true, - single: true, maxAge: 31536000, immutable: true, setHeaders(res, pathname) { - if (!pathname.includes('/assets/')) { + if (notAnAsset(pathname)) { // force server validation for non-asset files since they // are not fingerprinted res.setHeader('cache-control', 'no-cache') @@ -42,14 +51,14 @@ export async function serve(options: ServeOptions = {}) { }) if (base) { - polka() + polka({ onNoMatch }) .use(base, compress, serve) .listen(port, (err: any) => { if (err) throw err console.log(`Built site served at http://localhost:${port}/${base}/\n`) }) } else { - polka() + polka({ onNoMatch }) .use(compress, serve) .listen(port, (err: any) => { if (err) throw err diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 959d470c..98be7691 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -1,4 +1,9 @@ -import { SiteData, PageData, LocaleConfig } from '../../types/shared' +import { + SiteData, + PageData, + LocaleConfig, + HeadConfig +} from '../../types/shared' export type { SiteData, @@ -21,7 +26,7 @@ export const notFoundPageData: PageData = { title: '404', description: 'Not Found', headers: [], - frontmatter: {}, + frontmatter: { sidebar: false, layout: 'page' }, lastUpdated: 0 } @@ -136,3 +141,17 @@ function cleanRoute(siteData: SiteData, route: string): string { return route.slice(baseWithoutSuffix.length) } + +function hasTag(head: HeadConfig[], tag: HeadConfig) { + const [tagType, tagAttrs] = tag + if (tagType !== 'meta') return false + const keyAttr = Object.entries(tagAttrs)[0] // First key + if (keyAttr == null) return false + return head.some( + ([type, attrs]) => type === tagType && attrs[keyAttr[0]] === keyAttr[1] + ) +} + +export function mergeHead(prev: HeadConfig[], curr: HeadConfig[]) { + return [...prev.filter((tagAttrs) => !hasTag(curr, tagAttrs)), ...curr] +}