Merge branch 'main' into feat/footer-with-sidebar

pull/4532/head
Leo 6 months ago committed by GitHub
commit 6414b5ef11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,3 +1,47 @@
## [2.0.0-alpha.4](https://github.com/vuejs/vitepress/compare/v2.0.0-alpha.3...v2.0.0-alpha.4) (2025-03-09)
### Bug Fixes
- **build/regression:** langAlias not working ([06ae2bf](https://github.com/vuejs/vitepress/commit/06ae2bf3a4ee02351530b0bd055e577ca6509d62)), closes [#4581](https://github.com/vuejs/vitepress/issues/4581)
- don't hardcode `tabindex` attr in table renderer ([#4082](https://github.com/vuejs/vitepress/issues/4082)) ([aadc517](https://github.com/vuejs/vitepress/commit/aadc517c69fb239bdda99173bbc123ace567484b))
- hmr not working for watched files in path loaders ([e271695](https://github.com/vuejs/vitepress/commit/e271695d716247455ca620948f814e6c8ca0e3c4)), closes [#4525](https://github.com/vuejs/vitepress/issues/4525)
- ignore non-text content in permalink generation and fix types of markdown.config ([a8a1800](https://github.com/vuejs/vitepress/commit/a8a1800ae578be88027aa4ec7561ada4d055b888))
- prevent reload on first server start in fresh installations ([d8a884e](https://github.com/vuejs/vitepress/commit/d8a884ed0f754523765058a70149cdbaf6942341))
- properly merge classes in custom containers ([#4128](https://github.com/vuejs/vitepress/issues/4128)) ([8aad617](https://github.com/vuejs/vitepress/commit/8aad617446c03d39a65a0b21e9fce43bc484af1e))
- rebuild dynamic routes cache on server restart ([9f54714](https://github.com/vuejs/vitepress/commit/9f54714e7db69fd4902f1917f927456c71b5a292)), closes [#4525](https://github.com/vuejs/vitepress/issues/4525)
### Features
- allow matching region end in snippets without tag ([#4287](https://github.com/vuejs/vitepress/issues/4287)) ([1a2f81d](https://github.com/vuejs/vitepress/commit/1a2f81de4d6549dd1adf86ae131d1a861158bd2d))
- improve region regexes for snippet plugin ([1a6684c](https://github.com/vuejs/vitepress/commit/1a6684cf1054d326bc1dd6eeb9fb78b150ac2b2a))
- support using header anchors in markdown file inclusion ([#4608](https://github.com/vuejs/vitepress/issues/4608)) ([b99d512](https://github.com/vuejs/vitepress/commit/b99d5123c9b2afdc7461089e03476c34d7816faf)), closes [#4375](https://github.com/vuejs/vitepress/issues/4375) [#4382](https://github.com/vuejs/vitepress/issues/4382)
## [2.0.0-alpha.3](https://github.com/vuejs/vitepress/compare/v2.0.0-alpha.2...v2.0.0-alpha.3) (2025-02-24)
### Bug Fixes
- **build:** `--minify` not working as documented ([9b5c037](https://github.com/vuejs/vitepress/commit/9b5c0377cd3474447c84b2901801287f3caf3d82)), closes [#4523](https://github.com/vuejs/vitepress/issues/4523)
- **build:** deterministic code group ids ([#4565](https://github.com/vuejs/vitepress/issues/4565)) ([b930b8d](https://github.com/vuejs/vitepress/commit/b930b8d5310f1691d8d9f009f45b70122e4ce800))
- **markdown:** include content of all tokens in heading ids ([68dff2a](https://github.com/vuejs/vitepress/commit/68dff2af8547ae70f6622ac826affd76f2f6378e)), closes [#4561](https://github.com/vuejs/vitepress/issues/4561)
- **client:** set correct oldURL and newURL for hashchange ([#4573](https://github.com/vuejs/vitepress/issues/4573)) ([d1f2afd](https://github.com/vuejs/vitepress/commit/d1f2afdf0fbb022f12cc12295723b3b7c7ef5cb1))
- **theme:** allow interactions behind scroll shadow ([#4537](https://github.com/vuejs/vitepress/issues/4537)) ([091d584](https://github.com/vuejs/vitepress/commit/091d5840ae15b64e04e8c07fbc0263a2749571bd))
- **theme:** code block contrast ratio ([#4487](https://github.com/vuejs/vitepress/issues/4487)) ([5dccaee](https://github.com/vuejs/vitepress/commit/5dccaeef055beb109919f8990032975a0d630384))
- **build:** fix flaky embedded languages highlighting ([#4566](https://github.com/vuejs/vitepress/issues/4566)) ([1969cf4](https://github.com/vuejs/vitepress/commit/1969cf4f3b93ad105595e4e2f8b030b04eb1c975))
### Features
- **cli:** support custom `srcDir` ([#4270](https://github.com/vuejs/vitepress/issues/4270)) ([518c094](https://github.com/vuejs/vitepress/commit/518c0945f159aae679ef710bb48ae3ab3891cc9f))
- **cli:** support custom npm scripts prefix ([#4271](https://github.com/vuejs/vitepress/issues/4271)) ([e5a0ee8](https://github.com/vuejs/vitepress/commit/e5a0ee8161752a77c5bb9546245a940cb5f28fb8))
- **build:** dynamic routes plugin overhaul ([#4525](https://github.com/vuejs/vitepress/issues/4525)) ([a62ea6a](https://github.com/vuejs/vitepress/commit/a62ea6a832a33b756642b24ad5d38c248e08b554))
- **build:** update to shiki v3 ([#4571](https://github.com/vuejs/vitepress/issues/4571)) ([52c2aa1](https://github.com/vuejs/vitepress/commit/52c2aa178d4b3fa98b863cf28f0ccf6d2aabcd93))
- **build:** use `markdown-it-async`, remove `synckit` ([#4507](https://github.com/vuejs/vitepress/issues/4507)) ([8062235](https://github.com/vuejs/vitepress/commit/80622356f1d648577ee47ee3a44b04bb015ee462))
### BREAKING CHANGES
- markdown-it-async is used instead of markdown-it. If you're using custom content renderer for local search, you'll need to do `await md.renderAsync` instead of `md.render`.
- Internals are modified a bit to better support vite 6 and handle HMR more correctly. For most users this won't need any change on their side.
- shiki is upgraded to v3. There shouldn't be any breaking change but if you see any issue, please report it.
## [2.0.0-alpha.2](https://github.com/vuejs/vitepress/compare/v2.0.0-alpha.1...v2.0.0-alpha.2) (2025-01-23) ## [2.0.0-alpha.2](https://github.com/vuejs/vitepress/compare/v2.0.0-alpha.1...v2.0.0-alpha.2) (2025-01-23)
### Bug Fixes ### Bug Fixes

@ -173,5 +173,13 @@ export default defineConfig({
} }
} }
} }
},
vite: {
server: {
watch: {
usePolling: true,
interval: 100
}
}
} }
}) })

@ -1,8 +1,14 @@
export default { import { defineRoutes } from 'vitepress'
async paths() { import paths from './paths'
return [
{ params: { id: 'foo' }, content: `# Foo` }, export default defineRoutes({
{ params: { id: 'bar' }, content: `# Bar` } async paths(watchedFiles: string[]) {
] // console.log('watchedFiles', watchedFiles)
return paths
},
watch: ['**/data-loading/**/*.json'],
async transformPageData(pageData) {
// console.log('transformPageData', pageData.filePath)
pageData.title += ' - transformed'
} }
} })

@ -0,0 +1,4 @@
export default [
{ params: { id: 'foo' }, content: `# Foo` },
{ params: { id: 'bar' }, content: `# Bar` }
]

@ -0,0 +1,27 @@
# header 1
header 1 content
## header 1.1
header 1.1 content
### header 1.1.1
header 1.1.1 content
### header 1.1.2
header 1.1.2 content
## header 1.2
header 1.2 content
### header 1.2.1
header 1.2.1 content
### header 1.2.2
header 1.2.2 content

@ -213,6 +213,10 @@ export default config
<!--@include: ./region-include.md#range-region{5,}--> <!--@include: ./region-include.md#range-region{5,}-->
## Markdown File Inclusion with Header
<!--@include: ./header-include.md#header-1-1-->
## Image Lazy Loading ## Image Lazy Loading
![vitepress logo](/vitepress.png) ![vitepress logo](/vitepress.png)

@ -64,8 +64,61 @@ describe('Emoji', () => {
describe('Table of Contents', () => { describe('Table of Contents', () => {
test('render toc', async () => { test('render toc', async () => {
const items = page.locator('#table-of-contents + nav ul li') const items = page.locator('#table-of-contents + nav ul li')
const count = await items.count() expect(
expect(count).toBe(44) await items.evaluateAll((elements) =>
elements.map((el) => el.childNodes[0].textContent)
)
).toMatchInlineSnapshot(`
[
"Links",
"Internal Links",
"External Links",
"GitHub-Style Tables",
"Emoji",
"Table of Contents",
"Custom Containers",
"Default Title",
"Custom Title",
"Line Highlighting in Code Blocks",
"Single Line",
"Multiple single lines, ranges",
"Comment Highlight",
"Line Numbers",
"Import Code Snippets",
"Basic Code Snippet",
"Specify Region",
"With Other Features",
"Code Groups",
"Basic Code Group",
"With Other Features",
"Markdown File Inclusion",
"Region",
"Markdown At File Inclusion",
"Markdown Nested File Inclusion",
"Region",
"After Foo",
"Sub sub",
"Sub sub sub",
"Markdown File Inclusion with Range",
"Region",
"Markdown File Inclusion with Range without Start",
"Region",
"Markdown File Inclusion with Range without End",
"Region",
"Markdown At File Region Snippet",
"Region Snippet",
"Markdown At File Range Region Snippet",
"Range Region Line 2",
"Markdown At File Range Region Snippet without start",
"Range Region Line 1",
"Markdown At File Range Region Snippet without end",
"Range Region Line 3",
"Markdown File Inclusion with Header",
"header 1.1.1",
"header 1.1.2",
"Image Lazy Loading",
]
`)
}) })
}) })

@ -1,4 +1,9 @@
import { dedent, rawPathToToken } from 'node/markdown/plugins/snippet' import {
dedent,
findRegion,
rawPathToToken
} from 'node/markdown/plugins/snippet'
import { expect } from 'vitest'
const removeEmptyKeys = <T extends Record<string, unknown>>(obj: T) => { const removeEmptyKeys = <T extends Record<string, unknown>>(obj: T) => {
return Object.fromEntries( return Object.fromEntries(
@ -94,9 +99,228 @@ describe('node/markdown/plugins/snippet', () => {
}) })
}) })
test('rawPathToToken', () => { describe('rawPathToToken', () => {
rawPathTokenMap.forEach(([rawPath, token]) => { test.each(rawPathTokenMap)('%s', (rawPath, token) => {
expect(removeEmptyKeys(rawPathToToken(rawPath))).toEqual(token) expect(removeEmptyKeys(rawPathToToken(rawPath))).toEqual(token)
}) })
}) })
describe('findRegion', () => {
it('returns null when no region markers are present', () => {
const lines = ['function foo() {', ' console.log("hello");', '}']
expect(findRegion(lines, 'foo')).toBeNull()
})
it('ignores non-matching region names', () => {
const lines = [
'// #region regionA',
'some code here',
'// #endregion regionA'
]
expect(findRegion(lines, 'regionC')).toBeNull()
})
it('returns null if a region start marker exists without a matching end marker', () => {
const lines = [
'// #region missingEnd',
'console.log("inside region");',
'console.log("still inside");'
]
expect(findRegion(lines, 'missingEnd')).toBeNull()
})
it('returns null if an end marker exists without a preceding start marker', () => {
const lines = [
'// #endregion ghostRegion',
'console.log("stray end marker");'
]
expect(findRegion(lines, 'ghostRegion')).toBeNull()
})
it('detects C#/JavaScript style region markers with matching tags', () => {
const lines = [
'Console.WriteLine("Before region");',
'#region hello',
'Console.WriteLine("Hello, World!");',
'#endregion hello',
'Console.WriteLine("After region");'
]
const result = findRegion(lines, 'hello')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'Console.WriteLine("Hello, World!");'
)
}
})
it('detects region markers even when the end marker omits the region name', () => {
const lines = [
'Console.WriteLine("Before region");',
'#region hello',
'Console.WriteLine("Hello, World!");',
'#endregion',
'Console.WriteLine("After region");'
]
const result = findRegion(lines, 'hello')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'Console.WriteLine("Hello, World!");'
)
}
})
it('handles indented region markers correctly', () => {
const lines = [
' Console.WriteLine("Before region");',
' #region hello',
' Console.WriteLine("Hello, World!");',
' #endregion hello',
' Console.WriteLine("After region");'
]
const result = findRegion(lines, 'hello')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
' Console.WriteLine("Hello, World!");'
)
}
})
it('detects TypeScript style region markers', () => {
const lines = [
'let regexp: RegExp[] = [];',
'// #region foo',
'let start = -1;',
'// #endregion foo'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'let start = -1;'
)
}
})
it('detects CSS style region markers', () => {
const lines = [
'.body-content {',
'/* #region foo */',
' padding-left: 15px;',
'/* #endregion foo */',
' padding-right: 15px;',
'}'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
' padding-left: 15px;'
)
}
})
it('detects HTML style region markers', () => {
const lines = [
'<div>Some content</div>',
'<!-- #region foo -->',
' <h1>Hello world</h1>',
'<!-- #endregion foo -->',
'<div>Other content</div>'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
' <h1>Hello world</h1>'
)
}
})
it('detects Visual Basic style region markers (with case-insensitive "End")', () => {
const lines = [
'Console.WriteLine("VB")',
'#Region VBRegion',
' Console.WriteLine("Inside region")',
'#End Region VBRegion',
'Console.WriteLine("Done")'
]
const result = findRegion(lines, 'VBRegion')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
' Console.WriteLine("Inside region")'
)
}
})
it('detects Bat style region markers', () => {
const lines = ['::#region foo', 'echo off', '::#endregion foo']
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'echo off'
)
}
})
it('detects C/C++ style region markers using #pragma', () => {
const lines = [
'#pragma region foo',
'int a = 1;',
'#pragma endregion foo'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'int a = 1;'
)
}
})
it('returns the first complete region when multiple regions exist', () => {
const lines = [
'// #region foo',
'first region content',
'// #endregion foo',
'// #region foo',
'second region content',
'// #endregion foo'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'first region content'
)
}
})
it('handles nested regions with different names properly', () => {
const lines = [
'// #region foo',
"console.log('line before nested');",
'// #region bar',
"console.log('nested content');",
'// #endregion bar',
'// #endregion foo'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
const extracted = lines.slice(result.start, result.end).join('\n')
const expected = [
"console.log('line before nested');",
'// #region bar',
"console.log('nested content');",
'// #endregion bar'
].join('\n')
expect(extracted).toBe(expected)
}
})
})
}) })

@ -0,0 +1,72 @@
import { ModuleGraph } from 'node/utils/moduleGraph'
describe('node/utils/moduleGraph', () => {
let graph: ModuleGraph
beforeEach(() => {
graph = new ModuleGraph()
})
it('should correctly delete a module and its dependents', () => {
graph.add('A', ['B', 'C'])
graph.add('B', ['D'])
graph.add('C', [])
graph.add('D', [])
expect(graph.delete('D')).toEqual(new Set(['D', 'B', 'A']))
})
it('should handle shared dependencies correctly', () => {
graph.add('A', ['B', 'C'])
graph.add('B', ['D'])
graph.add('C', ['D']) // Shared dependency
graph.add('D', [])
expect(graph.delete('D')).toEqual(new Set(['A', 'B', 'C', 'D']))
})
it('merges dependencies correctly', () => {
// Add module A with dependency B
graph.add('A', ['B'])
// Merge new dependency C into module A (B should remain)
graph.add('A', ['C'])
// Deleting B should remove A as well, since A depends on B.
expect(graph.delete('B')).toEqual(new Set(['B', 'A']))
})
it('handles cycles gracefully', () => {
// Create a cycle: A -> B, B -> C, C -> A.
graph.add('A', ['B'])
graph.add('B', ['C'])
graph.add('C', ['A'])
// Deleting any module in the cycle should delete all modules in the cycle.
expect(graph.delete('A')).toEqual(new Set(['A', 'B', 'C']))
})
it('cleans up dependencies when deletion', () => {
// Setup A -> B relationship.
graph.add('A', ['B'])
graph.add('B', [])
// Deleting B should remove both B and A from the graph.
expect(graph.delete('B')).toEqual(new Set(['B', 'A']))
// After deletion, add modules again.
graph.add('C', [])
graph.add('A', ['C']) // Now A depends only on C.
expect(graph.delete('C')).toEqual(new Set(['C', 'A']))
})
it('handles independent modules', () => {
// Modules with no dependencies.
graph.add('X', [])
graph.add('Y', [])
// Deletion of one should only remove that module.
expect(graph.delete('X')).toEqual(new Set(['X']))
expect(graph.delete('Y')).toEqual(new Set(['Y']))
})
})

@ -897,6 +897,53 @@ You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/co
Note that this does not throw errors if your file is not present. Hence, when using this feature make sure that the contents are being rendered as expected. Note that this does not throw errors if your file is not present. Hence, when using this feature make sure that the contents are being rendered as expected.
::: :::
Instead of VS Code regions, you can also use header anchors to include a specific section of the file. For example, if you have a header in your markdown file like this:
```md
## My Base Section
Some content here.
### My Sub Section
Some more content here.
## Another Section
Content outside `My Base Section`.
```
You can include the `My Base Section` section like this:
```md
## My Extended Section
<!--@include: ./parts/basics.md#my-base-section-->
```
**Equivalent code**
```md
## My Extended Section
Some content here.
### My Sub Section
Some more content here.
```
Here, `my-base-section` is the generated id of the heading element. In case it's not easily guessable, you can open the part file in your browser and click on the heading anchor (`#` symbol left to the heading when hovered) to see the id in the URL bar. Or use browser dev tools to inspect the element. Alternatively, you can also specify the id to the part file like this:
```md
## My Base Section {#custom-id}
```
and include it like this:
```md
<!--@include: ./parts/basics.md#custom-id-->
```
## Math Equations ## Math Equations
This is currently opt-in. To enable it, you need to install `markdown-it-mathjax3` and set `markdown.math` to `true` in your config file: This is currently opt-in. To enable it, you need to install `markdown-it-mathjax3` and set `markdown.math` to `true` in your config file:

@ -288,4 +288,4 @@ Vue provides IntelliSense support out of the box via the [Vue - Official VS Code
"vue.server.includeLanguages": ["vue", "markdown"] "vue.server.includeLanguages": ["vue", "markdown"]
} }
``` ```
::: :::

@ -286,4 +286,4 @@ Vue ofrece soporte para IntelliSense de forma predeterminada mediante el [Plugin
"vue.server.includeLanguages": ["vue", "markdown"] "vue.server.includeLanguages": ["vue", "markdown"]
} }
``` ```
::: :::

@ -211,7 +211,6 @@ const { hasSidebar } = useSidebar()
</script> </script>
<template> <template>
<div v-if="hasSidebar">Sólo visible cuando existe la barra lateral <div v-if="hasSidebar">Sólo visible cuando existe la barra lateral</div>
</div>
</template> </template>
``` ```

@ -60,4 +60,4 @@ const { theme } = useData()
<template> <template>
<img :src="withBase(theme.logoPath)" /> <img :src="withBase(theme.logoPath)" />
</template> </template>
``` ```

@ -45,4 +45,4 @@ editLink: true
"editLink": true "editLink": true
} }
--- ---
``` ```

@ -20,4 +20,4 @@
## پیکربندی Frontmatter ## پیکربندی Frontmatter
- گزینه `home: true` به `layout: home` تغییر کرده است. همچنین، تنظیمات مربوط به صفحه اصلی بسیار تغییر کرده‌اند تا ویژگی‌های اضافی را ارائه دهند. برای جزئیات بیشتر، [راهنمای صفحه اصلی](../reference/default-theme-home-page) را ببینید. - گزینه `home: true` به `layout: home` تغییر کرده است. همچنین، تنظیمات مربوط به صفحه اصلی بسیار تغییر کرده‌اند تا ویژگی‌های اضافی را ارائه دهند. برای جزئیات بیشتر، [راهنمای صفحه اصلی](../reference/default-theme-home-page) را ببینید.
- گزینه `footer` به [`themeConfig.footer`](../reference/default-theme-config#footer) منتقل شده است. - گزینه `footer` به [`themeConfig.footer`](../reference/default-theme-config#footer) منتقل شده است.

@ -27,4 +27,4 @@
--- ---
ادامه دارد... ادامه دارد...

@ -20,4 +20,4 @@ document.querySelector('h1').addEventListener('click', () => {
`<script client>` یک ویژگی تنها برای ویت‌پرس است، نه یک ویژگی Vue. این در هر دو فایل `.md` و `.vue` کار می‌کند، اما فقط در حالت MPA. اسکریپت‌های کلاینت در تمام اجزای تم با هم بسته می‌شوند، در حالی که اسکریپت کلاینت برای یک صفحه خاص، فقط برای آن صفحه تقسیم می‌شود. `<script client>` یک ویژگی تنها برای ویت‌پرس است، نه یک ویژگی Vue. این در هر دو فایل `.md` و `.vue` کار می‌کند، اما فقط در حالت MPA. اسکریپت‌های کلاینت در تمام اجزای تم با هم بسته می‌شوند، در حالی که اسکریپت کلاینت برای یک صفحه خاص، فقط برای آن صفحه تقسیم می‌شود.
توجه داشته باشید که `<script client>` به عنوان **کد مؤلفه مؤلفه Vue** ارزیابی نمی‌شود: به عنوان یک ماژول جاوااسکریپت معمولی پردازش می‌شود. به همین دلیل، حالت MPA فقط باید در صورتی استفاده شود که سایت شما به تعامل کمینه‌ای از جانب کلاینت نیاز دارد. توجه داشته باشید که `<script client>` به عنوان **کد مؤلفه مؤلفه Vue** ارزیابی نمی‌شود: به عنوان یک ماژول جاوااسکریپت معمولی پردازش می‌شود. به همین دلیل، حالت MPA فقط باید در صورتی استفاده شود که سایت شما به تعامل کمینه‌ای از جانب کلاینت نیاز دارد.

@ -55,4 +55,4 @@ export default {
} }
} }
} }
``` ```

@ -254,4 +254,4 @@ import ComponentInHeader from '../../components/ComponentInHeader.vue'
border-radius: 8px; border-radius: 8px;
padding: 0 20px; padding: 0 20px;
} }
</style> </style>

@ -54,4 +54,4 @@
تفاوت API بین ویت‌پرس و VuePress عمدتاً در زمینه تم‌سازی و سفارشی‌سازی است. اگر از VuePress 1 با تم پیش‌فرض استفاده می‌کنید، باید مهاجرت به ویت‌پرس نسبتاً ساده باشد. تفاوت API بین ویت‌پرس و VuePress عمدتاً در زمینه تم‌سازی و سفارشی‌سازی است. اگر از VuePress 1 با تم پیش‌فرض استفاده می‌کنید، باید مهاجرت به ویت‌پرس نسبتاً ساده باشد.
همچنین تلاش‌هایی برای VuePress 2 انجام شده است، که از Vue 3 و Vite با سازگاری بیشتر با VuePress 1 پشتیبانی می‌کند. با این حال، نگهداری دو SSG به صورت موازی پایدار نیست، بنابراین تیم Vue تصمیم گرفته است که در دراز مدت بر روی ویت‌پرس به عنوان SSG اصلی توصیه شده تمرکز کند. همچنین تلاش‌هایی برای VuePress 2 انجام شده است، که از Vue 3 و Vite با سازگاری بیشتر با VuePress 1 پشتیبانی می‌کند. با این حال، نگهداری دو SSG به صورت موازی پایدار نیست، بنابراین تیم Vue تصمیم گرفته است که در دراز مدت بر روی ویت‌پرس به عنوان SSG اصلی توصیه شده تمرکز کند.

@ -69,4 +69,4 @@ interface Props {
// پیش‌فرض به `tip`. // پیش‌فرض به `tip`.
type?: 'info' | 'tip' | 'warning' | 'danger' type?: 'info' | 'tip' | 'warning' | 'danger'
} }
``` ```

@ -19,4 +19,4 @@ export default {
`//cdn.carbonads.com/carbon.js?serve=${code}&placement=${placement}` `//cdn.carbonads.com/carbon.js?serve=${code}&placement=${placement}`
``` ```
برای یادگیری بیشتر درباره پیکربندی تبلیغات Carbon، لطفاً به [وب‌سایت Carbon Ads](https://www.carbonads.net/) مراجعه کنید. برای یادگیری بیشتر درباره پیکربندی تبلیغات Carbon، لطفاً به [وب‌سایت Carbon Ads](https://www.carbonads.net/) مراجعه کنید.

@ -466,4 +466,4 @@ export interface DocFooter {
- نوع: `boolean` - نوع: `boolean`
- پیش‌فرض: `false` - پیش‌فرض: `false`
آیا باید نمایش آیکون لینک خارجی کنار لینک‌های خارجی در مارک‌داون باشد. آیا باید نمایش آیکون لینک خارجی کنار لینک‌های خارجی در مارک‌داون باشد.

@ -50,4 +50,4 @@ export default {
--- ---
footer: false footer: false
--- ---
``` ```

@ -190,4 +190,4 @@ npx vitepress init
::: info اطلاعات ::: info اطلاعات
ویت‌پرس همیشه استایل اضافی محتوای صفحه `layout: home` را خودکار نمی‌کند. برای بازگشت به رفتار قدیمی، می‌توانید `markdownStyles: false` را به frontmatter اضافه کنید. ویت‌پرس همیشه استایل اضافی محتوای صفحه `layout: home` را خودکار نمی‌کند. برای بازگشت به رفتار قدیمی، می‌توانید `markdownStyles: false` را به frontmatter اضافه کنید.
::: :::

@ -24,4 +24,4 @@ lastUpdated: false
--- ---
``` ```
همچنین به [پیکربندی پیش‌فرض: آخرین بروزرسانی](./default-theme-config#lastupdated) مراجعه کنید تا اطلاعات بیشتری دریافت کنید. هر مقدار حقیقی در سطح تم از ویژگی را فعال خواهد کرد مگر آنکه به صورت صریح در سطح سایت یا صفحه غیرفعال شود. همچنین به [پیکربندی پیش‌فرض: آخرین بروزرسانی](./default-theme-config#lastupdated) مراجعه کنید تا اطلاعات بیشتری دریافت کنید. هر مقدار حقیقی در سطح تم از ویژگی را فعال خواهد کرد مگر آنکه به صورت صریح در سطح سایت یا صفحه غیرفعال شود.

@ -59,4 +59,4 @@ export default {
app.component('foo', Foo) app.component('foo', Foo)
} }
} }
``` ```

@ -212,4 +212,4 @@ const { hasSidebar } = useSidebar()
مایش داده شود زمانی که نوار کناری وجود دارد</div> مایش داده شود زمانی که نوار کناری وجود دارد</div>
</template> </template>
``` ```

@ -165,4 +165,4 @@ title: سلام
```md ```md
- نام بسته: {{ $params.pkg }} - نام بسته: {{ $params.pkg }}
- نسخه: {{ $params.version }} - نسخه: {{ $params.version }}
``` ```

@ -723,4 +723,4 @@ export default {
interface TransformPageContext { interface TransformPageContext {
siteConfig: SiteConfig siteConfig: SiteConfig
} }
``` ```

@ -15,6 +15,6 @@
"open-cli": "^8.0.0", "open-cli": "^8.0.0",
"postcss-rtlcss": "^5.6.0", "postcss-rtlcss": "^5.6.0",
"vitepress": "workspace:*", "vitepress": "workspace:*",
"vitepress-plugin-group-icons": "^1.3.5" "vitepress-plugin-group-icons": "^1.3.6"
} }
} }

@ -27,7 +27,7 @@ export default defineConfig({
ru: { ru: {
label: 'Русский', label: 'Русский',
lang: 'ru', // необязательный, будет добавлен как атрибут `lang` в тег `html` lang: 'ru', // необязательный, будет добавлен как атрибут `lang` в тег `html`
link: '/ru/guide' // по умолчанию /ru/ -- отображается в меню переводов на панели навигации, может быть внешним link: '/ru/guide' // по умолчанию /ru/ -- ссылка в меню переводов на панели навигации, может быть внешней
// другие свойства, специфичные для локали... // другие свойства, специфичные для локали...
} }

@ -844,7 +844,7 @@ export default config
Может быть создана с помощью `.foorc.json`. Может быть создана с помощью `.foorc.json`.
``` ```
**Эквивалентный код** **Соответствующий код**
```md ```md
# Документация # Документация
@ -883,7 +883,7 @@ export default config
<!-- #endregion basic-usage --> <!-- #endregion basic-usage -->
``` ```
**Эквивалентный код** **Соответствующий код**
```md ```md
# Документация # Документация
@ -899,6 +899,53 @@ export default config
Обратите внимание, что это не приводит к ошибкам, если ваш файл отсутствует. Поэтому при использовании этой функции убедитесь, что содержимое отображается так, как ожидается. Обратите внимание, что это не приводит к ошибкам, если ваш файл отсутствует. Поэтому при использовании этой функции убедитесь, что содержимое отображается так, как ожидается.
::: :::
Вместо регионов VS Code вы также можете использовать якоря заголовков, чтобы включить определённый раздел файла. Например, если у вас есть заголовок в вашем markdown-файле, например:
```md
## Мой основной раздел
Какой-то контент здесь.
### Мой подраздел
Ещё немного контента здесь.
## Другой раздел
Контент вне `Моего основного раздела`.
```
Вы можете включить раздел `Мой основной раздел` следующим образом:
```md
## Мой дополнительный раздел
<!--@include: ./parts/basics.md#мои-основнои-раздел-->
```
**Соответствующий код**
```md
## Мой дополнительный раздел
Какой-то контент здесь.
### Мой подраздел
Ещё немного контента здесь.
```
Здесь `мои-основнои-раздел` — это сгенерированный идентификатор элемента заголовка. Если его нелегко угадать, вы можете открыть файл в браузере и нажать на якорь заголовка (символ `#` слева от заголовка при наведении), чтобы увидеть идентификатор в адресной строке. Или используйте инструменты разработчика браузера для проверки элемента. Кроме того, вы также можете указать идентификатор для файла части следующим образом:
```md
## Мой основной раздел {#custom-id}
```
и включить его следующим образом:
```md
<!--@include: ./parts/basics.md#custom-id-->
```
## Математические уравнения {#math-equations} ## Математические уравнения {#math-equations}
В настоящее время эта фича предоставляется по желанию. Чтобы включить её, вам нужно установить `markdown-it-mathjax3` и установить значение `true` для опции `markdown.math` в вашем файле конфигурации: В настоящее время эта фича предоставляется по желанию. Чтобы включить её, вам нужно установить `markdown-it-mathjax3` и установить значение `true` для опции `markdown.math` в вашем файле конфигурации:

@ -320,4 +320,4 @@ Vue предоставляет поддержку IntelliSense из коробк
"vue.server.includeLanguages": ["vue", "markdown"] "vue.server.includeLanguages": ["vue", "markdown"]
} }
``` ```
::: :::

@ -1,16 +1,30 @@
┌ Welcome to VitePress! ┌ Welcome to VitePress!
│ │
◇ Where should VitePress initialize the config? ◇ Where should VitePress initialize the config?
│ ./docs │ ./docs
│ │
◇ Site title: ◇ Where should VitePress look for your markdown files?
│ My Awesome Project │ ./docs
│ │
◇ Site description: ◇ Site title:
│ A VitePress Site │ My Awesome Project
│ │
◆ Theme: ◇ Site description:
│ ● Default Theme (Out of the box, good-looking docs) │ A VitePress Site
│ ○ Default Theme + Customization │
│ ○ Custom Theme ◇ Theme:
└ │ Default Theme
│
◇ Use TypeScript for config and theme files?
│ Yes
│
◇ Add VitePress npm scripts to package.json?
│ Yes
│
◇ Add a prefix for VitePress npm scripts?
│ Yes
│
◇ Prefix for VitePress npm scripts:
│ docs
│
└ Done! Now run pnpm run docs:dev and start writing.

@ -1,5 +1,5 @@
[build.environment] [build.environment]
NODE_VERSION = "20" NODE_VERSION = "22"
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1" PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"
[build] [build]

@ -1,6 +1,6 @@
{ {
"name": "vitepress", "name": "vitepress",
"version": "2.0.0-alpha.2", "version": "2.0.0-alpha.4",
"description": "Vite & Vue powered static site generator", "description": "Vite & Vue powered static site generator",
"keywords": [ "keywords": [
"vite", "vite",
@ -95,22 +95,22 @@
"*": "prettier --write --ignore-unknown" "*": "prettier --write --ignore-unknown"
}, },
"dependencies": { "dependencies": {
"@docsearch/css": "^3.8.3", "@docsearch/css": "^3.9.0",
"@docsearch/js": "^3.8.3", "@docsearch/js": "^3.9.0",
"@iconify-json/simple-icons": "^1.2.24", "@iconify-json/simple-icons": "^1.2.27",
"@shikijs/core": "^2.3.2", "@shikijs/core": "^3.1.0",
"@shikijs/transformers": "^2.3.2", "@shikijs/transformers": "^3.1.0",
"@shikijs/types": "^2.3.2", "@shikijs/types": "^3.1.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/devtools-api": "^7.7.1", "@vue/devtools-api": "^7.7.2",
"@vue/shared": "^3.5.13", "@vue/shared": "^3.5.13",
"@vueuse/core": "^12.5.0", "@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^12.5.0", "@vueuse/integrations": "^12.8.2",
"focus-trap": "^7.6.4", "focus-trap": "^7.6.4",
"mark.js": "8.11.1", "mark.js": "8.11.1",
"minisearch": "^7.1.1", "minisearch": "^7.1.2",
"shiki": "^2.3.2", "shiki": "^3.1.0",
"vite": "^6.1.0", "vite": "^6.2.1",
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
"devDependencies": { "devDependencies": {
@ -125,7 +125,7 @@
"@mdit-vue/shared": "^2.1.3", "@mdit-vue/shared": "^2.1.3",
"@polka/compression": "^1.0.0-next.28", "@polka/compression": "^1.0.0-next.28",
"@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-alias": "^5.1.1",
"@rollup/plugin-commonjs": "^28.0.2", "@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-json": "^6.1.0", "@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.2",
@ -139,7 +139,7 @@
"@types/markdown-it-container": "^2.0.10", "@types/markdown-it-container": "^2.0.10",
"@types/markdown-it-emoji": "^3.0.1", "@types/markdown-it-emoji": "^3.0.1",
"@types/minimist": "^1.2.5", "@types/minimist": "^1.2.5",
"@types/node": "^22.13.1", "@types/node": "^22.13.9",
"@types/picomatch": "^3.0.2", "@types/picomatch": "^3.0.2",
"@types/postcss-prefix-selector": "^1.16.3", "@types/postcss-prefix-selector": "^1.16.3",
"@types/prompts": "^2.4.9", "@types/prompts": "^2.4.9",
@ -157,37 +157,37 @@
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0", "markdown-it-anchor": "^9.2.0",
"markdown-it-async": "^2.0.0", "markdown-it-async": "^2.2.0",
"markdown-it-attrs": "^4.3.1", "markdown-it-attrs": "^4.3.1",
"markdown-it-container": "^4.0.0", "markdown-it-container": "^4.0.0",
"markdown-it-emoji": "^3.0.0", "markdown-it-emoji": "^3.0.0",
"markdown-it-mathjax3": "^4.3.2", "markdown-it-mathjax3": "^4.3.2",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"nanoid": "^5.0.9", "nanoid": "^5.1.3",
"ora": "^8.2.0", "ora": "^8.2.0",
"p-map": "^7.0.3", "p-map": "^7.0.3",
"path-to-regexp": "^6.3.0", "path-to-regexp": "^6.3.0",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"picomatch": "^4.0.2", "picomatch": "^4.0.2",
"pkg-dir": "^8.0.0", "pkg-dir": "^8.0.0",
"playwright-chromium": "^1.50.1", "playwright-chromium": "^1.51.0",
"polka": "^1.0.0-next.28", "polka": "^1.0.0-next.28",
"postcss-prefix-selector": "^2.1.0", "postcss-prefix-selector": "^2.1.0",
"prettier": "^3.5.0", "prettier": "^3.5.3",
"prompts": "^2.4.2", "prompts": "^2.4.2",
"punycode": "^2.3.1", "punycode": "^2.3.1",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"rollup": "^4.34.6", "rollup": "^4.34.9",
"rollup-plugin-dts": "^6.1.1", "rollup-plugin-dts": "^6.1.1",
"rollup-plugin-esbuild": "^6.2.0", "rollup-plugin-esbuild": "^6.2.1",
"semver": "^7.7.1", "semver": "^7.7.1",
"simple-git-hooks": "^2.11.1", "simple-git-hooks": "^2.11.1",
"sirv": "^3.0.0", "sirv": "^3.0.1",
"sitemap": "^8.0.0", "sitemap": "^8.0.0",
"tinyglobby": "^0.2.10", "tinyglobby": "^0.2.12",
"typescript": "^5.7.3", "typescript": "^5.8.2",
"vitest": "^3.0.5", "vitest": "^3.0.8",
"vue-tsc": "^2.2.0", "vue-tsc": "^2.2.8",
"wait-on": "^8.0.2" "wait-on": "^8.0.2"
}, },
"peerDependencies": { "peerDependencies": {
@ -217,6 +217,11 @@
"patchedDependencies": { "patchedDependencies": {
"@types/mdurl@2.0.0": "patches/@types__mdurl@2.0.0.patch", "@types/mdurl@2.0.0": "patches/@types__mdurl@2.0.0.patch",
"markdown-it-anchor@9.2.0": "patches/markdown-it-anchor@9.2.0.patch" "markdown-it-anchor@9.2.0": "patches/markdown-it-anchor@9.2.0.patch"
} },
"onlyBuiltDependencies": [
"esbuild",
"playwright-chromium",
"simple-git-hooks"
]
} }
} }

File diff suppressed because it is too large Load Diff

@ -62,7 +62,7 @@ export const siteDataRef: Ref<SiteData> = shallowRef(
// hmr // hmr
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.accept('/@siteData', (m) => { import.meta.hot.accept('@siteData', (m) => {
if (m) { if (m) {
siteDataRef.value = m.default siteDataRef.value = m.default
} }

@ -325,12 +325,12 @@ async function changeRoute(
{ smoothScroll = false, initialLoad = false } = {} { smoothScroll = false, initialLoad = false } = {}
): Promise<boolean> { ): Promise<boolean> {
const loc = normalizeHref(location.href) const loc = normalizeHref(location.href)
const { pathname, hash } = new URL(href, fakeHost) const nextUrl = new URL(href, location.origin)
const currentLoc = new URL(loc, fakeHost) const currentUrl = new URL(loc, location.origin)
if (href === loc) { if (href === loc) {
if (!initialLoad) { if (!initialLoad) {
scrollTo(hash, smoothScroll) scrollTo(nextUrl.hash, smoothScroll)
return false return false
} }
} else { } else {
@ -338,16 +338,16 @@ async function changeRoute(
history.replaceState({ scrollPosition: window.scrollY }, '') history.replaceState({ scrollPosition: window.scrollY }, '')
history.pushState({}, '', href) history.pushState({}, '', href)
if (pathname === currentLoc.pathname) { if (nextUrl.pathname === currentUrl.pathname) {
// scroll between hash anchors on the same page, avoid duplicate entries // scroll between hash anchors on the same page, avoid duplicate entries
if (hash !== currentLoc.hash) { if (nextUrl.hash !== currentUrl.hash) {
window.dispatchEvent( window.dispatchEvent(
new HashChangeEvent('hashchange', { new HashChangeEvent('hashchange', {
oldURL: currentLoc.href, oldURL: currentUrl.href,
newURL: href newURL: nextUrl.href
}) })
) )
scrollTo(hash, smoothScroll) scrollTo(nextUrl.hash, smoothScroll)
} }
return false return false

@ -46,7 +46,7 @@ const searchIndexData = shallowRef(localSearchIndex)
// hmr // hmr
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.accept('/@localSearchIndex', (m) => { import.meta.hot.accept('@localSearchIndex', (m) => {
if (m) { if (m) {
searchIndexData.value = m.default searchIndexData.value = m.default
} }

@ -340,10 +340,10 @@
--vp-code-block-bg: var(--vp-c-bg-alt); --vp-code-block-bg: var(--vp-c-bg-alt);
--vp-code-block-divider-color: var(--vp-c-gutter); --vp-code-block-divider-color: var(--vp-c-gutter);
--vp-code-lang-color: var(--vp-c-text-3); --vp-code-lang-color: var(--vp-c-text-2);
--vp-code-line-highlight-color: var(--vp-c-default-soft); --vp-code-line-highlight-color: var(--vp-c-default-soft);
--vp-code-line-number-color: var(--vp-c-text-3); --vp-code-line-number-color: var(--vp-c-text-2);
--vp-code-line-diff-add-color: var(--vp-c-success-soft); --vp-code-line-diff-add-color: var(--vp-c-success-soft);
--vp-code-line-diff-add-symbol-color: var(--vp-c-success-1); --vp-code-line-diff-add-symbol-color: var(--vp-c-success-1);

@ -97,20 +97,12 @@ export async function resolveConfig(
? userThemeDir ? userThemeDir
: DEFAULT_THEME_PATH : DEFAULT_THEME_PATH
const { pages, dynamicRoutes, rewrites } = await resolvePages(
srcDir,
userConfig,
logger
)
const config: SiteConfig = { const config: SiteConfig = {
root, root,
srcDir, srcDir,
assetsDir, assetsDir,
site, site,
themeDir, themeDir,
pages,
dynamicRoutes,
configPath, configPath,
configDeps, configDeps,
outDir, outDir,
@ -135,10 +127,10 @@ export async function resolveConfig(
transformHead: userConfig.transformHead, transformHead: userConfig.transformHead,
transformHtml: userConfig.transformHtml, transformHtml: userConfig.transformHtml,
transformPageData: userConfig.transformPageData, transformPageData: userConfig.transformPageData,
rewrites,
userConfig, userConfig,
sitemap: userConfig.sitemap, sitemap: userConfig.sitemap,
buildConcurrency: userConfig.buildConcurrency ?? 64 buildConcurrency: userConfig.buildConcurrency ?? 64,
...(await resolvePages(srcDir, userConfig, logger, true))
} }
// to be shared with content loaders // to be shared with content loaders

@ -5,6 +5,11 @@ export * from './contentLoader'
export * from './init/init' export * from './init/init'
export * from './markdown/markdown' export * from './markdown/markdown'
export { defineLoader, type LoaderModule } from './plugins/staticDataPlugin' export { defineLoader, type LoaderModule } from './plugins/staticDataPlugin'
export {
defineRoutes,
type ResolvedRouteConfig,
type RouteModule
} from './plugins/dynamicRoutesPlugin'
export * from './postcss/isolateStyles' export * from './postcss/isolateStyles'
export * from './serve/serve' export * from './serve/serve'
export * from './server' export * from './server'

@ -11,7 +11,8 @@ import fs from 'fs-extra'
import template from 'lodash.template' import template from 'lodash.template'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { bold, cyan, yellow } from 'picocolors' import c from 'picocolors'
import { slash } from '../shared'
export enum ScaffoldThemeType { export enum ScaffoldThemeType {
Default = 'default theme', Default = 'default theme',
@ -20,12 +21,15 @@ export enum ScaffoldThemeType {
} }
export interface ScaffoldOptions { export interface ScaffoldOptions {
root: string root?: string
srcDir?: string
title?: string title?: string
description?: string description?: string
theme: ScaffoldThemeType theme?: ScaffoldThemeType
useTs: boolean useTs?: boolean
injectNpmScripts: boolean injectNpmScripts?: boolean
addNpmScriptsPrefix?: boolean
npmScriptsPrefix?: string
} }
const getPackageManger = () => { const getPackageManger = () => {
@ -33,10 +37,10 @@ const getPackageManger = () => {
return name.split('/')[0] return name.split('/')[0]
} }
export async function init(root: string | undefined) { export async function init(root?: string) {
intro(bold(cyan('Welcome to VitePress!'))) intro(c.bold(c.cyan('Welcome to VitePress!')))
const options: ScaffoldOptions = await group( const options = await group(
{ {
root: async () => { root: async () => {
if (root) return root if (root) return root
@ -44,6 +48,7 @@ export async function init(root: string | undefined) {
return text({ return text({
message: 'Where should VitePress initialize the config?', message: 'Where should VitePress initialize the config?',
initialValue: './', initialValue: './',
defaultValue: './',
validate(value) { validate(value) {
// TODO make sure directory is inside // TODO make sure directory is inside
return undefined return undefined
@ -51,50 +56,82 @@ export async function init(root: string | undefined) {
}) })
}, },
title: () => srcDir: async ({ results }: any) => {
text({ return text({
message: 'Where should VitePress look for your markdown files?',
initialValue: results.root,
defaultValue: results.root
})
},
title: async () => {
return text({
message: 'Site title:', message: 'Site title:',
placeholder: 'My Awesome Project' placeholder: 'My Awesome Project',
}), defaultValue: 'My Awesome Project'
})
},
description: () => description: async () => {
text({ return text({
message: 'Site description:', message: 'Site description:',
placeholder: 'A VitePress Site' placeholder: 'A VitePress Site',
}), defaultValue: 'A VitePress Site'
})
},
theme: () => theme: async () => {
select({ return select({
message: 'Theme:', message: 'Theme:',
options: [ options: [
{ {
// @ts-ignore
value: ScaffoldThemeType.Default, value: ScaffoldThemeType.Default,
label: 'Default Theme', label: 'Default Theme',
hint: 'Out of the box, good-looking docs' hint: 'Out of the box, good-looking docs'
}, },
{ {
// @ts-ignore
value: ScaffoldThemeType.DefaultCustom, value: ScaffoldThemeType.DefaultCustom,
label: 'Default Theme + Customization', label: 'Default Theme + Customization',
hint: 'Add custom CSS and layout slots' hint: 'Add custom CSS and layout slots'
}, },
{ {
// @ts-ignore
value: ScaffoldThemeType.Custom, value: ScaffoldThemeType.Custom,
label: 'Custom Theme', label: 'Custom Theme',
hint: 'Build your own or use external' hint: 'Build your own or use external'
} }
] ]
}), })
},
useTs: () => useTs: async () => {
confirm({ message: 'Use TypeScript for config and theme files?' }), return confirm({
message: 'Use TypeScript for config and theme files?'
})
},
injectNpmScripts: () => injectNpmScripts: async () => {
confirm({ return confirm({
message: 'Add VitePress npm scripts to package.json?' message: 'Add VitePress npm scripts to package.json?'
}) })
},
addNpmScriptsPrefix: async ({ results }: any) => {
if (!results.injectNpmScripts) return false
return confirm({
message: 'Add a prefix for VitePress npm scripts?'
})
},
npmScriptsPrefix: async ({ results }: any) => {
if (!results.addNpmScriptsPrefix) return 'docs'
return text({
message: 'Prefix for VitePress npm scripts:',
placeholder: 'docs',
defaultValue: 'docs'
})
}
}, },
{ {
onCancel: () => { onCancel: () => {
@ -108,20 +145,29 @@ export async function init(root: string | undefined) {
} }
export function scaffold({ export function scaffold({
root = './', root: root_ = './',
srcDir: srcDir_ = root_,
title = 'My Awesome Project', title = 'My Awesome Project',
description = 'A VitePress Site', description = 'A VitePress Site',
theme, theme = ScaffoldThemeType.Default,
useTs, useTs = true,
injectNpmScripts injectNpmScripts = true,
}: ScaffoldOptions): string { addNpmScriptsPrefix = true,
const resolvedRoot = path.resolve(root) npmScriptsPrefix = 'docs'
}: ScaffoldOptions) {
const resolvedRoot = path.resolve(root_)
const root = path.relative(process.cwd(), resolvedRoot)
const resolvedSrcDir = path.resolve(srcDir_)
const srcDir = path.relative(resolvedRoot, resolvedSrcDir)
const templateDir = path.resolve( const templateDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
'../../template' '../../template'
) )
const data = { const data = {
srcDir: srcDir ? JSON.stringify(srcDir) : undefined, // omit if default
title: JSON.stringify(title), title: JSON.stringify(title),
description: JSON.stringify(description), description: JSON.stringify(description),
useTs, useTs,
@ -140,14 +186,20 @@ export function scaffold({
const renderFile = (file: string) => { const renderFile = (file: string) => {
const filePath = path.resolve(templateDir, file) const filePath = path.resolve(templateDir, file)
let targetPath = path.resolve(resolvedRoot, file) let targetPath = path.resolve(resolvedRoot, file)
if (useMjs && file === '.vitepress/config.js') { if (useMjs && file === '.vitepress/config.js') {
targetPath = targetPath.replace(/\.js$/, '.mjs') targetPath = targetPath.replace(/\.js$/, '.mjs')
} }
if (useTs) { if (useTs) {
targetPath = targetPath.replace(/\.(m?)js$/, '.$1ts') targetPath = targetPath.replace(/\.(m?)js$/, '.$1ts')
} }
const src = fs.readFileSync(filePath, 'utf-8') if (file.endsWith('.md')) {
const compiled = template(src)(data) targetPath = path.resolve(resolvedSrcDir, file)
}
const content = fs.readFileSync(filePath, 'utf-8')
const compiled = template(content)(data)
fs.outputFileSync(targetPath, compiled) fs.outputFileSync(targetPath, compiled)
} }
@ -175,46 +227,42 @@ export function scaffold({
renderFile(file) renderFile(file)
} }
const dir =
root === './' ? '' : ` ${root.replace(/^\.\//, '').replace(/[/\\]$/, '')}`
const gitignorePrefix = dir ? `${dir}/.vitepress` : '.vitepress'
const tips = [] const tips = []
const gitignorePrefix = root ? `${slash(root)}/.vitepress` : '.vitepress'
if (fs.existsSync('.git')) { if (fs.existsSync('.git')) {
tips.push( tips.push(
`Make sure to add ${cyan(`${gitignorePrefix}/dist`)} and ` + `Make sure to add ${c.cyan(`${gitignorePrefix}/dist`)} and ${c.cyan(`${gitignorePrefix}/cache`)} to your ${c.cyan(`.gitignore`)} file.`
`${cyan(`${gitignorePrefix}/cache`)} to your ` +
`${cyan(`.gitignore`)} file.`
) )
} }
if ( if (
theme !== ScaffoldThemeType.Default && theme !== ScaffoldThemeType.Default &&
!userPkg.dependencies?.['vue'] && !userPkg.dependencies?.['vue'] &&
!userPkg.devDependencies?.['vue'] !userPkg.devDependencies?.['vue']
) { ) {
tips.push( tips.push(
`Since you've chosen to customize the theme, ` + `Since you've chosen to customize the theme, you should also explicitly install ${c.cyan(`vue`)} as a dev dependency.`
`you should also explicitly install ${cyan(`vue`)} as a dev dependency.`
) )
} }
const tip = tips.length ? yellow([`\n\nTips:`, ...tips].join('\n- ')) : `` const tip = tips.length ? c.yellow([`\n\nTips:`, ...tips].join('\n- ')) : ``
const dir = root ? ' ' + root : ''
const pm = getPackageManger()
if (injectNpmScripts) { if (injectNpmScripts) {
const scripts = { const scripts: Record<string, string> = {}
'docs:dev': `vitepress dev${dir}`, const prefix = addNpmScriptsPrefix ? `${npmScriptsPrefix}:` : ''
'docs:build': `vitepress build${dir}`,
'docs:preview': `vitepress preview${dir}` scripts[`${prefix}dev`] = `vitepress dev${dir}`
} scripts[`${prefix}build`] = `vitepress build${dir}`
scripts[`${prefix}preview`] = `vitepress preview${dir}`
Object.assign(userPkg.scripts || (userPkg.scripts = {}), scripts) Object.assign(userPkg.scripts || (userPkg.scripts = {}), scripts)
fs.writeFileSync(pkgPath, JSON.stringify(userPkg, null, 2)) fs.writeFileSync(pkgPath, JSON.stringify(userPkg, null, 2))
return `Done! Now run ${cyan(
`${getPackageManger()} run docs:dev` return `Done! Now run ${c.cyan(`${pm} run ${prefix}dev`)} and start writing.${tip}`
)} and start writing.${tip}`
} else { } else {
const pm = getPackageManger() return `You're all set! Now run ${c.cyan(`${pm === 'npm' ? 'npx' : pm} vitepress dev${dir}`)} and start writing.${tip}`
return `You're all set! Now run ${cyan(
`${pm === 'npm' ? 'npx' : pm} vitepress dev${dir}`
)} and start writing.${tip}`
} }
} }

@ -19,13 +19,13 @@ import type {
ShikiTransformer, ShikiTransformer,
ThemeRegistrationAny ThemeRegistrationAny
} from '@shikijs/types' } from '@shikijs/types'
import type { Options } from 'markdown-it'
import { MarkdownItAsync } from 'markdown-it-async'
import anchorPlugin from 'markdown-it-anchor' import anchorPlugin from 'markdown-it-anchor'
import { MarkdownItAsync, type Options } from 'markdown-it-async'
import attrsPlugin from 'markdown-it-attrs' import attrsPlugin from 'markdown-it-attrs'
import { full as emojiPlugin } from 'markdown-it-emoji' import { full as emojiPlugin } from 'markdown-it-emoji'
import type { BuiltinLanguage, BuiltinTheme, Highlighter } from 'shiki' import type { BuiltinLanguage, BuiltinTheme, Highlighter } from 'shiki'
import type { Logger } from 'vite' import type { Logger } from 'vite'
import type { Awaitable } from '../shared'
import { containerPlugin, type ContainerOptions } from './plugins/containers' import { containerPlugin, type ContainerOptions } from './plugins/containers'
import { gitHubAlertsPlugin } from './plugins/githubAlerts' import { gitHubAlertsPlugin } from './plugins/githubAlerts'
import { highlight as createHighlighter } from './plugins/highlight' import { highlight as createHighlighter } from './plugins/highlight'
@ -53,11 +53,11 @@ export interface MarkdownOptions extends Options {
/** /**
* Setup markdown-it instance before applying plugins * Setup markdown-it instance before applying plugins
*/ */
preConfig?: (md: MarkdownItAsync) => Awaited<void> preConfig?: (md: MarkdownItAsync) => Awaitable<void>
/** /**
* Setup markdown-it instance * Setup markdown-it instance
*/ */
config?: (md: MarkdownItAsync) => Awaited<void> config?: (md: MarkdownItAsync) => Awaitable<void>
/** /**
* Disable cache (experimental) * Disable cache (experimental)
*/ */
@ -246,8 +246,13 @@ export async function createMarkdownRenderer(
) )
.use(lineNumberPlugin, options.lineNumbers) .use(lineNumberPlugin, options.lineNumbers)
const tableOpen = md.renderer.rules.table_open
md.renderer.rules.table_open = function (tokens, idx, options, env, self) { md.renderer.rules.table_open = function (tokens, idx, options, env, self) {
return '<table tabindex="0">\n' const token = tokens[idx]
if (token.attrIndex('tabindex') < 0) token.attrPush(['tabindex', '0'])
return tableOpen
? tableOpen(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
} }
if (options.gfmAlerts !== false) { if (options.gfmAlerts !== false) {
@ -263,22 +268,37 @@ export async function createMarkdownRenderer(
// mdit-vue plugins // mdit-vue plugins
md.use(anchorPlugin, { md.use(anchorPlugin, {
slugify, slugify,
permalink: anchorPlugin.permalink.linkInsideHeader({ getTokensText: (tokens) => {
symbol: '&ZeroWidthSpace;', return tokens
renderAttrs: (slug, state) => { .filter((t) => !['html_inline', 'emoji'].includes(t.type))
// Find `heading_open` with the id identical to slug .map((t) => t.content)
const idx = state.tokens.findIndex((token) => { .join('')
const attrs = token.attrs },
const id = attrs?.find((attr) => attr[0] === 'id') permalink: (slug, _, state, idx) => {
return id && slug === id[1] const title =
}) state.tokens[idx + 1]?.children
// Get the actual heading content ?.filter((token) => ['text', 'code_inline'].includes(token.type))
const title = state.tokens[idx + 1].content .reduce((acc, t) => acc + t.content, '')
return { .trim() || ''
'aria-label': `Permalink to "${title}"`
} const linkTokens = [
} Object.assign(new state.Token('text', '', 0), { content: ' ' }),
}), Object.assign(new state.Token('link_open', 'a', 1), {
attrs: [
['class', 'header-anchor'],
['href', `#${slug}`],
['aria-label', `Permalink to “${title}`]
]
}),
Object.assign(new state.Token('html_inline', '', 0), {
content: '&#8203;',
meta: { isPermalinkSymbol: true }
}),
new state.Token('link_close', 'a', -1)
]
state.tokens[idx + 1].children?.push(...linkTokens)
},
...options.anchor ...options.anchor
} as anchorPlugin.AnchorOptions).use(frontmatterPlugin, { } as anchorPlugin.AnchorOptions).use(frontmatterPlugin, {
...options.frontmatter ...options.frontmatter

@ -1,10 +1,8 @@
import type MarkdownIt from 'markdown-it' import type { MarkdownItAsync } from 'markdown-it-async'
import container from 'markdown-it-container' import container from 'markdown-it-container'
import type { RenderRule } from 'markdown-it/lib/renderer.mjs' import type { RenderRule } from 'markdown-it/lib/renderer.mjs'
import type Token from 'markdown-it/lib/token.mjs' import type Token from 'markdown-it/lib/token.mjs'
import { nanoid } from 'nanoid'
import type { MarkdownEnv } from '../../shared' import type { MarkdownEnv } from '../../shared'
import { import {
extractTitle, extractTitle,
getAdaptiveThemeMarker, getAdaptiveThemeMarker,
@ -12,7 +10,7 @@ import {
} from './preWrapper' } from './preWrapper'
export const containerPlugin = ( export const containerPlugin = (
md: MarkdownIt, md: MarkdownItAsync,
options: Options, options: Options,
containerOptions?: ContainerOptions containerOptions?: ContainerOptions
) => { ) => {
@ -56,7 +54,7 @@ type ContainerArgs = [typeof container, string, { render: RenderRule }]
function createContainer( function createContainer(
klass: string, klass: string,
defaultTitle: string, defaultTitle: string,
md: MarkdownIt md: MarkdownItAsync
): ContainerArgs { ): ContainerArgs {
return [ return [
container, container,
@ -64,29 +62,29 @@ function createContainer(
{ {
render(tokens, idx, _options, env: MarkdownEnv & { references?: any }) { render(tokens, idx, _options, env: MarkdownEnv & { references?: any }) {
const token = tokens[idx] const token = tokens[idx]
const info = token.info.trim().slice(klass.length).trim()
const attrs = md.renderer.renderAttrs(token)
if (token.nesting === 1) { if (token.nesting === 1) {
token.attrJoin('class', `${klass} custom-block`)
const attrs = md.renderer.renderAttrs(token)
const info = token.info.trim().slice(klass.length).trim()
const title = md.renderInline(info || defaultTitle, { const title = md.renderInline(info || defaultTitle, {
references: env.references references: env.references
}) })
if (klass === 'details') if (klass === 'details')
return `<details class="${klass} custom-block"${attrs}><summary>${title}</summary>\n` return `<details ${attrs}><summary>${title}</summary>\n`
return `<div class="${klass} custom-block"${attrs}><p class="custom-block-title">${title}</p>\n` return `<div ${attrs}><p class="custom-block-title">${title}</p>\n`
} else return klass === 'details' ? `</details>\n` : `</div>\n` } else return klass === 'details' ? `</details>\n` : `</div>\n`
} }
} }
] ]
} }
function createCodeGroup(options: Options, md: MarkdownIt): ContainerArgs { function createCodeGroup(options: Options, md: MarkdownItAsync): ContainerArgs {
return [ return [
container, container,
'code-group', 'code-group',
{ {
render(tokens, idx) { render(tokens, idx) {
if (tokens[idx].nesting === 1) { if (tokens[idx].nesting === 1) {
const name = nanoid(5)
let tabs = '' let tabs = ''
let checked = 'checked' let checked = 'checked'
@ -110,8 +108,7 @@ function createCodeGroup(options: Options, md: MarkdownIt): ContainerArgs {
) )
if (title) { if (title) {
const id = nanoid(7) tabs += `<input type="radio" name="group-${idx}" id="tab-${i}" ${checked}><label data-title="${md.utils.escapeHtml(title)}" for="tab-${i}">${title}</label>`
tabs += `<input type="radio" name="group-${name}" id="tab-${id}" ${checked}><label data-title="${md.utils.escapeHtml(title)}" for="tab-${id}">${title}</label>`
if (checked && !isHtml) tokens[i].info += ' active' if (checked && !isHtml) tokens[i].info += ' active'
checked = '' checked = ''
@ -119,9 +116,7 @@ function createCodeGroup(options: Options, md: MarkdownIt): ContainerArgs {
} }
} }
return `<div class="vp-code-group${getAdaptiveThemeMarker( return `<div class="vp-code-group${getAdaptiveThemeMarker(options)}"><div class="tabs">${tabs}</div><div class="blocks">\n`
options
)}"><div class="tabs">${tabs}</div><div class="blocks">\n`
} }
return `</div></div>\n` return `</div></div>\n`
} }

@ -1,11 +1,11 @@
import type MarkdownIt from 'markdown-it' import type { MarkdownItAsync } from 'markdown-it-async'
import type { ContainerOptions } from './containers' import type { ContainerOptions } from './containers'
const markerRE = const markerRE =
/^\[!(TIP|NOTE|INFO|IMPORTANT|WARNING|CAUTION|DANGER)\]([^\n\r]*)/i /^\[!(TIP|NOTE|INFO|IMPORTANT|WARNING|CAUTION|DANGER)\]([^\n\r]*)/i
export const gitHubAlertsPlugin = ( export const gitHubAlertsPlugin = (
md: MarkdownIt, md: MarkdownItAsync,
options?: ContainerOptions options?: ContainerOptions
) => { ) => {
const titleMark = { const titleMark = {

@ -7,11 +7,11 @@ import {
type TransformerCompactLineOption type TransformerCompactLineOption
} from '@shikijs/transformers' } from '@shikijs/transformers'
import { customAlphabet } from 'nanoid' import { customAlphabet } from 'nanoid'
import type { LanguageRegistration, ShikiTransformer } from 'shiki' import c from 'picocolors'
import { createHighlighter, isSpecialLang } from 'shiki' import type { BundledLanguage, ShikiTransformer } from 'shiki'
import { createHighlighter, guessEmbeddedLanguages, isSpecialLang } from 'shiki'
import type { Logger } from 'vite' import type { Logger } from 'vite'
import type { MarkdownOptions, ThemeOptions } from '../markdown' import type { MarkdownOptions, ThemeOptions } from '../markdown'
import c from 'picocolors'
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10) const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)
@ -71,34 +71,16 @@ export async function highlight(
langAlias: options.languageAlias langAlias: options.languageAlias
}) })
async function loadLanguage(name: string | LanguageRegistration) {
const lang = typeof name === 'string' ? name : name.name
if (
!isSpecialLang(lang) &&
!highlighter.getLoadedLanguages().includes(lang)
) {
await highlighter.loadLanguage(lang as any)
}
}
await options?.shikiSetup?.(highlighter) await options?.shikiSetup?.(highlighter)
// TODO: remove explicit matchAlgorithm in shiki v3
const transformers: ShikiTransformer[] = [ const transformers: ShikiTransformer[] = [
transformerNotationDiff({ transformerNotationDiff(),
matchAlgorithm: 'v3'
}),
transformerNotationFocus({ transformerNotationFocus({
matchAlgorithm: 'v3',
classActiveLine: 'has-focus', classActiveLine: 'has-focus',
classActivePre: 'has-focused-lines' classActivePre: 'has-focused-lines'
}), }),
transformerNotationHighlight({ transformerNotationHighlight(),
matchAlgorithm: 'v3' transformerNotationErrorLevel(),
}),
transformerNotationErrorLevel({
matchAlgorithm: 'v3'
}),
{ {
name: 'vitepress:add-class', name: 'vitepress:add-class',
pre(node) { pre(node) {
@ -129,7 +111,13 @@ export async function highlight(
.toLowerCase() || defaultLang .toLowerCase() || defaultLang
try { try {
await loadLanguage(lang) // https://github.com/shikijs/shiki/issues/952
if (
!isSpecialLang(lang) &&
!highlighter.getLoadedLanguages().includes(lang)
) {
await highlighter.loadLanguage(lang as any)
}
} catch { } catch {
logger.warn( logger.warn(
c.yellow( c.yellow(
@ -163,6 +151,9 @@ export async function highlight(
str = removeMustache(str).trimEnd() str = removeMustache(str).trimEnd()
const embeddedLang = guessEmbeddedLanguages(str, lang, highlighter)
await highlighter.loadLanguage(...(embeddedLang as BundledLanguage[]))
const highlighted = highlighter.codeToHtml(str, { const highlighted = highlighter.codeToHtml(str, {
lang, lang,
transformers: [ transformers: [

@ -2,11 +2,11 @@
// Now this plugin is only used to normalize line attrs. // Now this plugin is only used to normalize line attrs.
// The else part of line highlights logic is in './highlight.ts'. // The else part of line highlights logic is in './highlight.ts'.
import type MarkdownIt from 'markdown-it' import type { MarkdownItAsync } from 'markdown-it-async'
const RE = /{([\d,-]+)}/ const RE = /{([\d,-]+)}/
export const highlightLinePlugin = (md: MarkdownIt) => { export const highlightLinePlugin = (md: MarkdownItAsync) => {
const fence = md.renderer.rules.fence! const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => { md.renderer.rules.fence = (...args) => {
const [tokens, idx] = args const [tokens, idx] = args

@ -1,6 +1,6 @@
// markdown-it plugin for normalizing image source // markdown-it plugin for normalizing image source
import type MarkdownIt from 'markdown-it' import type { MarkdownItAsync } from 'markdown-it-async'
import { EXTERNAL_URL_RE } from '../../shared' import { EXTERNAL_URL_RE } from '../../shared'
export interface Options { export interface Options {
@ -11,7 +11,10 @@ export interface Options {
lazyLoading?: boolean lazyLoading?: boolean
} }
export const imagePlugin = (md: MarkdownIt, { lazyLoading }: Options = {}) => { export const imagePlugin = (
md: MarkdownItAsync,
{ lazyLoading }: Options = {}
) => {
const imageRule = md.renderer.rules.image! const imageRule = md.renderer.rules.image!
md.renderer.rules.image = (tokens, idx, options, env, self) => { md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx] const token = tokens[idx]

@ -1,9 +1,9 @@
// markdown-it plugin for generating line numbers. // markdown-it plugin for generating line numbers.
// It depends on preWrapper plugin. // It depends on preWrapper plugin.
import type MarkdownIt from 'markdown-it' import type { MarkdownItAsync } from 'markdown-it-async'
export const lineNumberPlugin = (md: MarkdownIt, enable = false) => { export const lineNumberPlugin = (md: MarkdownItAsync, enable = false) => {
const fence = md.renderer.rules.fence! const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => { md.renderer.rules.fence = (...args) => {
const rawCode = fence(...args) const rawCode = fence(...args)

@ -2,7 +2,7 @@
// 1. adding target="_blank" to external links // 1. adding target="_blank" to external links
// 2. normalize internal links to end with `.html` // 2. normalize internal links to end with `.html`
import type MarkdownIt from 'markdown-it' import type { MarkdownItAsync } from 'markdown-it-async'
import { URL } from 'node:url' import { URL } from 'node:url'
import { import {
EXTERNAL_URL_RE, EXTERNAL_URL_RE,
@ -14,7 +14,7 @@ import {
const indexRE = /(^|.*\/)index.md(#?.*)$/i const indexRE = /(^|.*\/)index.md(#?.*)$/i
export const linkPlugin = ( export const linkPlugin = (
md: MarkdownIt, md: MarkdownItAsync,
externalAttrs: Record<string, string>, externalAttrs: Record<string, string>,
base: string base: string
) => { ) => {

@ -1,11 +1,11 @@
import type MarkdownIt from 'markdown-it' import type { MarkdownItAsync } from 'markdown-it-async'
export interface Options { export interface Options {
codeCopyButtonTitle: string codeCopyButtonTitle: string
hasSingleTheme: boolean hasSingleTheme: boolean
} }
export function preWrapperPlugin(md: MarkdownIt, options: Options) { export function preWrapperPlugin(md: MarkdownItAsync, options: Options) {
const fence = md.renderer.rules.fence! const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => { md.renderer.rules.fence = (...args) => {
const [tokens, idx] = args const [tokens, idx] = args

@ -1,9 +1,9 @@
import type MarkdownIt from 'markdown-it' import type { MarkdownItAsync } from 'markdown-it-async'
import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs' import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs'
import type Token from 'markdown-it/lib/token.mjs' import type Token from 'markdown-it/lib/token.mjs'
import { escapeHtml } from '../../shared' import { escapeHtml } from '../../shared'
export function restoreEntities(md: MarkdownIt): void { export function restoreEntities(md: MarkdownItAsync): void {
md.core.ruler.at('text_join', text_join) md.core.ruler.at('text_join', text_join)
md.renderer.rules.text = (tokens, idx) => escapeHtml(tokens[idx].content) md.renderer.rules.text = (tokens, idx) => escapeHtml(tokens[idx].content)
} }

@ -1,5 +1,5 @@
import fs from 'fs-extra' import fs from 'fs-extra'
import type MarkdownIt from 'markdown-it' import type { MarkdownItAsync } from 'markdown-it-async'
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs' import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
import path from 'node:path' import path from 'node:path'
import type { MarkdownEnv } from '../../shared' import type { MarkdownEnv } from '../../shared'
@ -51,54 +51,75 @@ export function dedent(text: string): string {
return text return text
} }
function testLine( const markers = [
line: string, {
regexp: RegExp, start: /^\s*\/\/\s*#?region\b\s*(.*?)\s*$/,
regionName: string, end: /^\s*\/\/\s*#?endregion\b\s*(.*?)\s*$/
end: boolean = false },
) { {
const [full, tag, name] = regexp.exec(line.trim()) || [] start: /^\s*<!--\s*#?region\b\s*(.*?)\s*-->/,
end: /^\s*<!--\s*#?endregion\b\s*(.*?)\s*-->/
return ( },
full && {
tag && start: /^\s*\/\*\s*#region\b\s*(.*?)\s*\*\//,
name === regionName && end: /^\s*\/\*\s*#endregion\b\s*(.*?)\s*\*\//
tag.match(end ? /^[Ee]nd ?[rR]egion$/ : /^[rR]egion$/) },
) {
} start: /^\s*#[rR]egion\b\s*(.*?)\s*$/,
end: /^\s*#[eE]nd ?[rR]egion\b\s*(.*?)\s*$/
},
{
start: /^\s*#\s*#?region\b\s*(.*?)\s*$/,
end: /^\s*#\s*#?endregion\b\s*(.*?)\s*$/
},
{
start: /^\s*(?:--|::|@?REM)\s*#region\b\s*(.*?)\s*$/,
end: /^\s*(?:--|::|@?REM)\s*#endregion\b\s*(.*?)\s*$/
},
{
start: /^\s*#pragma\s+region\b\s*(.*?)\s*$/,
end: /^\s*#pragma\s+endregion\b\s*(.*?)\s*$/
},
{
start: /^\s*\(\*\s*#region\b\s*(.*?)\s*\*\)/,
end: /^\s*\(\*\s*#endregion\b\s*(.*?)\s*\*\)/
}
]
export function findRegion(lines: Array<string>, regionName: string) { export function findRegion(lines: Array<string>, regionName: string) {
const regionRegexps = [ let chosen: { re: (typeof markers)[number]; start: number } | null = null
/^\/\/ ?#?((?:end)?region) ([\w*-]+)$/, // javascript, typescript, java // find the regex pair for a start marker that matches the given region name
/^\/\* ?#((?:end)?region) ([\w*-]+) ?\*\/$/, // css, less, scss for (let i = 0; i < lines.length; i++) {
/^#pragma ((?:end)?region) ([\w*-]+)$/, // C, C++ for (const re of markers) {
/^<!-- #?((?:end)?region) ([\w*-]+) -->$/, // HTML, markdown if (re.start.exec(lines[i])?.[1] === regionName) {
/^#((?:End )Region) ([\w*-]+)$/, // Visual Basic chosen = { re, start: i + 1 }
/^::#((?:end)region) ([\w*-]+)$/, // Bat break
/^# ?((?:end)?region) ([\w*-]+)$/ // C#, PHP, Powershell, Python, perl & misc
]
let regexp = null
let start = -1
for (const [lineId, line] of lines.entries()) {
if (regexp === null) {
for (const reg of regionRegexps) {
if (testLine(line, reg, regionName)) {
start = lineId + 1
regexp = reg
break
}
} }
} else if (testLine(line, regexp, regionName, true)) { }
return { start, end: lineId, regexp } if (chosen) break
}
if (!chosen) return null
let counter = 1
// scan the rest of the lines to find the matching end marker, handling nested markers
for (let i = chosen.start; i < lines.length; i++) {
// check for an inner start marker for the same region
if (chosen.re.start.exec(lines[i])?.[1] === regionName) {
counter++
continue
}
// check for an end marker for the same region
const endRegion = chosen.re.end.exec(lines[i])?.[1]
// allow empty region name on the end marker as a fallback
if (endRegion === regionName || endRegion === '') {
if (--counter === 0) return { ...chosen, end: i }
} }
} }
return null return null
} }
export const snippetPlugin = (md: MarkdownIt, srcDir: string) => { export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => {
const parser: RuleBlock = (state, startLine, endLine, silent) => { const parser: RuleBlock = (state, startLine, endLine, silent) => {
const CH = '<'.charCodeAt(0) const CH = '<'.charCodeAt(0)
const pos = state.bMarks[startLine] + state.tShift[startLine] const pos = state.bMarks[startLine] + state.tShift[startLine]
@ -181,7 +202,7 @@ export const snippetPlugin = (md: MarkdownIt, srcDir: string) => {
content = dedent( content = dedent(
lines lines
.slice(region.start, region.end) .slice(region.start, region.end)
.filter((line) => !region.regexp.test(line.trim())) .filter((l) => !(region.re.start.test(l) || region.re.end.test(l)))
.join('\n') .join('\n')
) )
} }

@ -9,6 +9,7 @@ import {
type MarkdownOptions, type MarkdownOptions,
type MarkdownRenderer type MarkdownRenderer
} from './markdown/markdown' } from './markdown/markdown'
import { getPageDataTransformer } from './plugins/dynamicRoutesPlugin'
import { import {
EXTERNAL_URL_RE, EXTERNAL_URL_RE,
getLocaleForPath, getLocaleForPath,
@ -31,25 +32,62 @@ export interface MarkdownCompileResult {
includes: string[] includes: string[]
} }
export function clearCache(file?: string) { export function clearCache(id?: string) {
if (!file) { if (!id) {
cache.clear() cache.clear()
return return
} }
file = JSON.stringify({ file }).slice(1) id = JSON.stringify({ id }).slice(1)
cache.find((_, key) => key.endsWith(file!) && cache.delete(key)) cache.find((_, key) => key.endsWith(id!) && cache.delete(key))
}
let __pages: string[] = []
let __dynamicRoutes = new Map<string, [string, string]>()
let __rewrites = new Map<string, string>()
let __ts: number
function getResolutionCache(siteConfig: SiteConfig) {
// @ts-expect-error internal
if (siteConfig.__dirty) {
__pages = siteConfig.pages.map((p) => slash(p.replace(/\.md$/, '')))
__dynamicRoutes = new Map(
siteConfig.dynamicRoutes.map((r) => [
r.fullPath,
[slash(path.join(siteConfig.srcDir, r.route)), r.loaderPath]
])
)
__rewrites = new Map(
Object.entries(siteConfig.rewrites.map).map(([key, value]) => [
slash(path.join(siteConfig.srcDir, key)),
slash(path.join(siteConfig.srcDir, value!))
])
)
__ts = Date.now()
// @ts-expect-error internal
siteConfig.__dirty = false
}
return {
pages: __pages,
dynamicRoutes: __dynamicRoutes,
rewrites: __rewrites,
ts: __ts
}
} }
export async function createMarkdownToVueRenderFn( export async function createMarkdownToVueRenderFn(
srcDir: string, srcDir: string,
options: MarkdownOptions = {}, options: MarkdownOptions = {},
pages: string[],
isBuild = false, isBuild = false,
base = '/', base = '/',
includeLastUpdatedData = false, includeLastUpdatedData = false,
cleanUrls = false, cleanUrls = false,
siteConfig: SiteConfig | null = null siteConfig: SiteConfig
) { ) {
const md = await createMarkdownRenderer( const md = await createMarkdownRenderer(
srcDir, srcDir,
@ -58,32 +96,30 @@ export async function createMarkdownToVueRenderFn(
siteConfig?.logger siteConfig?.logger
) )
pages = pages.map((p) => slash(p.replace(/\.md$/, '')))
const dynamicRoutes = new Map(
siteConfig?.dynamicRoutes?.routes.map((r) => [
r.fullPath,
slash(path.join(srcDir, r.route))
]) || []
)
const rewrites = new Map(
Object.entries(siteConfig?.rewrites.map || {}).map(([key, value]) => [
slash(path.join(srcDir, key)),
slash(path.join(srcDir, value!))
]) || []
)
return async ( return async (
src: string, src: string,
file: string, file: string,
publicDir: string publicDir: string
): Promise<MarkdownCompileResult> => { ): Promise<MarkdownCompileResult> => {
const fileOrig = dynamicRoutes.get(file) || file const { pages, dynamicRoutes, rewrites, ts } =
getResolutionCache(siteConfig)
const dynamicRoute = dynamicRoutes.get(file)
const fileOrig = dynamicRoute?.[0] || file
const transformPageData = [
siteConfig?.transformPageData,
getPageDataTransformer(dynamicRoute?.[1]!)
].filter((fn) => fn != null)
file = rewrites.get(file) || file file = rewrites.get(file) || file
const relativePath = slash(path.relative(srcDir, file)) const relativePath = slash(path.relative(srcDir, file))
const cacheKey = JSON.stringify({ src, file: relativePath }) const cacheKey = JSON.stringify({
src,
ts,
file: relativePath,
id: fileOrig
})
if (isBuild || options.cache !== false) { if (isBuild || options.cache !== false) {
const cached = cache.get(cacheKey) const cached = cache.get(cacheKey)
if (cached) { if (cached) {
@ -106,7 +142,7 @@ export async function createMarkdownToVueRenderFn(
// resolve includes // resolve includes
let includes: string[] = [] let includes: string[] = []
src = processIncludes(srcDir, src, fileOrig, includes) src = processIncludes(md, srcDir, src, fileOrig, includes, cleanUrls)
const localeIndex = getLocaleForPath(siteConfig?.site, relativePath) const localeIndex = getLocaleForPath(siteConfig?.site, relativePath)
@ -205,14 +241,14 @@ export async function createMarkdownToVueRenderFn(
} }
} }
if (siteConfig?.transformPageData) { for (const fn of transformPageData) {
const dataToMerge = await siteConfig.transformPageData(pageData, { if (fn) {
siteConfig const dataToMerge = await fn(pageData, { siteConfig })
}) if (dataToMerge) {
if (dataToMerge) { pageData = {
pageData = { ...pageData,
...pageData, ...dataToMerge
...dataToMerge }
} }
} }
} }
@ -318,10 +354,7 @@ const inferDescription = (frontmatter: Record<string, any>) => {
return (head && getHeadMetaContent(head, 'description')) || '' return (head && getHeadMetaContent(head, 'description')) || ''
} }
const getHeadMetaContent = ( const getHeadMetaContent = (head: HeadConfig[], name: string) => {
head: HeadConfig[],
name: string
): string | undefined => {
if (!head || !head.length) { if (!head || !head.length) {
return undefined return undefined
} }

@ -3,7 +3,6 @@ import c from 'picocolors'
import { import {
mergeConfig, mergeConfig,
searchForWorkspaceRoot, searchForWorkspaceRoot,
type ModuleNode,
type Plugin, type Plugin,
type ResolvedConfig, type ResolvedConfig,
type Rollup, type Rollup,
@ -11,6 +10,7 @@ import {
} from 'vite' } from 'vite'
import { import {
APP_PATH, APP_PATH,
DEFAULT_THEME_PATH,
DIST_CLIENT_PATH, DIST_CLIENT_PATH,
SITE_DATA_REQUEST_PATH, SITE_DATA_REQUEST_PATH,
resolveAliases resolveAliases
@ -96,7 +96,7 @@ export async function createVitePressPlugin(
// lazy require plugin-vue to respect NODE_ENV in @vue/compiler-x // lazy require plugin-vue to respect NODE_ENV in @vue/compiler-x
const vuePlugin = await import('@vitejs/plugin-vue').then((r) => const vuePlugin = await import('@vitejs/plugin-vue').then((r) =>
r.default({ r.default({
include: [/\.vue$/, /\.md$/], include: /\.(?:vue|md)$/,
...userVuePluginOptions, ...userVuePluginOptions,
template: { template: {
...userVuePluginOptions?.template, ...userVuePluginOptions?.template,
@ -130,7 +130,6 @@ export async function createVitePressPlugin(
markdownToVue = await createMarkdownToVueRenderFn( markdownToVue = await createMarkdownToVueRenderFn(
srcDir, srcDir,
markdown, markdown,
siteConfig.pages,
config.command === 'build', config.command === 'build',
config.base, config.base,
lastUpdated, lastUpdated,
@ -158,8 +157,11 @@ export async function createVitePressPlugin(
include: [ include: [
'vue', 'vue',
'vitepress > @vue/devtools-api', 'vitepress > @vue/devtools-api',
'vitepress > @vueuse/core' 'vitepress > @vueuse/core',
], siteConfig.themeDir === DEFAULT_THEME_PATH
? '@theme/index'
: undefined
].filter((d) => d != null),
exclude: ['@docsearch/js', 'vitepress'] exclude: ['@docsearch/js', 'vitepress']
}, },
server: { server: {
@ -197,9 +199,7 @@ export async function createVitePressPlugin(
} }
} }
data = serializeFunctions(data) data = serializeFunctions(data)
return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify( return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(JSON.stringify(data))}))`
JSON.stringify(data)
)}))`
} }
}, },
@ -208,7 +208,7 @@ export async function createVitePressPlugin(
return processClientJS(code, id) return processClientJS(code, id)
} else if (id.endsWith('.md')) { } else if (id.endsWith('.md')) {
// transform .md files into vueSrc so plugin-vue can handle it // transform .md files into vueSrc so plugin-vue can handle it
const { vueSrc, deadLinks, includes } = await markdownToVue( const { vueSrc, deadLinks, includes, pageData } = await markdownToVue(
code, code,
id, id,
config.publicDir config.publicDir
@ -220,6 +220,22 @@ export async function createVitePressPlugin(
this.addWatchFile(i) this.addWatchFile(i)
}) })
} }
if (
this.environment.mode === 'dev' &&
this.environment.name === 'client'
) {
const relativePath = path.posix.relative(srcDir, id)
const payload: PageDataPayload = {
path: `/${siteConfig.rewrites.map[relativePath] || relativePath}`,
pageData
}
// notify the client to update page data
this.environment.hot.send({
type: 'custom',
event: 'vitepress:pageData',
data: payload
})
}
return processClientJS(vueSrc, id) return processClientJS(vueSrc, id)
} }
}, },
@ -256,9 +272,7 @@ export async function createVitePressPlugin(
if (themeRE.test(file)) { if (themeRE.test(file)) {
siteConfig.logger.info( siteConfig.logger.info(
c.green( c.green(
`${path.relative(process.cwd(), _file)} ${ `${path.relative(process.cwd(), _file)} ${added ? 'created' : 'deleted'}, restarting server...\n`
added ? 'created' : 'deleted'
}, restarting server...\n`
), ),
{ clear: true, timestamp: true } { clear: true, timestamp: true }
) )
@ -293,7 +307,8 @@ export async function createVitePressPlugin(
if (url?.endsWith('.html')) { if (url?.endsWith('.html')) {
res.statusCode = 200 res.statusCode = 200
res.setHeader('Content-Type', 'text/html') res.setHeader('Content-Type', 'text/html')
let html = `<!DOCTYPE html> let html = `\
<!DOCTYPE html>
<html> <html>
<head> <head>
<title></title> <title></title>
@ -368,15 +383,13 @@ export async function createVitePressPlugin(
} }
}, },
async handleHotUpdate(ctx) { async hotUpdate({ file }) {
const { file, read, server } = ctx if (this.environment.name !== 'client') return
if (file === configPath || configDeps.includes(file)) { if (file === configPath || configDeps.includes(file)) {
siteConfig.logger.info( siteConfig.logger.info(
c.green( c.green(
`${path.relative( `${path.relative(process.cwd(), file)} changed, restarting server...\n`
process.cwd(),
file
)} changed, restarting server...\n`
), ),
{ clear: true, timestamp: true } { clear: true, timestamp: true }
) )
@ -393,47 +406,23 @@ export async function createVitePressPlugin(
await recreateServer?.() await recreateServer?.()
return return
} }
// hot reload .md files as .vue files
if (file.endsWith('.md')) {
const content = await read()
const { pageData, vueSrc } = await markdownToVue(
content,
file,
config.publicDir
)
const relativePath = slash(path.relative(srcDir, file))
const payload: PageDataPayload = {
path: `/${siteConfig.rewrites.map[relativePath] || relativePath}`,
pageData
}
// notify the client to update page data
server.ws.send({
type: 'custom',
event: 'vitepress:pageData',
data: payload
})
// overwrite src so vue plugin can handle the HMR
ctx.read = () => vueSrc
}
} }
} }
const hmrFix: Plugin = { const hmrFix: Plugin = {
name: 'vitepress:hmr-fix', name: 'vitepress:hmr-fix',
async handleHotUpdate({ file, server, modules }) { async hotUpdate({ file, modules }) {
if (this.environment.name !== 'client') return
const importers = [...(importerMap[slash(file)] || [])] const importers = [...(importerMap[slash(file)] || [])]
if (importers.length > 0) { if (importers.length > 0) {
return [ return [
...modules, ...modules,
...importers.map((id) => { ...importers.map((id) => {
clearCache(slash(path.relative(srcDir, id))) clearCache(id)
return server.moduleGraph.getModuleById(id) return this.environment.moduleGraph.getModuleById(id)
}) })
].filter(Boolean) as ModuleNode[] ].filter((mod) => mod !== undefined)
} }
} }
} }

@ -1,24 +1,83 @@
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'node:path' import path from 'node:path'
import c from 'picocolors' import c from 'picocolors'
import { isMatch } from 'picomatch'
import { glob } from 'tinyglobby' import { glob } from 'tinyglobby'
import { import {
loadConfigFromFile, loadConfigFromFile,
normalizePath, normalizePath,
type EnvironmentModuleNode,
type Logger, type Logger,
type Plugin, type Plugin
type ViteDevServer
} from 'vite' } from 'vite'
import type { Awaitable } from '../shared'
import { type SiteConfig, type UserConfig } from '../siteConfig' import { type SiteConfig, type UserConfig } from '../siteConfig'
import { ModuleGraph } from '../utils/moduleGraph'
import { resolveRewrites } from './rewritesPlugin' import { resolveRewrites } from './rewritesPlugin'
export const dynamicRouteRE = /\[(\w+?)\]/g interface UserRouteConfig {
params: Record<string, string>
content?: string
}
export type ResolvedRouteConfig = UserRouteConfig & {
/**
* the raw route (relative to src root), e.g. foo/[bar].md
*/
route: string
/**
* the actual path with params resolved (relative to src root), e.g. foo/1.md
*/
path: string
/**
* absolute fs path
*/
fullPath: string
/**
* the path to the paths loader module
*/
loaderPath: string
}
export interface RouteModule {
watch?: string[] | string
paths:
| UserRouteConfig[]
| ((watchedFiles: string[]) => Awaitable<UserRouteConfig[]>)
transformPageData?: UserConfig['transformPageData']
}
interface ResolvedRouteModule {
watch: string[] | undefined
routes: ResolvedRouteConfig[] | undefined
loader: RouteModule['paths']
transformPageData?: RouteModule['transformPageData']
}
const dynamicRouteRE = /\[(\w+?)\]/g
const pathLoaderRE = /\.paths\.m?[jt]s$/
const routeModuleCache = new Map<string, ResolvedRouteModule>()
let moduleGraph = new ModuleGraph()
/**
* Helper for defining routes with type inference
*/
export function defineRoutes(loader: RouteModule) {
return loader
}
export async function resolvePages( export async function resolvePages(
srcDir: string, srcDir: string,
userConfig: UserConfig, userConfig: UserConfig,
logger: Logger logger: Logger,
) { rebuildCache = false
): Promise<Pick<SiteConfig, 'pages' | 'dynamicRoutes' | 'rewrites'>> {
if (rebuildCache) {
moduleGraph = new ModuleGraph()
routeModuleCache.clear()
}
// Important: tinyglobby doesn't guarantee order of the returned files. // Important: tinyglobby doesn't guarantee order of the returned files.
// We must sort the pages so the input list to rollup is stable across // We must sort the pages so the input list to rollup is stable across
// builds - otherwise different input order could result in different exports // builds - otherwise different input order could result in different exports
@ -26,7 +85,7 @@ export async function resolvePages(
// JavaScript built-in sort() is mandated to be stable as of ES2019 and // JavaScript built-in sort() is mandated to be stable as of ES2019 and
// supported in Node 12+, which is required by Vite. // supported in Node 12+, which is required by Vite.
const allMarkdownFiles = ( const allMarkdownFiles = (
await glob(['**.md'], { await glob(['**/*.md'], {
cwd: srcDir, cwd: srcDir,
ignore: [ ignore: [
'**/node_modules/**', '**/node_modules/**',
@ -50,67 +109,32 @@ export async function resolvePages(
dynamicRouteFiles, dynamicRouteFiles,
logger logger
) )
pages.push(...dynamicRoutes.routes.map((r) => r.path))
pages.push(...dynamicRoutes.map((r) => r.path))
const rewrites = resolveRewrites(pages, userConfig.rewrites) const rewrites = resolveRewrites(pages, userConfig.rewrites)
return { return {
pages, pages,
dynamicRoutes, dynamicRoutes,
rewrites rewrites,
} // @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts
} __dirty: true
interface UserRouteConfig {
params: Record<string, string>
content?: string
}
interface RouteModule {
path: string
config: {
paths:
| UserRouteConfig[]
| (() => UserRouteConfig[] | Promise<UserRouteConfig[]>)
} }
dependencies: string[]
}
const routeModuleCache = new Map<string, RouteModule>()
export type ResolvedRouteConfig = UserRouteConfig & {
/**
* the raw route (relative to src root), e.g. foo/[bar].md
*/
route: string
/**
* the actual path with params resolved (relative to src root), e.g. foo/1.md
*/
path: string
/**
* absolute fs path
*/
fullPath: string
} }
export const dynamicRoutesPlugin = async ( export const dynamicRoutesPlugin = async (
config: SiteConfig config: SiteConfig
): Promise<Plugin> => { ): Promise<Plugin> => {
let server: ViteDevServer
return { return {
name: 'vitepress:dynamic-routes', name: 'vitepress:dynamic-routes',
configureServer(_server) {
server = _server
},
resolveId(id) { resolveId(id) {
if (!id.endsWith('.md')) return if (!id.endsWith('.md')) return
const normalizedId = id.startsWith(config.srcDir) const normalizedId = id.startsWith(config.srcDir)
? id ? id
: normalizePath(path.resolve(config.srcDir, id.replace(/^\//, ''))) : normalizePath(path.resolve(config.srcDir, id.replace(/^\//, '')))
const matched = config.dynamicRoutes.routes.find( const matched = config.dynamicRoutes.find(
(r) => r.fullPath === normalizedId (r) => r.fullPath === normalizedId
) )
if (matched) { if (matched) {
@ -119,11 +143,13 @@ export const dynamicRoutesPlugin = async (
}, },
load(id) { load(id) {
const matched = config.dynamicRoutes.routes.find((r) => r.fullPath === id) const matched = config.dynamicRoutes.find((r) => r.fullPath === id)
if (matched) { if (matched) {
const { route, params, content } = matched const { route, params, content } = matched
const routeFile = normalizePath(path.resolve(config.srcDir, route)) const routeFile = normalizePath(path.resolve(config.srcDir, route))
config.dynamicRoutes.fileToModulesMap[routeFile].add(id)
moduleGraph.add(id, [routeFile])
moduleGraph.add(routeFile, [matched.loaderPath])
let baseContent = fs.readFileSync(routeFile, 'utf-8') let baseContent = fs.readFileSync(routeFile, 'utf-8')
@ -139,39 +165,72 @@ export const dynamicRoutesPlugin = async (
} }
// params are injected with special markers and extracted as part of // params are injected with special markers and extracted as part of
// __pageData in ../markdownTovue.ts // __pageData in ../markdownToVue.ts
return `__VP_PARAMS_START${JSON.stringify( return `__VP_PARAMS_START${JSON.stringify(params)}__VP_PARAMS_END__${baseContent}`
params
)}__VP_PARAMS_END__${baseContent}`
} }
}, },
async handleHotUpdate(ctx) { async hotUpdate({ file, modules: existingMods }) {
routeModuleCache.delete(ctx.file) if (this.environment.name !== 'client') return
const mods = config.dynamicRoutes.fileToModulesMap[ctx.file]
if (mods) { const modules: EnvironmentModuleNode[] = []
// path loader module or deps updated, reset loaded routes const normalizedFile = normalizePath(file)
if (!ctx.file.endsWith('.md')) {
Object.assign( // Trigger update if a module or its dependencies changed.
config, for (const id of moduleGraph.delete(normalizedFile)) {
await resolvePages(config.srcDir, config.userConfig, config.logger) routeModuleCache.delete(id)
) const mod = this.environment.moduleGraph.getModuleById(id)
if (mod) {
modules.push(mod)
} }
for (const id of mods) { }
ctx.modules.push(server.moduleGraph.getModuleById(id)!)
// Also check if the file matches any custom watch patterns.
let watchedFileChanged = false
for (const [file, route] of routeModuleCache) {
if (route.watch && isMatch(normalizedFile, route.watch)) {
route.routes = undefined
watchedFileChanged = true
for (const id of moduleGraph.delete(file)) {
const mod = this.environment.moduleGraph.getModuleById(id)
if (mod) {
modules.push(mod)
}
}
} }
} }
if (
(modules.length && !normalizedFile.endsWith('.md')) ||
watchedFileChanged ||
pathLoaderRE.test(normalizedFile)
) {
// path loader module or deps updated, reset loaded routes
Object.assign(
config,
await resolvePages(config.srcDir, config.userConfig, config.logger)
)
}
return modules.length ? [...existingMods, ...modules] : undefined
} }
} }
} }
export async function resolveDynamicRoutes( export function getPageDataTransformer(
loaderPath: string
): UserConfig['transformPageData'] | undefined {
return routeModuleCache.get(loaderPath)?.transformPageData
}
async function resolveDynamicRoutes(
srcDir: string, srcDir: string,
routes: string[], routes: string[],
logger: Logger logger: Logger
): Promise<SiteConfig['dynamicRoutes']> { ): Promise<ResolvedRouteConfig[]> {
const pendingResolveRoutes: Promise<ResolvedRouteConfig[]>[] = [] const pendingResolveRoutes: Promise<ResolvedRouteConfig[]>[] = []
const routeFileToModulesMap: Record<string, Set<string>> = {} const newModuleGraph = moduleGraph.clone()
for (const route of routes) { for (const route of routes) {
// locate corresponding route paths file // locate corresponding route paths file
@ -194,50 +253,95 @@ export async function resolveDynamicRoutes(
} }
// load the paths loader module // load the paths loader module
let mod = routeModuleCache.get(pathsFile) let watch: ResolvedRouteModule['watch']
if (!mod) { let loader: ResolvedRouteModule['loader']
let extras: Partial<ResolvedRouteModule>
const loaderPath = normalizePath(pathsFile)
const existing = routeModuleCache.get(loaderPath)
if (existing) {
// use cached routes if not invalidated by hmr
if (existing.routes) {
pendingResolveRoutes.push(Promise.resolve(existing.routes))
continue
}
;({ watch, loader, ...extras } = existing)
} else {
let mod
try { try {
mod = (await loadConfigFromFile( mod = await loadConfigFromFile(
{} as any, {} as any,
pathsFile, pathsFile,
undefined, undefined,
'silent' 'silent'
)) as RouteModule )
routeModuleCache.set(pathsFile, mod)
} catch (err: any) { } catch (err: any) {
logger.warn( logger.warn(
`${c.yellow(`Failed to load ${pathsFile}:`)}\n${err.message}\n${err.stack}` `${c.yellow(`Failed to load ${pathsFile}:`)}\n${err.message}\n${err.stack}`
) )
continue continue
} }
}
// this array represents the virtual modules affected by this route if (!mod) {
const matchedModuleIds = (routeFileToModulesMap[ logger.warn(
normalizePath(path.resolve(srcDir, route)) c.yellow(
] = new Set()) `Invalid paths file export in ${pathsFile}. ` +
`Missing "default" export.`
)
)
continue
}
// each dependency (including the loader module itself) also point to the // @ts-ignore
// same array ;({ paths: loader, watch, ...extras } = mod.config)
for (const dep of mod.dependencies) {
// deps are resolved relative to cwd
routeFileToModulesMap[normalizePath(path.resolve(dep))] = matchedModuleIds
}
const loader = mod!.config.paths if (!loader) {
if (!loader) { logger.warn(
logger.warn( c.yellow(
c.yellow( `Invalid paths file export in ${pathsFile}. ` +
`Invalid paths file export in ${pathsFile}. ` + `Missing "paths" property from default export.`
`Missing "paths" property from default export.` )
)
continue
}
watch = typeof watch === 'string' ? [watch] : watch
if (watch) {
watch = watch.map((p) =>
p.startsWith('.')
? normalizePath(path.resolve(path.dirname(pathsFile), p))
: normalizePath(p)
) )
}
// record deps for hmr
newModuleGraph.add(
loaderPath,
mod.dependencies.map((p) => normalizePath(path.resolve(p)))
) )
continue
} }
const resolveRoute = async (): Promise<ResolvedRouteConfig[]> => { const resolveRoute = async (): Promise<ResolvedRouteConfig[]> => {
const paths = await (typeof loader === 'function' ? loader() : loader) let pathsData: UserRouteConfig[]
return paths.map((userConfig) => {
if (typeof loader === 'function') {
let watchedFiles: string[] = []
if (watch) {
watchedFiles = (
await glob(watch, {
ignore: ['**/node_modules/**', '**/dist/**'],
expandDirectories: false
})
).sort()
}
pathsData = await loader(watchedFiles)
} else {
pathsData = loader
}
const routes = pathsData.map((userConfig) => {
const resolvedPath = route.replace( const resolvedPath = route.replace(
dynamicRouteRE, dynamicRouteRE,
(_, key) => userConfig.params[key] (_, key) => userConfig.params[key]
@ -246,15 +350,21 @@ export async function resolveDynamicRoutes(
path: resolvedPath, path: resolvedPath,
fullPath: normalizePath(path.resolve(srcDir, resolvedPath)), fullPath: normalizePath(path.resolve(srcDir, resolvedPath)),
route, route,
loaderPath,
...userConfig ...userConfig
} }
}) })
routeModuleCache.set(loaderPath, { ...extras, watch, routes, loader })
return routes
} }
pendingResolveRoutes.push(resolveRoute()) pendingResolveRoutes.push(resolveRoute())
} }
return { const resolvedRoutes = (await Promise.all(pendingResolveRoutes)).flat()
routes: (await Promise.all(pendingResolveRoutes)).flat(), moduleGraph = newModuleGraph
fileToModulesMap: routeFileToModulesMap
} return resolvedRoutes
} }

@ -56,7 +56,7 @@ export async function localSearchPlugin(
const relativePath = slash(path.relative(srcDir, file)) const relativePath = slash(path.relative(srcDir, file))
const env: MarkdownEnv = { path: file, relativePath, cleanUrls } const env: MarkdownEnv = { path: file, relativePath, cleanUrls }
const md_raw = await fs.promises.readFile(file, 'utf-8') const md_raw = await fs.promises.readFile(file, 'utf-8')
const md_src = processIncludes(srcDir, md_raw, file, []) const md_src = processIncludes(md, srcDir, md_raw, file, [], cleanUrls)
if (options._render) { if (options._render) {
return await options._render(md_src, env, md) return await options._render(md_src, env, md)
} else { } else {
@ -198,7 +198,9 @@ export async function localSearchPlugin(
} }
}, },
async handleHotUpdate({ file }) { async hotUpdate({ file }) {
if (this.environment.name !== 'client') return
if (file.endsWith('.md')) { if (file.endsWith('.md')) {
await indexFile(file) await indexFile(file)
debug('🔍️ Updated', file) debug('🔍️ Updated', file)

@ -1,5 +1,5 @@
import path from 'node:path'
import { isMatch } from 'picomatch' import { isMatch } from 'picomatch'
import path, { dirname, resolve } from 'node:path'
import { glob } from 'tinyglobby' import { glob } from 'tinyglobby'
import { import {
type EnvironmentModuleNode, type EnvironmentModuleNode,
@ -25,10 +25,12 @@ export function defineLoader(loader: LoaderModule) {
return loader return loader
} }
// Map from loader module id to its module info
const idToLoaderModulesMap: Record<string, LoaderModule | undefined> = const idToLoaderModulesMap: Record<string, LoaderModule | undefined> =
Object.create(null) Object.create(null)
const depToLoaderModuleIdMap: Record<string, string> = Object.create(null) // Map from dependency file to a set of loader module ids
const depToLoaderModuleIdsMap: Record<string, Set<string>> = Object.create(null)
// During build, the load hook will be called on the same file twice // During build, the load hook will be called on the same file twice
// once for client and once for server build. Not only is this wasteful, it // once for client and once for server build. Not only is this wasteful, it
@ -62,7 +64,7 @@ export const staticDataPlugin: Plugin = {
}) })
} }
const base = dirname(id) const base = path.dirname(id)
let watch: LoaderModule['watch'] let watch: LoaderModule['watch']
let load: LoaderModule['load'] let load: LoaderModule['load']
@ -70,14 +72,18 @@ export const staticDataPlugin: Plugin = {
if (existing) { if (existing) {
;({ watch, load } = existing) ;({ watch, load } = existing)
} else { } else {
// use vite's load config util as a away to load Node.js file with // use vite's load config util as a way to load Node.js file with
// TS & native ESM support // TS & native ESM support
const res = await loadConfigFromFile({} as any, id.replace(/\?.*$/, '')) const res = await loadConfigFromFile({} as any, id.replace(/\?.*$/, ''))
// record deps for hmr // record deps for hmr
if (server && res) { if (server && res) {
for (const dep of res.dependencies) { for (const dep of res.dependencies) {
depToLoaderModuleIdMap[normalizePath(path.resolve(dep))] = id const depPath = normalizePath(path.resolve(dep))
if (!depToLoaderModuleIdsMap[depPath]) {
depToLoaderModuleIdsMap[depPath] = new Set()
}
depToLoaderModuleIdsMap[depPath].add(id)
} }
} }
@ -89,7 +95,7 @@ export const staticDataPlugin: Plugin = {
if (watch) { if (watch) {
watch = watch.map((p) => { watch = watch.map((p) => {
return p.startsWith('.') return p.startsWith('.')
? normalizePath(resolve(base, p)) ? normalizePath(path.resolve(base, p))
: normalizePath(p) : normalizePath(p)
}) })
} }
@ -97,9 +103,8 @@ export const staticDataPlugin: Plugin = {
} }
// load the data // load the data
let watchedFiles let watchedFiles: string[] = []
if (watch) { if (watch) {
if (typeof watch === 'string') watch = [watch]
watchedFiles = ( watchedFiles = (
await glob(watch, { await glob(watch, {
ignore: ['**/node_modules/**', '**/dist/**'], ignore: ['**/node_modules/**', '**/dist/**'],
@ -107,41 +112,50 @@ export const staticDataPlugin: Plugin = {
}) })
).sort() ).sort()
} }
const data = await load(watchedFiles || []) const data = await load(watchedFiles)
// record loader module for HMR // record loader module for HMR
if (server) { if (server) {
idToLoaderModulesMap[id] = { watch, load } idToLoaderModulesMap[id] = { watch, load }
} }
const result = `export const data = JSON.parse(${JSON.stringify( const result = `export const data = JSON.parse(${JSON.stringify(JSON.stringify(data))})`
JSON.stringify(data)
)})`
if (_resolve) _resolve(result) if (_resolve) _resolve(result)
return result return result
} }
}, },
hotUpdate(ctx) { hotUpdate({ file, modules: existingMods }) {
const file = ctx.file if (this.environment.name !== 'client') return
const modules: EnvironmentModuleNode[] = [] const modules: EnvironmentModuleNode[] = []
// dependency of data loader changed const normalizedFile = normalizePath(file)
// (note the dep array includes the loader file itself)
if (file in depToLoaderModuleIdMap) { // Trigger update if a dependency (including transitive ones) changed.
const id = depToLoaderModuleIdMap[file]! if (normalizedFile in depToLoaderModuleIdsMap) {
delete idToLoaderModulesMap[id] for (const id of Array.from(
modules.push(this.environment.moduleGraph.getModuleById(id)!) depToLoaderModuleIdsMap[normalizedFile] || []
)) {
delete idToLoaderModulesMap[id]
const mod = this.environment.moduleGraph.getModuleById(id)
if (mod) {
modules.push(mod)
}
}
} }
// Also check if the file matches any custom watch patterns.
for (const id in idToLoaderModulesMap) { for (const id in idToLoaderModulesMap) {
const { watch } = idToLoaderModulesMap[id]! const loader = idToLoaderModulesMap[id]
if (watch && isMatch(file, watch)) { if (loader && loader.watch && isMatch(normalizedFile, loader.watch)) {
modules.push(this.environment.moduleGraph.getModuleById(id)!) const mod = this.environment.moduleGraph.getModuleById(id)
if (mod) {
modules.push(mod)
}
} }
} }
return modules.length > 0 ? [...ctx.modules, ...modules] : undefined return modules.length ? [...existingMods, ...modules] : undefined
} }
} }

@ -4,6 +4,7 @@ import type { SitemapStreamOptions } from 'sitemap'
import type { Logger, UserConfig as ViteConfig } from 'vite' import type { Logger, UserConfig as ViteConfig } from 'vite'
import type { SitemapItem } from './build/generateSitemap' import type { SitemapItem } from './build/generateSitemap'
import type { MarkdownOptions } from './markdown/markdown' import type { MarkdownOptions } from './markdown/markdown'
import type { ResolvedRouteConfig } from './plugins/dynamicRoutesPlugin'
import type { import type {
Awaitable, Awaitable,
HeadConfig, HeadConfig,
@ -30,26 +31,6 @@ export interface TransformContext {
assets: string[] assets: string[]
} }
interface UserRouteConfig {
params: Record<string, string>
content?: string
}
export type ResolvedRouteConfig = UserRouteConfig & {
/**
* the raw route (relative to src root), e.g. foo/[bar].md
*/
route: string
/**
* the actual path with params resolved (relative to src root), e.g. foo/1.md
*/
path: string
/**
* absolute fs path
*/
fullPath: string
}
export interface TransformPageContext { export interface TransformPageContext {
siteConfig: SiteConfig siteConfig: SiteConfig
} }
@ -240,10 +221,7 @@ export interface SiteConfig<ThemeConfig = any>
cacheDir: string cacheDir: string
tempDir: string tempDir: string
pages: string[] pages: string[]
dynamicRoutes: { dynamicRoutes: ResolvedRouteConfig[]
routes: ResolvedRouteConfig[]
fileToModulesMap: Record<string, Set<string>>
}
rewrites: { rewrites: {
map: Record<string, string | undefined> map: Record<string, string | undefined>
inv: Record<string, string | undefined> inv: Record<string, string | undefined>

@ -0,0 +1,115 @@
export class ModuleGraph {
// Each module is tracked with its dependencies and dependents.
private nodes: Map<
string,
{ dependencies: Set<string>; dependents: Set<string> }
> = new Map()
/**
* Adds or updates a module by merging the provided dependencies
* with any existing ones.
*
* For every new dependency, the module is added to that dependency's
* 'dependents' set.
*
* @param module - The module to add or update.
* @param dependencies - Array of module names that the module depends on.
*/
add(module: string, dependencies: string[]): void {
// Ensure the module exists in the graph.
if (!this.nodes.has(module)) {
this.nodes.set(module, {
dependencies: new Set(),
dependents: new Set()
})
}
const moduleNode = this.nodes.get(module)!
// Merge the new dependencies with any that already exist.
for (const dep of dependencies) {
if (!moduleNode.dependencies.has(dep) && dep !== module) {
moduleNode.dependencies.add(dep)
// Ensure the dependency exists in the graph.
if (!this.nodes.has(dep)) {
this.nodes.set(dep, {
dependencies: new Set(),
dependents: new Set()
})
}
// Add the module as a dependent of the dependency.
this.nodes.get(dep)!.dependents.add(module)
}
}
}
/**
* Deletes a module and all modules that (transitively) depend on it.
*
* This method performs a depth-first search from the target module,
* collects all affected modules, and then removes them from the graph,
* cleaning up their references from other nodes.
*
* @param module - The module to delete.
* @returns A Set containing the deleted module and all modules that depend on it.
*/
delete(module: string): Set<string> {
const deleted = new Set<string>()
const stack: string[] = [module]
// Traverse the reverse dependency graph (using dependents).
while (stack.length) {
const current = stack.pop()!
if (!deleted.has(current)) {
deleted.add(current)
const node = this.nodes.get(current)
if (node) {
for (const dependent of node.dependents) {
stack.push(dependent)
}
}
}
}
// Remove deleted nodes from the graph.
// For each deleted node, also remove it from its dependencies' dependents.
for (const mod of deleted) {
const node = this.nodes.get(mod)
if (node) {
for (const dep of node.dependencies) {
const depNode = this.nodes.get(dep)
if (depNode) {
depNode.dependents.delete(mod)
}
}
}
this.nodes.delete(mod)
}
return deleted
}
/**
* Clears all modules from the graph.
*/
clear(): void {
this.nodes.clear()
}
/**
* Creates a deep clone of the ModuleGraph instance.
* This is useful for preserving the state of the graph
* before making modifications.
*
* @returns A new ModuleGraph instance with the same state as the original.
*/
clone(): ModuleGraph {
const clone = new ModuleGraph()
for (const [module, { dependencies, dependents }] of this.nodes) {
clone.nodes.set(module, {
dependencies: new Set(dependencies),
dependents: new Set(dependents)
})
}
return clone
}
}

@ -1,18 +1,21 @@
import fs from 'fs-extra' import fs from 'fs-extra'
import matter from 'gray-matter' import matter from 'gray-matter'
import type { MarkdownItAsync } from 'markdown-it-async'
import path from 'node:path' import path from 'node:path'
import c from 'picocolors' import c from 'picocolors'
import { findRegion } from '../markdown/plugins/snippet' import { findRegion } from '../markdown/plugins/snippet'
import { slash } from '../shared' import { slash, type MarkdownEnv } from '../shared'
export function processIncludes( export function processIncludes(
md: MarkdownItAsync,
srcDir: string, srcDir: string,
src: string, src: string,
file: string, file: string,
includes: string[] includes: string[],
cleanUrls: boolean
): string { ): string {
const includesRE = /<!--\s*@include:\s*(.*?)\s*-->/g const includesRE = /<!--\s*@include:\s*(.*?)\s*-->/g
const regionRE = /(#[\w-]+)/ const regionRE = /(#[^\s\{]+)/
const rangeRE = /\{(\d*),(\d*)\}$/ const rangeRE = /\{(\d*),(\d*)\}$/
return src.replace(includesRE, (m: string, m1: string) => { return src.replace(includesRE, (m: string, m1: string) => {
@ -39,8 +42,34 @@ export function processIncludes(
if (region) { if (region) {
const [regionName] = region const [regionName] = region
const lines = content.split(/\r?\n/) const lines = content.split(/\r?\n/)
const regionLines = findRegion(lines, regionName.slice(1)) let { start, end } = findRegion(lines, regionName.slice(1)) ?? {}
content = lines.slice(regionLines?.start, regionLines?.end).join('\n')
if (start === undefined) {
// region not found, it might be a header
const tokens = md
.parse(content, {
path: includePath,
relativePath: slash(path.relative(srcDir, includePath)),
cleanUrls
} satisfies MarkdownEnv)
.filter((t) => t.type === 'heading_open' && t.map)
const idx = tokens.findIndex(
(t) => t.attrGet('id') === regionName.slice(1)
)
const token = tokens[idx]
if (token) {
start = token.map![1]
const level = parseInt(token.tag.slice(1))
for (let i = idx + 1; i < tokens.length; i++) {
if (parseInt(tokens[i].tag.slice(1)) <= level) {
end = tokens[i].map![0]
break
}
}
}
}
content = lines.slice(start, end).join('\n')
} }
if (range) { if (range) {
@ -48,8 +77,8 @@ export function processIncludes(
const lines = content.split(/\r?\n/) const lines = content.split(/\r?\n/)
content = lines content = lines
.slice( .slice(
startLine ? parseInt(startLine, 10) - 1 : undefined, startLine ? parseInt(startLine) - 1 : undefined,
endLine ? parseInt(endLine, 10) : undefined endLine ? parseInt(endLine) : undefined
) )
.join('\n') .join('\n')
} }
@ -60,7 +89,14 @@ export function processIncludes(
includes.push(slash(includePath)) includes.push(slash(includePath))
// recursively process includes in the content // recursively process includes in the content
return processIncludes(srcDir, content, includePath, includes) return processIncludes(
md,
srcDir,
content,
includePath,
includes,
cleanUrls
)
// //
} catch (error) { } catch (error) {

@ -1,7 +1,7 @@
import ora from 'ora' import ora from 'ora'
export const okMark = '\x1b[32m✓\x1b[0m' export const okMark = '\x1b[32m✓\x1b[0m'
export const failMark = '\x1b[31m\x1b[0m' export const failMark = '\x1b[31m\x1b[0m'
export async function task(taskName: string, task: () => Promise<void>) { export async function task(taskName: string, task: () => Promise<void>) {
const spinner = ora({ discardStdin: false }) const spinner = ora({ discardStdin: false })

@ -1,7 +1,9 @@
import { defineConfig } from 'vitepress' import { defineConfig } from 'vitepress'
// https://vitepress.dev/reference/site-config // https://vitepress.dev/reference/site-config
export default defineConfig({ export default defineConfig({<% if (srcDir) { %>
srcDir: <%= srcDir %>,
<% } %>
title: <%= title %>, title: <%= title %>,
description: <%= description %><% if (defaultTheme) { %>, description: <%= description %><% if (defaultTheme) { %>,
themeConfig: { themeConfig: {

@ -24,7 +24,7 @@
* *
* The soft color must be semi transparent alpha channel. This is crucial * The soft color must be semi transparent alpha channel. This is crucial
* because it allows adding multiple "soft" colors on top of each other * because it allows adding multiple "soft" colors on top of each other
* to create a accent, such as when having inline code block inside * to create an accent, such as when having inline code block inside
* custom containers. * custom containers.
* *
* - `default`: The color used purely for subtle indication without any * - `default`: The color used purely for subtle indication without any

Loading…
Cancel
Save