mirror of https://github.com/vuejs/vitepress
commit
93f53f5782
@ -0,0 +1,9 @@
|
||||
---
|
||||
title: bar
|
||||
---
|
||||
|
||||
Hello
|
||||
|
||||
---
|
||||
|
||||
world
|
||||
@ -0,0 +1,9 @@
|
||||
---
|
||||
title: foo
|
||||
---
|
||||
|
||||
Hello
|
||||
|
||||
---
|
||||
|
||||
world
|
||||
@ -0,0 +1,13 @@
|
||||
import { createContentLoader } from 'vitepress'
|
||||
|
||||
export default createContentLoader('data-loading/content/*.md', {
|
||||
includeSrc: true,
|
||||
excerpt: true,
|
||||
render: true,
|
||||
transform(data) {
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
transformed: true
|
||||
}))
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,10 @@
|
||||
# Static Data
|
||||
|
||||
<script setup lang="ts">
|
||||
import { data } from './basic.data.js'
|
||||
import { data as contentData } from './contentLoader.data.js'
|
||||
</script>
|
||||
|
||||
<pre id="basic">{{ data }}</pre>
|
||||
|
||||
<pre id="content">{{ contentData }}</pre>
|
||||
@ -0,0 +1,42 @@
|
||||
describe('static data file support in vite 3', () => {
|
||||
beforeAll(async () => {
|
||||
await goto('/data-loading/data')
|
||||
})
|
||||
|
||||
test('render correct content', async () => {
|
||||
expect(await page.textContent('pre#basic')).toMatchInlineSnapshot(`
|
||||
"[
|
||||
{
|
||||
\\"foo\\": true
|
||||
},
|
||||
{
|
||||
\\"bar\\": true
|
||||
}
|
||||
]"
|
||||
`)
|
||||
expect(await page.textContent('pre#content')).toMatchInlineSnapshot(`
|
||||
"[
|
||||
{
|
||||
\\"src\\": \\"---\\\\ntitle: bar\\\\n---\\\\n\\\\nHello\\\\n\\\\n---\\\\n\\\\nworld\\\\n\\",
|
||||
\\"html\\": \\"<p>Hello</p>\\\\n<hr>\\\\n<p>world</p>\\\\n\\",
|
||||
\\"frontmatter\\": {
|
||||
\\"title\\": \\"bar\\"
|
||||
},
|
||||
\\"excerpt\\": \\"<p>Hello</p>\\\\n\\",
|
||||
\\"url\\": \\"/data-loading/content/bar.html\\",
|
||||
\\"transformed\\": true
|
||||
},
|
||||
{
|
||||
\\"src\\": \\"---\\\\ntitle: foo\\\\n---\\\\n\\\\nHello\\\\n\\\\n---\\\\n\\\\nworld\\\\n\\",
|
||||
\\"html\\": \\"<p>Hello</p>\\\\n<hr>\\\\n<p>world</p>\\\\n\\",
|
||||
\\"frontmatter\\": {
|
||||
\\"title\\": \\"foo\\"
|
||||
},
|
||||
\\"excerpt\\": \\"<p>Hello</p>\\\\n\\",
|
||||
\\"url\\": \\"/data-loading/content/foo.html\\",
|
||||
\\"transformed\\": true
|
||||
}
|
||||
]"
|
||||
`)
|
||||
})
|
||||
})
|
||||
@ -1,14 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`static data file support in vite 3 > render correct content 1`] = `
|
||||
[
|
||||
"[
|
||||
{
|
||||
\\"foo\\": true
|
||||
},
|
||||
{
|
||||
\\"bar\\": true
|
||||
}
|
||||
]",
|
||||
]
|
||||
`;
|
||||
@ -1,7 +0,0 @@
|
||||
# Static Data
|
||||
|
||||
<script setup lang="ts">
|
||||
import { data } from './static.data.js'
|
||||
</script>
|
||||
|
||||
{{ data }}
|
||||
@ -1,12 +0,0 @@
|
||||
describe('static data file support in vite 3', () => {
|
||||
beforeAll(async () => {
|
||||
await goto('/static-data/data')
|
||||
})
|
||||
|
||||
test('render correct content', async () => {
|
||||
const pLocator = page.locator('.VPContent p')
|
||||
|
||||
const pContents = await pLocator.allTextContents()
|
||||
expect(pContents).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,84 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# SSR Compatibility
|
||||
|
||||
VitePress pre-renders the app in Node.js during the production build, using Vue's Server-Side Rendering (SSR) capabilities. This means all custom code in theme components are subject to SSR Compatibility.
|
||||
|
||||
The [SSR section in official Vue docs](https://vuejs.org/guide/scaling-up/ssr.html) provides more context on what is SSR, the relationship between SSR / SSG, and common notes on writing SSR-friendly code. The rule of thumb is to only access browser / DOM APIs in `beforeMount` or `mounted` hooks of Vue components.
|
||||
|
||||
## `<ClientOnly>`
|
||||
|
||||
If you are using or demoing components that are not SSR-friendly (for example, contain custom directives), you can wrap them inside the built-in `<ClientOnly>` component:
|
||||
|
||||
```md
|
||||
<ClientOnly>
|
||||
<NonSSRFriendlyComponent />
|
||||
</ClientOnly>
|
||||
```
|
||||
|
||||
## Libraries that Access Browser API on Import
|
||||
|
||||
Some components or libraries access browser APIs **on import**. To use code that assumes a browser environment on import, you need to dynamically import them.
|
||||
|
||||
### Importing in Mounted Hook
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
import('./lib-that-access-window-on-import').then((module) => {
|
||||
// use code
|
||||
})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Conditional Import
|
||||
|
||||
You can also conditionally import a dependency using the `import.meta.env.SSR` flag (part of [Vite env variables](https://vitejs.dev/guide/env-and-mode.html#env-variables)):
|
||||
|
||||
```js
|
||||
if (!import.meta.env.SSR) {
|
||||
import('./lib-that-access-window-on-import').then((module) => {
|
||||
// use code
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Since [`Theme.enhanceApp`](/guide/custom-theme#theme-interface) can be async, you can conditionally import and register Vue plugins that access browser APIs on import:
|
||||
|
||||
```js
|
||||
// .vitepress/theme/index.js
|
||||
export default {
|
||||
// ...
|
||||
async enhanceApp({ app }) {
|
||||
if (!import.meta.env.SSR) {
|
||||
const plugin = await import('plugin-that-access-window-on-import')
|
||||
app.use(plugin)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `defineClientComponent`
|
||||
|
||||
VitePress provides a convenience helper for importing Vue components that access browser APIs on import.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { defineClientComponent } from 'vitepress'
|
||||
|
||||
const ClientComp = defineClientComponent(() => {
|
||||
return import('component-that-access-window-on-import')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientComp />
|
||||
</template>
|
||||
```
|
||||
|
||||
The target component will only be imported in the mounted hook of the wrapper component.
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useData } from '../composables/data.js'
|
||||
import { getHeaders, resolveTitle } from '../composables/outline.js'
|
||||
import VPDocOutlineItem from './VPDocOutlineItem.vue'
|
||||
import { onContentUpdated } from 'vitepress'
|
||||
import VPIconChevronRight from './icons/VPIconChevronRight.vue'
|
||||
|
||||
const { frontmatter, theme } = useData()
|
||||
const open = ref(false)
|
||||
|
||||
onContentUpdated(() => {
|
||||
open.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="VPDocOutlineDropdown">
|
||||
<button @click="open = !open" :class="{ open }">
|
||||
{{ resolveTitle(theme) }}
|
||||
<VPIconChevronRight class="icon" />
|
||||
</button>
|
||||
<div class="items" v-if="open">
|
||||
<VPDocOutlineItem :headers="getHeaders(frontmatter.outline ?? theme.outline)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.VPDocOutlineDropdown {
|
||||
margin-bottom: 42px;
|
||||
}
|
||||
|
||||
.VPDocOutlineDropdown button {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color 0.5s;
|
||||
border: 1px solid var(--vp-c-border);
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.VPDocOutlineDropdown button:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.VPDocOutlineDropdown button.open {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
:deep(.outline-link) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.open > .icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.items {
|
||||
margin-top: 10px;
|
||||
border-left: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useData } from '../composables/data.js'
|
||||
import { getHeaders, resolveTitle } from '../composables/outline.js'
|
||||
import VPDocOutlineItem from './VPDocOutlineItem.vue'
|
||||
import { onContentUpdated } from 'vitepress'
|
||||
import VPIconChevronRight from './icons/VPIconChevronRight.vue'
|
||||
|
||||
const { frontmatter, theme } = useData()
|
||||
const open = ref(false)
|
||||
const vh = ref(0)
|
||||
|
||||
onContentUpdated(() => {
|
||||
open.value = false
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
vh.value = window.innerHeight + Math.min(window.scrollY - 64, 0)
|
||||
}
|
||||
|
||||
function onItemClick(e: Event) {
|
||||
if ((e.target as HTMLElement).classList.contains('outline-link')) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
open.value = false
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="VPLocalNavOutlineDropdown" :style="{ '--vp-vh': vh + 'px' }">
|
||||
<button @click="toggle" :class="{ open }">
|
||||
{{ resolveTitle(theme) }}
|
||||
<VPIconChevronRight class="icon" />
|
||||
</button>
|
||||
<div class="items" v-if="open" @click="onItemClick">
|
||||
<a class="top-link" href="#" @click="scrollToTop">
|
||||
{{ theme.returnToTopLabel || 'Return to top' }}
|
||||
</a>
|
||||
<VPDocOutlineItem :headers="getHeaders(frontmatter.outline ?? theme.outline)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.VPLocalNavOutlineDropdown {
|
||||
padding: 12px 20px 11px;
|
||||
}
|
||||
.VPLocalNavOutlineDropdown button {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color 0.5s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.VPLocalNavOutlineDropdown button:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.VPLocalNavOutlineDropdown button.open {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
:deep(.outline-link) {
|
||||
font-size: 14px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.open > .icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.items {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
top: 64px;
|
||||
background-color: var(--vp-local-nav-bg-color);
|
||||
padding: 4px 10px 16px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
max-height: calc(var(--vp-vh, 100vh) - 86px);
|
||||
overflow: scroll;
|
||||
box-shadow: var(--vp-shadow-3);
|
||||
}
|
||||
|
||||
.top-link {
|
||||
display: block;
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 6px 0;
|
||||
margin: 0 13px 10px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
</style>
|
||||
@ -1,31 +1,4 @@
|
||||
import './styles/fonts.css'
|
||||
import './styles/vars.css'
|
||||
import './styles/base.css'
|
||||
import './styles/utils.css'
|
||||
import './styles/components/custom-block.css'
|
||||
import './styles/components/vp-code.css'
|
||||
import './styles/components/vp-code-group.css'
|
||||
import './styles/components/vp-doc.css'
|
||||
import './styles/components/vp-sponsor.css'
|
||||
|
||||
import type { Theme } from 'vitepress'
|
||||
import VPBadge from './components/VPBadge.vue'
|
||||
import Layout from './Layout.vue'
|
||||
|
||||
export { default as VPHomeHero } from './components/VPHomeHero.vue'
|
||||
export { default as VPHomeFeatures } from './components/VPHomeFeatures.vue'
|
||||
export { default as VPHomeSponsors } from './components/VPHomeSponsors.vue'
|
||||
export { default as VPDocAsideSponsors } from './components/VPDocAsideSponsors.vue'
|
||||
export { default as VPTeamPage } from './components/VPTeamPage.vue'
|
||||
export { default as VPTeamPageTitle } from './components/VPTeamPageTitle.vue'
|
||||
export { default as VPTeamPageSection } from './components/VPTeamPageSection.vue'
|
||||
export { default as VPTeamMembers } from './components/VPTeamMembers.vue'
|
||||
|
||||
const theme: Theme = {
|
||||
Layout,
|
||||
enhanceApp: ({ app }) => {
|
||||
app.component('Badge', VPBadge)
|
||||
}
|
||||
}
|
||||
|
||||
export default theme
|
||||
export * from './without-fonts.js'
|
||||
export { default as default } from './without-fonts.js'
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import './styles/vars.css'
|
||||
import './styles/base.css'
|
||||
import './styles/utils.css'
|
||||
import './styles/components/custom-block.css'
|
||||
import './styles/components/vp-code.css'
|
||||
import './styles/components/vp-code-group.css'
|
||||
import './styles/components/vp-doc.css'
|
||||
import './styles/components/vp-sponsor.css'
|
||||
|
||||
import type { Theme } from 'vitepress'
|
||||
import VPBadge from './components/VPBadge.vue'
|
||||
import Layout from './Layout.vue'
|
||||
|
||||
// Note: if we add more optional components here, i.e. components that are not
|
||||
// used in the theme by default unless the user imports them, make sure to update
|
||||
// the `lazyDefaultThemeComponentsRE` regex in src/node/build/bundle.ts.
|
||||
export { default as VPHomeHero } from './components/VPHomeHero.vue'
|
||||
export { default as VPHomeFeatures } from './components/VPHomeFeatures.vue'
|
||||
export { default as VPHomeSponsors } from './components/VPHomeSponsors.vue'
|
||||
export { default as VPDocAsideSponsors } from './components/VPDocAsideSponsors.vue'
|
||||
export { default as VPTeamPage } from './components/VPTeamPage.vue'
|
||||
export { default as VPTeamPageTitle } from './components/VPTeamPageTitle.vue'
|
||||
export { default as VPTeamPageSection } from './components/VPTeamPageSection.vue'
|
||||
export { default as VPTeamMembers } from './components/VPTeamMembers.vue'
|
||||
|
||||
const theme: Theme = {
|
||||
Layout,
|
||||
enhanceApp: ({ app }) => {
|
||||
app.component('Badge', VPBadge)
|
||||
}
|
||||
}
|
||||
|
||||
export default theme
|
||||
@ -0,0 +1,140 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import glob from 'fast-glob'
|
||||
import type { SiteConfig } from './config'
|
||||
import matter from 'gray-matter'
|
||||
import { normalizePath } from 'vite'
|
||||
import { createMarkdownRenderer, type MarkdownRenderer } from './markdown'
|
||||
|
||||
export interface ContentOptions<T = ContentData[]> {
|
||||
/**
|
||||
* Include src?
|
||||
* default: false
|
||||
*/
|
||||
includeSrc?: boolean
|
||||
/**
|
||||
* Render src to HTML and include in data?
|
||||
* default: false
|
||||
*/
|
||||
render?: boolean
|
||||
/**
|
||||
* Whether to parse and include excerpt (rendered as HTML)
|
||||
* default: false
|
||||
*/
|
||||
excerpt?: boolean
|
||||
/**
|
||||
* Transform the data. Note the data will be inlined as JSON in the client
|
||||
* bundle if imported from components or markdown files.
|
||||
*/
|
||||
transform?: (data: ContentData[]) => T | Promise<T>
|
||||
}
|
||||
|
||||
export interface ContentData {
|
||||
url: string
|
||||
src: string | undefined
|
||||
html: string | undefined
|
||||
frontmatter: Record<string, any>
|
||||
excerpt: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a loader object that can be directly used as the default export
|
||||
* of a data loader file.
|
||||
*/
|
||||
export function createContentLoader<T = ContentData[]>(
|
||||
/**
|
||||
* files to glob / watch - relative to <project root>
|
||||
*/
|
||||
pattern: string | string[],
|
||||
{
|
||||
includeSrc,
|
||||
render,
|
||||
excerpt: renderExcerpt,
|
||||
transform
|
||||
}: ContentOptions<T> = {}
|
||||
): {
|
||||
watch: string | string[]
|
||||
load: () => Promise<T>
|
||||
} {
|
||||
const config: SiteConfig = (global as any).VITEPRESS_CONFIG
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
'content loader invoked without an active vitepress process, ' +
|
||||
'or before vitepress config is resolved.'
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof pattern === 'string') pattern = [pattern]
|
||||
pattern = pattern.map((p) => normalizePath(path.join(config.root, p)))
|
||||
|
||||
let md: MarkdownRenderer
|
||||
|
||||
const cache = new Map<
|
||||
string,
|
||||
{
|
||||
data: any
|
||||
timestamp: number
|
||||
}
|
||||
>()
|
||||
|
||||
return {
|
||||
watch: pattern,
|
||||
async load(files?: string[]) {
|
||||
if (!files) {
|
||||
// the loader is being called directly, do a fresh glob
|
||||
files = (
|
||||
await glob(pattern, {
|
||||
ignore: ['**/node_modules/**', '**/dist/**']
|
||||
})
|
||||
).sort()
|
||||
}
|
||||
|
||||
md =
|
||||
md ||
|
||||
(await createMarkdownRenderer(
|
||||
config.srcDir,
|
||||
config.markdown,
|
||||
config.site.base,
|
||||
config.logger
|
||||
))
|
||||
|
||||
const raw: ContentData[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.md')) {
|
||||
continue
|
||||
}
|
||||
const timestamp = fs.statSync(file).mtimeMs
|
||||
const cached = cache.get(file)
|
||||
if (cached && timestamp === cached.timestamp) {
|
||||
raw.push(cached.data)
|
||||
} else {
|
||||
const src = fs.readFileSync(file, 'utf-8')
|
||||
const { data: frontmatter, excerpt } = matter(src, {
|
||||
excerpt: true
|
||||
})
|
||||
const url =
|
||||
'/' +
|
||||
normalizePath(path.relative(config.root, file)).replace(
|
||||
/\.md$/,
|
||||
config.cleanUrls ? '' : '.html'
|
||||
)
|
||||
const html = render ? md.render(src) : undefined
|
||||
const renderedExcerpt = renderExcerpt
|
||||
? excerpt && md.render(excerpt)
|
||||
: undefined
|
||||
const data: ContentData = {
|
||||
src: includeSrc ? src : undefined,
|
||||
html,
|
||||
frontmatter,
|
||||
excerpt: renderedExcerpt,
|
||||
url
|
||||
}
|
||||
cache.set(file, { data, timestamp })
|
||||
raw.push(data)
|
||||
}
|
||||
}
|
||||
return (transform ? transform(raw) : raw) as any
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
export function serializeFunctions(value: any): any {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(serializeFunctions)
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
return Object.keys(value).reduce((acc, key) => {
|
||||
acc[key] = serializeFunctions(value[key])
|
||||
return acc
|
||||
}, {} as any)
|
||||
} else if (typeof value === 'function') {
|
||||
return `_vp-fn_${value.toString()}`
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function deserializeFunctions(value: any): any {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(deserializeFunctions)
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
return Object.keys(value).reduce((acc, key) => {
|
||||
acc[key] = deserializeFunctions(value[key])
|
||||
return acc
|
||||
}, {} as any)
|
||||
} else if (typeof value === 'string' && value.startsWith('_vp-fn_')) {
|
||||
return new Function(`return ${value.slice(7)}`)()
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue