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)
### Bug Fixes

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

@ -1,8 +1,14 @@
export default {
async paths() {
return [
{ params: { id: 'foo' }, content: `# Foo` },
{ params: { id: 'bar' }, content: `# Bar` }
]
import { defineRoutes } from 'vitepress'
import paths from './paths'
export default defineRoutes({
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,}-->
## Markdown File Inclusion with Header
<!--@include: ./header-include.md#header-1-1-->
## Image Lazy Loading
![vitepress logo](/vitepress.png)

@ -64,8 +64,61 @@ describe('Emoji', () => {
describe('Table of Contents', () => {
test('render toc', async () => {
const items = page.locator('#table-of-contents + nav ul li')
const count = await items.count()
expect(count).toBe(44)
expect(
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) => {
return Object.fromEntries(
@ -94,9 +99,228 @@ describe('node/markdown/plugins/snippet', () => {
})
})
test('rawPathToToken', () => {
rawPathTokenMap.forEach(([rawPath, token]) => {
describe('rawPathToToken', () => {
test.each(rawPathTokenMap)('%s', (rawPath, 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.
:::
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
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:

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

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

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

@ -844,7 +844,7 @@ export default config
Может быть создана с помощью `.foorc.json`.
```
**Эквивалентный код**
**Соответствующий код**
```md
# Документация
@ -883,7 +883,7 @@ export default config
<!-- #endregion basic-usage -->
```
**Эквивалентный код**
**Соответствующий код**
```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}
В настоящее время эта фича предоставляется по желанию. Чтобы включить её, вам нужно установить `markdown-it-mathjax3` и установить значение `true` для опции `markdown.math` в вашем файле конфигурации:

@ -1,16 +1,30 @@
┌ Welcome to VitePress!
│
◇ Where should VitePress initialize the config?
│ ./docs
│
◇ Site title:
│ My Awesome Project
│
◇ Site description:
│ A VitePress Site
│
◆ Theme:
│ ● Default Theme (Out of the box, good-looking docs)
│ ○ Default Theme + Customization
│ ○ Custom Theme
└
┌ Welcome to VitePress!
│
◇ Where should VitePress initialize the config?
│ ./docs
│
◇ Where should VitePress look for your markdown files?
│ ./docs
│
◇ Site title:
│ My Awesome Project
│
◇ Site description:
│ A VitePress Site
│
◇ 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]
NODE_VERSION = "20"
NODE_VERSION = "22"
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"
[build]

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

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

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

@ -340,10 +340,10 @@
--vp-code-block-bg: var(--vp-c-bg-alt);
--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-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-symbol-color: var(--vp-c-success-1);

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

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

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

@ -19,13 +19,13 @@ import type {
ShikiTransformer,
ThemeRegistrationAny
} from '@shikijs/types'
import type { Options } from 'markdown-it'
import { MarkdownItAsync } from 'markdown-it-async'
import anchorPlugin from 'markdown-it-anchor'
import { MarkdownItAsync, type Options } from 'markdown-it-async'
import attrsPlugin from 'markdown-it-attrs'
import { full as emojiPlugin } from 'markdown-it-emoji'
import type { BuiltinLanguage, BuiltinTheme, Highlighter } from 'shiki'
import type { Logger } from 'vite'
import type { Awaitable } from '../shared'
import { containerPlugin, type ContainerOptions } from './plugins/containers'
import { gitHubAlertsPlugin } from './plugins/githubAlerts'
import { highlight as createHighlighter } from './plugins/highlight'
@ -53,11 +53,11 @@ export interface MarkdownOptions extends Options {
/**
* Setup markdown-it instance before applying plugins
*/
preConfig?: (md: MarkdownItAsync) => Awaited<void>
preConfig?: (md: MarkdownItAsync) => Awaitable<void>
/**
* Setup markdown-it instance
*/
config?: (md: MarkdownItAsync) => Awaited<void>
config?: (md: MarkdownItAsync) => Awaitable<void>
/**
* Disable cache (experimental)
*/
@ -246,8 +246,13 @@ export async function createMarkdownRenderer(
)
.use(lineNumberPlugin, options.lineNumbers)
const tableOpen = md.renderer.rules.table_open
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) {
@ -263,22 +268,37 @@ export async function createMarkdownRenderer(
// mdit-vue plugins
md.use(anchorPlugin, {
slugify,
permalink: anchorPlugin.permalink.linkInsideHeader({
symbol: '&ZeroWidthSpace;',
renderAttrs: (slug, state) => {
// Find `heading_open` with the id identical to slug
const idx = state.tokens.findIndex((token) => {
const attrs = token.attrs
const id = attrs?.find((attr) => attr[0] === 'id')
return id && slug === id[1]
})
// Get the actual heading content
const title = state.tokens[idx + 1].content
return {
'aria-label': `Permalink to "${title}"`
}
}
getTokensText: (tokens) => {
return tokens
.filter((t) => !['html_inline', 'emoji'].includes(t.type))
.map((t) => t.content)
.join('')
},
permalink: (slug, _, state, idx) => {
const title =
state.tokens[idx + 1]?.children
?.filter((token) => ['text', 'code_inline'].includes(token.type))
.reduce((acc, t) => acc + t.content, '')
.trim() || ''
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
} as anchorPlugin.AnchorOptions).use(frontmatterPlugin, {
...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 type { RenderRule } from 'markdown-it/lib/renderer.mjs'
import type Token from 'markdown-it/lib/token.mjs'
import { nanoid } from 'nanoid'
import type { MarkdownEnv } from '../../shared'
import {
extractTitle,
getAdaptiveThemeMarker,
@ -12,7 +10,7 @@ import {
} from './preWrapper'
export const containerPlugin = (
md: MarkdownIt,
md: MarkdownItAsync,
options: Options,
containerOptions?: ContainerOptions
) => {
@ -56,7 +54,7 @@ type ContainerArgs = [typeof container, string, { render: RenderRule }]
function createContainer(
klass: string,
defaultTitle: string,
md: MarkdownIt
md: MarkdownItAsync
): ContainerArgs {
return [
container,
@ -64,29 +62,29 @@ function createContainer(
{
render(tokens, idx, _options, env: MarkdownEnv & { references?: any }) {
const token = tokens[idx]
const info = token.info.trim().slice(klass.length).trim()
const attrs = md.renderer.renderAttrs(token)
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, {
references: env.references
})
if (klass === 'details')
return `<details class="${klass} custom-block"${attrs}><summary>${title}</summary>\n`
return `<div class="${klass} custom-block"${attrs}><p class="custom-block-title">${title}</p>\n`
return `<details ${attrs}><summary>${title}</summary>\n`
return `<div ${attrs}><p class="custom-block-title">${title}</p>\n`
} else return klass === 'details' ? `</details>\n` : `</div>\n`
}
}
]
}
function createCodeGroup(options: Options, md: MarkdownIt): ContainerArgs {
function createCodeGroup(options: Options, md: MarkdownItAsync): ContainerArgs {
return [
container,
'code-group',
{
render(tokens, idx) {
if (tokens[idx].nesting === 1) {
const name = nanoid(5)
let tabs = ''
let checked = 'checked'
@ -110,8 +108,7 @@ function createCodeGroup(options: Options, md: MarkdownIt): ContainerArgs {
)
if (title) {
const id = nanoid(7)
tabs += `<input type="radio" name="group-${name}" id="tab-${id}" ${checked}><label data-title="${md.utils.escapeHtml(title)}" for="tab-${id}">${title}</label>`
tabs += `<input type="radio" name="group-${idx}" id="tab-${i}" ${checked}><label data-title="${md.utils.escapeHtml(title)}" for="tab-${i}">${title}</label>`
if (checked && !isHtml) tokens[i].info += ' active'
checked = ''
@ -119,9 +116,7 @@ function createCodeGroup(options: Options, md: MarkdownIt): ContainerArgs {
}
}
return `<div class="vp-code-group${getAdaptiveThemeMarker(
options
)}"><div class="tabs">${tabs}</div><div class="blocks">\n`
return `<div class="vp-code-group${getAdaptiveThemeMarker(options)}"><div class="tabs">${tabs}</div><div class="blocks">\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'
const markerRE =
/^\[!(TIP|NOTE|INFO|IMPORTANT|WARNING|CAUTION|DANGER)\]([^\n\r]*)/i
export const gitHubAlertsPlugin = (
md: MarkdownIt,
md: MarkdownItAsync,
options?: ContainerOptions
) => {
const titleMark = {

@ -7,11 +7,11 @@ import {
type TransformerCompactLineOption
} from '@shikijs/transformers'
import { customAlphabet } from 'nanoid'
import type { LanguageRegistration, ShikiTransformer } from 'shiki'
import { createHighlighter, isSpecialLang } from 'shiki'
import c from 'picocolors'
import type { BundledLanguage, ShikiTransformer } from 'shiki'
import { createHighlighter, guessEmbeddedLanguages, isSpecialLang } from 'shiki'
import type { Logger } from 'vite'
import type { MarkdownOptions, ThemeOptions } from '../markdown'
import c from 'picocolors'
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)
@ -71,34 +71,16 @@ export async function highlight(
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)
// TODO: remove explicit matchAlgorithm in shiki v3
const transformers: ShikiTransformer[] = [
transformerNotationDiff({
matchAlgorithm: 'v3'
}),
transformerNotationDiff(),
transformerNotationFocus({
matchAlgorithm: 'v3',
classActiveLine: 'has-focus',
classActivePre: 'has-focused-lines'
}),
transformerNotationHighlight({
matchAlgorithm: 'v3'
}),
transformerNotationErrorLevel({
matchAlgorithm: 'v3'
}),
transformerNotationHighlight(),
transformerNotationErrorLevel(),
{
name: 'vitepress:add-class',
pre(node) {
@ -129,7 +111,13 @@ export async function highlight(
.toLowerCase() || defaultLang
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 {
logger.warn(
c.yellow(
@ -163,6 +151,9 @@ export async function highlight(
str = removeMustache(str).trimEnd()
const embeddedLang = guessEmbeddedLanguages(str, lang, highlighter)
await highlighter.loadLanguage(...(embeddedLang as BundledLanguage[]))
const highlighted = highlighter.codeToHtml(str, {
lang,
transformers: [

@ -2,11 +2,11 @@
// Now this plugin is only used to normalize line attrs.
// 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,-]+)}/
export const highlightLinePlugin = (md: MarkdownIt) => {
export const highlightLinePlugin = (md: MarkdownItAsync) => {
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const [tokens, idx] = args

@ -1,6 +1,6 @@
// 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'
export interface Options {
@ -11,7 +11,10 @@ export interface Options {
lazyLoading?: boolean
}
export const imagePlugin = (md: MarkdownIt, { lazyLoading }: Options = {}) => {
export const imagePlugin = (
md: MarkdownItAsync,
{ lazyLoading }: Options = {}
) => {
const imageRule = md.renderer.rules.image!
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx]

@ -1,9 +1,9 @@
// markdown-it plugin for generating line numbers.
// 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!
md.renderer.rules.fence = (...args) => {
const rawCode = fence(...args)

@ -2,7 +2,7 @@
// 1. adding target="_blank" to external links
// 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 {
EXTERNAL_URL_RE,
@ -14,7 +14,7 @@ import {
const indexRE = /(^|.*\/)index.md(#?.*)$/i
export const linkPlugin = (
md: MarkdownIt,
md: MarkdownItAsync,
externalAttrs: Record<string, string>,
base: string
) => {

@ -1,11 +1,11 @@
import type MarkdownIt from 'markdown-it'
import type { MarkdownItAsync } from 'markdown-it-async'
export interface Options {
codeCopyButtonTitle: string
hasSingleTheme: boolean
}
export function preWrapperPlugin(md: MarkdownIt, options: Options) {
export function preWrapperPlugin(md: MarkdownItAsync, options: Options) {
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...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 Token from 'markdown-it/lib/token.mjs'
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.renderer.rules.text = (tokens, idx) => escapeHtml(tokens[idx].content)
}

@ -1,5 +1,5 @@
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 path from 'node:path'
import type { MarkdownEnv } from '../../shared'
@ -51,54 +51,75 @@ export function dedent(text: string): string {
return text
}
function testLine(
line: string,
regexp: RegExp,
regionName: string,
end: boolean = false
) {
const [full, tag, name] = regexp.exec(line.trim()) || []
return (
full &&
tag &&
name === regionName &&
tag.match(end ? /^[Ee]nd ?[rR]egion$/ : /^[rR]egion$/)
)
}
const markers = [
{
start: /^\s*\/\/\s*#?region\b\s*(.*?)\s*$/,
end: /^\s*\/\/\s*#?endregion\b\s*(.*?)\s*$/
},
{
start: /^\s*<!--\s*#?region\b\s*(.*?)\s*-->/,
end: /^\s*<!--\s*#?endregion\b\s*(.*?)\s*-->/
},
{
start: /^\s*\/\*\s*#region\b\s*(.*?)\s*\*\//,
end: /^\s*\/\*\s*#endregion\b\s*(.*?)\s*\*\//
},
{
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) {
const regionRegexps = [
/^\/\/ ?#?((?:end)?region) ([\w*-]+)$/, // javascript, typescript, java
/^\/\* ?#((?:end)?region) ([\w*-]+) ?\*\/$/, // css, less, scss
/^#pragma ((?:end)?region) ([\w*-]+)$/, // C, C++
/^<!-- #?((?:end)?region) ([\w*-]+) -->$/, // HTML, markdown
/^#((?:End )Region) ([\w*-]+)$/, // Visual Basic
/^::#((?:end)region) ([\w*-]+)$/, // Bat
/^# ?((?: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
let chosen: { re: (typeof markers)[number]; start: number } | null = null
// find the regex pair for a start marker that matches the given region name
for (let i = 0; i < lines.length; i++) {
for (const re of markers) {
if (re.start.exec(lines[i])?.[1] === regionName) {
chosen = { re, start: i + 1 }
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
}
export const snippetPlugin = (md: MarkdownIt, srcDir: string) => {
export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => {
const parser: RuleBlock = (state, startLine, endLine, silent) => {
const CH = '<'.charCodeAt(0)
const pos = state.bMarks[startLine] + state.tShift[startLine]
@ -181,7 +202,7 @@ export const snippetPlugin = (md: MarkdownIt, srcDir: string) => {
content = dedent(
lines
.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')
)
}

@ -9,6 +9,7 @@ import {
type MarkdownOptions,
type MarkdownRenderer
} from './markdown/markdown'
import { getPageDataTransformer } from './plugins/dynamicRoutesPlugin'
import {
EXTERNAL_URL_RE,
getLocaleForPath,
@ -31,25 +32,62 @@ export interface MarkdownCompileResult {
includes: string[]
}
export function clearCache(file?: string) {
if (!file) {
export function clearCache(id?: string) {
if (!id) {
cache.clear()
return
}
file = JSON.stringify({ file }).slice(1)
cache.find((_, key) => key.endsWith(file!) && cache.delete(key))
id = JSON.stringify({ id }).slice(1)
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(
srcDir: string,
options: MarkdownOptions = {},
pages: string[],
isBuild = false,
base = '/',
includeLastUpdatedData = false,
cleanUrls = false,
siteConfig: SiteConfig | null = null
siteConfig: SiteConfig
) {
const md = await createMarkdownRenderer(
srcDir,
@ -58,32 +96,30 @@ export async function createMarkdownToVueRenderFn(
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 (
src: string,
file: string,
publicDir: string
): 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
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) {
const cached = cache.get(cacheKey)
if (cached) {
@ -106,7 +142,7 @@ export async function createMarkdownToVueRenderFn(
// resolve includes
let includes: string[] = []
src = processIncludes(srcDir, src, fileOrig, includes)
src = processIncludes(md, srcDir, src, fileOrig, includes, cleanUrls)
const localeIndex = getLocaleForPath(siteConfig?.site, relativePath)
@ -205,10 +241,9 @@ export async function createMarkdownToVueRenderFn(
}
}
if (siteConfig?.transformPageData) {
const dataToMerge = await siteConfig.transformPageData(pageData, {
siteConfig
})
for (const fn of transformPageData) {
if (fn) {
const dataToMerge = await fn(pageData, { siteConfig })
if (dataToMerge) {
pageData = {
...pageData,
@ -216,6 +251,7 @@ export async function createMarkdownToVueRenderFn(
}
}
}
}
const vueSrc = [
...injectPageDataCode(
@ -318,10 +354,7 @@ const inferDescription = (frontmatter: Record<string, any>) => {
return (head && getHeadMetaContent(head, 'description')) || ''
}
const getHeadMetaContent = (
head: HeadConfig[],
name: string
): string | undefined => {
const getHeadMetaContent = (head: HeadConfig[], name: string) => {
if (!head || !head.length) {
return undefined
}

@ -3,7 +3,6 @@ import c from 'picocolors'
import {
mergeConfig,
searchForWorkspaceRoot,
type ModuleNode,
type Plugin,
type ResolvedConfig,
type Rollup,
@ -11,6 +10,7 @@ import {
} from 'vite'
import {
APP_PATH,
DEFAULT_THEME_PATH,
DIST_CLIENT_PATH,
SITE_DATA_REQUEST_PATH,
resolveAliases
@ -96,7 +96,7 @@ export async function createVitePressPlugin(
// lazy require plugin-vue to respect NODE_ENV in @vue/compiler-x
const vuePlugin = await import('@vitejs/plugin-vue').then((r) =>
r.default({
include: [/\.vue$/, /\.md$/],
include: /\.(?:vue|md)$/,
...userVuePluginOptions,
template: {
...userVuePluginOptions?.template,
@ -130,7 +130,6 @@ export async function createVitePressPlugin(
markdownToVue = await createMarkdownToVueRenderFn(
srcDir,
markdown,
siteConfig.pages,
config.command === 'build',
config.base,
lastUpdated,
@ -158,8 +157,11 @@ export async function createVitePressPlugin(
include: [
'vue',
'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']
},
server: {
@ -197,9 +199,7 @@ export async function createVitePressPlugin(
}
}
data = serializeFunctions(data)
return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(
JSON.stringify(data)
)}))`
return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(JSON.stringify(data))}))`
}
},
@ -208,7 +208,7 @@ export async function createVitePressPlugin(
return processClientJS(code, id)
} else if (id.endsWith('.md')) {
// 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,
id,
config.publicDir
@ -220,6 +220,22 @@ export async function createVitePressPlugin(
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)
}
},
@ -256,9 +272,7 @@ export async function createVitePressPlugin(
if (themeRE.test(file)) {
siteConfig.logger.info(
c.green(
`${path.relative(process.cwd(), _file)} ${
added ? 'created' : 'deleted'
}, restarting server...\n`
`${path.relative(process.cwd(), _file)} ${added ? 'created' : 'deleted'}, restarting server...\n`
),
{ clear: true, timestamp: true }
)
@ -293,7 +307,8 @@ export async function createVitePressPlugin(
if (url?.endsWith('.html')) {
res.statusCode = 200
res.setHeader('Content-Type', 'text/html')
let html = `<!DOCTYPE html>
let html = `\
<!DOCTYPE html>
<html>
<head>
<title></title>
@ -368,15 +383,13 @@ export async function createVitePressPlugin(
}
},
async handleHotUpdate(ctx) {
const { file, read, server } = ctx
async hotUpdate({ file }) {
if (this.environment.name !== 'client') return
if (file === configPath || configDeps.includes(file)) {
siteConfig.logger.info(
c.green(
`${path.relative(
process.cwd(),
file
)} changed, restarting server...\n`
`${path.relative(process.cwd(), file)} changed, restarting server...\n`
),
{ clear: true, timestamp: true }
)
@ -393,47 +406,23 @@ export async function createVitePressPlugin(
await recreateServer?.()
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 = {
name: 'vitepress:hmr-fix',
async handleHotUpdate({ file, server, modules }) {
async hotUpdate({ file, modules }) {
if (this.environment.name !== 'client') return
const importers = [...(importerMap[slash(file)] || [])]
if (importers.length > 0) {
return [
...modules,
...importers.map((id) => {
clearCache(slash(path.relative(srcDir, id)))
return server.moduleGraph.getModuleById(id)
clearCache(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 path from 'node:path'
import c from 'picocolors'
import { isMatch } from 'picomatch'
import { glob } from 'tinyglobby'
import {
loadConfigFromFile,
normalizePath,
type EnvironmentModuleNode,
type Logger,
type Plugin,
type ViteDevServer
type Plugin
} from 'vite'
import type { Awaitable } from '../shared'
import { type SiteConfig, type UserConfig } from '../siteConfig'
import { ModuleGraph } from '../utils/moduleGraph'
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(
srcDir: string,
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.
// We must sort the pages so the input list to rollup is stable across
// 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
// supported in Node 12+, which is required by Vite.
const allMarkdownFiles = (
await glob(['**.md'], {
await glob(['**/*.md'], {
cwd: srcDir,
ignore: [
'**/node_modules/**',
@ -50,67 +109,32 @@ export async function resolvePages(
dynamicRouteFiles,
logger
)
pages.push(...dynamicRoutes.routes.map((r) => r.path))
pages.push(...dynamicRoutes.map((r) => r.path))
const rewrites = resolveRewrites(pages, userConfig.rewrites)
return {
pages,
dynamicRoutes,
rewrites
}
}
interface UserRouteConfig {
params: Record<string, string>
content?: string
}
interface RouteModule {
path: string
config: {
paths:
| UserRouteConfig[]
| (() => UserRouteConfig[] | Promise<UserRouteConfig[]>)
rewrites,
// @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts
__dirty: true
}
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 (
config: SiteConfig
): Promise<Plugin> => {
let server: ViteDevServer
return {
name: 'vitepress:dynamic-routes',
configureServer(_server) {
server = _server
},
resolveId(id) {
if (!id.endsWith('.md')) return
const normalizedId = id.startsWith(config.srcDir)
? id
: normalizePath(path.resolve(config.srcDir, id.replace(/^\//, '')))
const matched = config.dynamicRoutes.routes.find(
const matched = config.dynamicRoutes.find(
(r) => r.fullPath === normalizedId
)
if (matched) {
@ -119,11 +143,13 @@ export const dynamicRoutesPlugin = async (
},
load(id) {
const matched = config.dynamicRoutes.routes.find((r) => r.fullPath === id)
const matched = config.dynamicRoutes.find((r) => r.fullPath === id)
if (matched) {
const { route, params, content } = matched
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')
@ -139,39 +165,72 @@ export const dynamicRoutesPlugin = async (
}
// params are injected with special markers and extracted as part of
// __pageData in ../markdownTovue.ts
return `__VP_PARAMS_START${JSON.stringify(
params
)}__VP_PARAMS_END__${baseContent}`
// __pageData in ../markdownToVue.ts
return `__VP_PARAMS_START${JSON.stringify(params)}__VP_PARAMS_END__${baseContent}`
}
},
async handleHotUpdate(ctx) {
routeModuleCache.delete(ctx.file)
const mods = config.dynamicRoutes.fileToModulesMap[ctx.file]
if (mods) {
async hotUpdate({ file, modules: existingMods }) {
if (this.environment.name !== 'client') return
const modules: EnvironmentModuleNode[] = []
const normalizedFile = normalizePath(file)
// Trigger update if a module or its dependencies changed.
for (const id of moduleGraph.delete(normalizedFile)) {
routeModuleCache.delete(id)
const mod = this.environment.moduleGraph.getModuleById(id)
if (mod) {
modules.push(mod)
}
}
// 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
if (!ctx.file.endsWith('.md')) {
Object.assign(
config,
await resolvePages(config.srcDir, config.userConfig, config.logger)
)
}
for (const id of mods) {
ctx.modules.push(server.moduleGraph.getModuleById(id)!)
}
}
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,
routes: string[],
logger: Logger
): Promise<SiteConfig['dynamicRoutes']> {
): Promise<ResolvedRouteConfig[]> {
const pendingResolveRoutes: Promise<ResolvedRouteConfig[]>[] = []
const routeFileToModulesMap: Record<string, Set<string>> = {}
const newModuleGraph = moduleGraph.clone()
for (const route of routes) {
// locate corresponding route paths file
@ -194,37 +253,50 @@ export async function resolveDynamicRoutes(
}
// load the paths loader module
let mod = routeModuleCache.get(pathsFile)
if (!mod) {
let watch: ResolvedRouteModule['watch']
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 {
mod = (await loadConfigFromFile(
mod = await loadConfigFromFile(
{} as any,
pathsFile,
undefined,
'silent'
)) as RouteModule
routeModuleCache.set(pathsFile, mod)
)
} catch (err: any) {
logger.warn(
`${c.yellow(`Failed to load ${pathsFile}:`)}\n${err.message}\n${err.stack}`
)
continue
}
}
// this array represents the virtual modules affected by this route
const matchedModuleIds = (routeFileToModulesMap[
normalizePath(path.resolve(srcDir, route))
] = new Set())
// each dependency (including the loader module itself) also point to the
// same array
for (const dep of mod.dependencies) {
// deps are resolved relative to cwd
routeFileToModulesMap[normalizePath(path.resolve(dep))] = matchedModuleIds
if (!mod) {
logger.warn(
c.yellow(
`Invalid paths file export in ${pathsFile}. ` +
`Missing "default" export.`
)
)
continue
}
const loader = mod!.config.paths
// @ts-ignore
;({ paths: loader, watch, ...extras } = mod.config)
if (!loader) {
logger.warn(
c.yellow(
@ -235,9 +307,41 @@ export async function resolveDynamicRoutes(
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)))
)
}
const resolveRoute = async (): Promise<ResolvedRouteConfig[]> => {
const paths = await (typeof loader === 'function' ? loader() : loader)
return paths.map((userConfig) => {
let pathsData: UserRouteConfig[]
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(
dynamicRouteRE,
(_, key) => userConfig.params[key]
@ -246,15 +350,21 @@ export async function resolveDynamicRoutes(
path: resolvedPath,
fullPath: normalizePath(path.resolve(srcDir, resolvedPath)),
route,
loaderPath,
...userConfig
}
})
routeModuleCache.set(loaderPath, { ...extras, watch, routes, loader })
return routes
}
pendingResolveRoutes.push(resolveRoute())
}
return {
routes: (await Promise.all(pendingResolveRoutes)).flat(),
fileToModulesMap: routeFileToModulesMap
}
const resolvedRoutes = (await Promise.all(pendingResolveRoutes)).flat()
moduleGraph = newModuleGraph
return resolvedRoutes
}

@ -56,7 +56,7 @@ export async function localSearchPlugin(
const relativePath = slash(path.relative(srcDir, file))
const env: MarkdownEnv = { path: file, relativePath, cleanUrls }
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) {
return await options._render(md_src, env, md)
} 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')) {
await indexFile(file)
debug('🔍️ Updated', file)

@ -1,5 +1,5 @@
import path from 'node:path'
import { isMatch } from 'picomatch'
import path, { dirname, resolve } from 'node:path'
import { glob } from 'tinyglobby'
import {
type EnvironmentModuleNode,
@ -25,10 +25,12 @@ export function defineLoader(loader: LoaderModule) {
return loader
}
// Map from loader module id to its module info
const idToLoaderModulesMap: Record<string, LoaderModule | undefined> =
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
// 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 load: LoaderModule['load']
@ -70,14 +72,18 @@ export const staticDataPlugin: Plugin = {
if (existing) {
;({ watch, load } = existing)
} 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
const res = await loadConfigFromFile({} as any, id.replace(/\?.*$/, ''))
// record deps for hmr
if (server && res) {
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) {
watch = watch.map((p) => {
return p.startsWith('.')
? normalizePath(resolve(base, p))
? normalizePath(path.resolve(base, p))
: normalizePath(p)
})
}
@ -97,9 +103,8 @@ export const staticDataPlugin: Plugin = {
}
// load the data
let watchedFiles
let watchedFiles: string[] = []
if (watch) {
if (typeof watch === 'string') watch = [watch]
watchedFiles = (
await glob(watch, {
ignore: ['**/node_modules/**', '**/dist/**'],
@ -107,41 +112,50 @@ export const staticDataPlugin: Plugin = {
})
).sort()
}
const data = await load(watchedFiles || [])
const data = await load(watchedFiles)
// record loader module for HMR
if (server) {
idToLoaderModulesMap[id] = { watch, load }
}
const result = `export const data = JSON.parse(${JSON.stringify(
JSON.stringify(data)
)})`
const result = `export const data = JSON.parse(${JSON.stringify(JSON.stringify(data))})`
if (_resolve) _resolve(result)
return result
}
},
hotUpdate(ctx) {
const file = ctx.file
hotUpdate({ file, modules: existingMods }) {
if (this.environment.name !== 'client') return
const modules: EnvironmentModuleNode[] = []
// dependency of data loader changed
// (note the dep array includes the loader file itself)
if (file in depToLoaderModuleIdMap) {
const id = depToLoaderModuleIdMap[file]!
const normalizedFile = normalizePath(file)
// Trigger update if a dependency (including transitive ones) changed.
if (normalizedFile in depToLoaderModuleIdsMap) {
for (const id of Array.from(
depToLoaderModuleIdsMap[normalizedFile] || []
)) {
delete idToLoaderModulesMap[id]
modules.push(this.environment.moduleGraph.getModuleById(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) {
const { watch } = idToLoaderModulesMap[id]!
if (watch && isMatch(file, watch)) {
modules.push(this.environment.moduleGraph.getModuleById(id)!)
const loader = idToLoaderModulesMap[id]
if (loader && loader.watch && isMatch(normalizedFile, loader.watch)) {
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 { SitemapItem } from './build/generateSitemap'
import type { MarkdownOptions } from './markdown/markdown'
import type { ResolvedRouteConfig } from './plugins/dynamicRoutesPlugin'
import type {
Awaitable,
HeadConfig,
@ -30,26 +31,6 @@ export interface TransformContext {
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 {
siteConfig: SiteConfig
}
@ -240,10 +221,7 @@ export interface SiteConfig<ThemeConfig = any>
cacheDir: string
tempDir: string
pages: string[]
dynamicRoutes: {
routes: ResolvedRouteConfig[]
fileToModulesMap: Record<string, Set<string>>
}
dynamicRoutes: ResolvedRouteConfig[]
rewrites: {
map: 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 matter from 'gray-matter'
import type { MarkdownItAsync } from 'markdown-it-async'
import path from 'node:path'
import c from 'picocolors'
import { findRegion } from '../markdown/plugins/snippet'
import { slash } from '../shared'
import { slash, type MarkdownEnv } from '../shared'
export function processIncludes(
md: MarkdownItAsync,
srcDir: string,
src: string,
file: string,
includes: string[]
includes: string[],
cleanUrls: boolean
): string {
const includesRE = /<!--\s*@include:\s*(.*?)\s*-->/g
const regionRE = /(#[\w-]+)/
const regionRE = /(#[^\s\{]+)/
const rangeRE = /\{(\d*),(\d*)\}$/
return src.replace(includesRE, (m: string, m1: string) => {
@ -39,8 +42,34 @@ export function processIncludes(
if (region) {
const [regionName] = region
const lines = content.split(/\r?\n/)
const regionLines = findRegion(lines, regionName.slice(1))
content = lines.slice(regionLines?.start, regionLines?.end).join('\n')
let { start, end } = findRegion(lines, regionName.slice(1)) ?? {}
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) {
@ -48,8 +77,8 @@ export function processIncludes(
const lines = content.split(/\r?\n/)
content = lines
.slice(
startLine ? parseInt(startLine, 10) - 1 : undefined,
endLine ? parseInt(endLine, 10) : undefined
startLine ? parseInt(startLine) - 1 : undefined,
endLine ? parseInt(endLine) : undefined
)
.join('\n')
}
@ -60,7 +89,14 @@ export function processIncludes(
includes.push(slash(includePath))
// recursively process includes in the content
return processIncludes(srcDir, content, includePath, includes)
return processIncludes(
md,
srcDir,
content,
includePath,
includes,
cleanUrls
)
//
} catch (error) {

@ -1,7 +1,7 @@
import ora from 'ora'
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>) {
const spinner = ora({ discardStdin: false })

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

@ -24,7 +24,7 @@
*
* The soft color must be semi transparent alpha channel. This is crucial
* 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.
*
* - `default`: The color used purely for subtle indication without any

Loading…
Cancel
Save