mirror of https://github.com/vuejs/vitepress
commit
ff0e9834a5
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent
|
||||
export default component
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["**/*", ".vitepress/**/*"]
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../tsconfig.json"
|
||||
}
|
||||
@ -1,12 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"isolatedModules": false,
|
||||
"types": ["node", "vitest/globals"],
|
||||
"paths": {
|
||||
"client/*": ["../src/client/*"],
|
||||
"node/*": ["../src/node/*"],
|
||||
"shared/*": ["../src/shared/*"]
|
||||
}
|
||||
"types": ["node", "vitest/globals"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import { resolveConfig } from 'node/config'
|
||||
import { createContentLoader } from 'node/contentLoader'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
describe('node/contentLoader', () => {
|
||||
let root: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
}
|
||||
delete (global as any).VITEPRESS_CONFIG
|
||||
})
|
||||
|
||||
async function setup(cleanUrls: boolean) {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'vitepress-content-loader-'))
|
||||
await writeFile(
|
||||
path.join(root, 'index.md'),
|
||||
'# Home\n\n[link](./other.md)\n'
|
||||
)
|
||||
await writeFile(path.join(root, 'other.md'), '# Other\n')
|
||||
|
||||
const siteConfig = await resolveConfig(root, 'build', 'production')
|
||||
siteConfig.cleanUrls = cleanUrls
|
||||
;(global as any).VITEPRESS_CONFIG = siteConfig
|
||||
}
|
||||
|
||||
test('rendered internal links get .html when cleanUrls is false', async () => {
|
||||
await setup(false)
|
||||
|
||||
const data = await createContentLoader('index.md', {
|
||||
render: true
|
||||
}).load()
|
||||
|
||||
expect(data[0].html).toContain('href="./other.html"')
|
||||
})
|
||||
|
||||
test('rendered internal links are clean when cleanUrls is true', async () => {
|
||||
await setup(true)
|
||||
|
||||
const data = await createContentLoader('index.md', {
|
||||
render: true
|
||||
}).load()
|
||||
|
||||
expect(data[0].html).toContain('href="./other"')
|
||||
expect(data[0].html).not.toContain('./other.html')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,206 @@
|
||||
import { anchor as anchorPlugin } from '@mdit/plugin-anchor'
|
||||
import { attrs as attrsPlugin } from '@mdit/plugin-attrs'
|
||||
import { MarkdownItAsync } from 'markdown-it-async'
|
||||
import {
|
||||
createMarkdownRenderer,
|
||||
disposeMdItInstance,
|
||||
type MarkdownOptions
|
||||
} from 'node/markdown/markdown'
|
||||
|
||||
async function render(src: string, options: MarkdownOptions = {}) {
|
||||
disposeMdItInstance()
|
||||
const md = await createMarkdownRenderer('.', {
|
||||
highlight: (code) => code,
|
||||
...options
|
||||
})
|
||||
return md.renderAsync(src)
|
||||
}
|
||||
|
||||
describe('node/markdown/markdown', () => {
|
||||
describe('disabling built-in plugins', () => {
|
||||
test('anchor', async () => {
|
||||
const enabled = await render('# Hello World')
|
||||
expect(enabled).toContain('id="hello-world"')
|
||||
expect(enabled).toContain('header-anchor')
|
||||
|
||||
const disabled = await render('# Hello World', { anchor: false })
|
||||
expect(disabled).not.toContain('id=')
|
||||
expect(disabled).not.toContain('header-anchor')
|
||||
})
|
||||
|
||||
test('attrs', async () => {
|
||||
const enabled = await render('## Title {#custom-id}')
|
||||
expect(enabled).toContain('id="custom-id"')
|
||||
|
||||
const disabled = await render('## Title {#custom-id}', { attrs: false })
|
||||
expect(disabled).not.toContain('id="custom-id"')
|
||||
expect(disabled).toContain('{#custom-id}')
|
||||
})
|
||||
|
||||
test('emoji', async () => {
|
||||
expect(await render(':tada:')).toContain('🎉')
|
||||
expect(await render(':tada:', { emoji: false })).toContain(':tada:')
|
||||
})
|
||||
|
||||
test('tasklist', async () => {
|
||||
const src = '- [ ] todo'
|
||||
expect(await render(src)).toContain('<input type="checkbox"')
|
||||
|
||||
const disabled = await render(src, { tasklist: false })
|
||||
expect(disabled).not.toContain('<input')
|
||||
expect(disabled).toContain('[ ] todo')
|
||||
})
|
||||
|
||||
test('toc', async () => {
|
||||
const src = '# Title\n\n[[toc]]'
|
||||
expect(await render(src)).toContain('table-of-contents')
|
||||
|
||||
const disabled = await render(src, { toc: false })
|
||||
expect(disabled).not.toContain('table-of-contents')
|
||||
expect(disabled).toContain('[[toc]]')
|
||||
})
|
||||
|
||||
test('preWrapper', async () => {
|
||||
const src = '```js\nconst a = 1\n```'
|
||||
const enabled = await render(src)
|
||||
expect(enabled).toContain('<div class="language-js">')
|
||||
expect(enabled).toContain('class="copy"')
|
||||
|
||||
const disabled = await render(src, { preWrapper: false })
|
||||
expect(disabled).not.toContain('<div class="language-js">')
|
||||
expect(disabled).not.toContain('class="copy"')
|
||||
})
|
||||
|
||||
test('preWrapper disables line numbers with it', async () => {
|
||||
const src = '```js\nconst a = 1\n```'
|
||||
const enabled = await render(src, { lineNumbers: true })
|
||||
expect(enabled).toContain('line-numbers-wrapper')
|
||||
|
||||
const disabled = await render(src, {
|
||||
preWrapper: false,
|
||||
lineNumbers: true
|
||||
})
|
||||
expect(disabled).not.toContain('line-numbers-wrapper')
|
||||
})
|
||||
|
||||
test('snippet', async () => {
|
||||
const disabled = await render('<<< ./foo.js', { snippet: false })
|
||||
expect(disabled).toContain('<<< ./foo.js')
|
||||
})
|
||||
|
||||
test('image', async () => {
|
||||
const src = ''
|
||||
const enabled = await render(src, { image: { lazyLoad: true } })
|
||||
expect(enabled).toContain('loading="lazy"')
|
||||
|
||||
const disabled = await render(src, { image: false })
|
||||
expect(disabled).not.toContain('loading="lazy"')
|
||||
})
|
||||
|
||||
test('component', async () => {
|
||||
const src = 'text\n<MyComponent/>\nmore'
|
||||
const enabled = await render(src)
|
||||
expect(enabled).toContain('</p>\n<MyComponent/><p>')
|
||||
|
||||
const disabled = await render(src, { component: false })
|
||||
expect(disabled).toContain('<p>text\n<MyComponent/>\nmore</p>')
|
||||
})
|
||||
|
||||
test('tableTabIndex', async () => {
|
||||
const src = '| a |\n| --- |\n| b |'
|
||||
expect(await render(src)).toContain('tabindex="0"')
|
||||
expect(await render(src, { tableTabIndex: false })).not.toContain(
|
||||
'tabindex'
|
||||
)
|
||||
})
|
||||
|
||||
test('cjkFriendlyEmphasis', async () => {
|
||||
const src = 'これは**「テスト」**です'
|
||||
expect(await render(src)).toContain('<strong>「テスト」</strong>')
|
||||
expect(await render(src, { cjkFriendlyEmphasis: false })).not.toContain(
|
||||
'<strong>'
|
||||
)
|
||||
})
|
||||
|
||||
test('`true` enables a plugin with its default options', async () => {
|
||||
const html = await render(
|
||||
'## Title {#custom-id}\n\n[[toc]]\n\n:tada:\n\n- [ ] todo',
|
||||
{
|
||||
anchor: true,
|
||||
attrs: true,
|
||||
emoji: true,
|
||||
tasklist: true,
|
||||
toc: true,
|
||||
image: true,
|
||||
component: true
|
||||
}
|
||||
)
|
||||
expect(html).toContain('id="custom-id"')
|
||||
expect(html).toContain('header-anchor')
|
||||
expect(html).toContain('table-of-contents')
|
||||
expect(html).toContain('🎉')
|
||||
expect(html).toContain('<input type="checkbox"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('attrs', () => {
|
||||
test('does not consume fence info', async () => {
|
||||
// line-highlight / meta syntax must reach the highlighter untouched
|
||||
const meta = await render('```js{4}\nconst a = 1\n```')
|
||||
expect(meta).toContain('language-js')
|
||||
expect(meta).not.toContain('4=""')
|
||||
|
||||
// curly attributes have no effect on fenced code blocks
|
||||
const backtick = await render('```js {.foo}\nconst a = 1\n```')
|
||||
expect(backtick).not.toContain('class="foo"')
|
||||
const tilde = await render('~~~js {.foo}\nconst a = 1\n~~~')
|
||||
expect(tilde).not.toContain('class="foo"')
|
||||
})
|
||||
|
||||
test('applies to inline elements and blocks', async () => {
|
||||
expect(await render('*hi*{.cls}')).toContain('<em class="cls">')
|
||||
expect(await render('`code`{.cls}')).toContain('class="cls"')
|
||||
expect(await render('text {.cls}')).toContain('<p class="cls">')
|
||||
expect(await render('- item\n{.cls}')).toContain('<ul class="cls">')
|
||||
expect(await render('| a |\n| --- |\n| b |\n\n{.cls}')).toContain(
|
||||
'<table class="cls"'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tasklist', () => {
|
||||
test('renders checkboxes with their checked state', async () => {
|
||||
const html = await render('- [ ] todo\n- [x] done')
|
||||
expect(html).toContain('<ul class="task-list-container">')
|
||||
expect(html).toContain('<li class="task-list-item">')
|
||||
|
||||
const inputs = html.match(/<input[^>]*>/g)!
|
||||
expect(inputs).toHaveLength(2)
|
||||
expect(inputs[0]).not.toContain('checked')
|
||||
expect(inputs[1]).toContain('checked')
|
||||
for (const input of inputs) expect(input).toContain('disabled')
|
||||
})
|
||||
|
||||
test('forwards options to the plugin', async () => {
|
||||
const html = await render('- [ ] todo', { tasklist: { label: false } })
|
||||
expect(html).toContain('<input type="checkbox"')
|
||||
expect(html).not.toContain('<label')
|
||||
})
|
||||
})
|
||||
|
||||
// attrs applies at a fixed position in the core chain (before linkify),
|
||||
// while anchor pushes to its end, so anchor always sees user-defined ids
|
||||
// no matter which plugin is registered first
|
||||
test('anchor respects ids from attrs regardless of plugin order', async () => {
|
||||
for (const plugins of [
|
||||
[attrsPlugin, anchorPlugin],
|
||||
[anchorPlugin, attrsPlugin]
|
||||
] as const) {
|
||||
const md = new MarkdownItAsync()
|
||||
for (const plugin of plugins) md.use(plugin)
|
||||
expect(await md.renderAsync('## Title {#custom-id}')).toContain(
|
||||
'id="custom-id"'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,607 @@
|
||||
import {
|
||||
createMarkdownRenderer,
|
||||
disposeMdItInstance,
|
||||
type MarkdownOptions
|
||||
} from 'node/markdown/markdown'
|
||||
import type { MarkdownEnv } from 'node/shared'
|
||||
|
||||
async function render(
|
||||
src: string,
|
||||
options: MarkdownOptions = {},
|
||||
env?: Partial<MarkdownEnv>
|
||||
) {
|
||||
disposeMdItInstance()
|
||||
const md = await createMarkdownRenderer('.', {
|
||||
highlight: (code) => code,
|
||||
...options
|
||||
})
|
||||
return md.renderAsync(src, env)
|
||||
}
|
||||
|
||||
describe('node/markdown/plugins/containers', () => {
|
||||
test('renders built-in containers with default titles', async () => {
|
||||
const src = [
|
||||
'tip',
|
||||
'info',
|
||||
'warning',
|
||||
'danger',
|
||||
'note',
|
||||
'important',
|
||||
'caution'
|
||||
]
|
||||
.map((t) => `::: ${t}\ncontent of ${t}\n:::`)
|
||||
.join('\n\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default">TIP</p>
|
||||
<p>content of tip</p>
|
||||
</div>
|
||||
<div class="info custom-block"><p class="custom-block-title custom-block-title-default">INFO</p>
|
||||
<p>content of info</p>
|
||||
</div>
|
||||
<div class="warning custom-block"><p class="custom-block-title custom-block-title-default">WARNING</p>
|
||||
<p>content of warning</p>
|
||||
</div>
|
||||
<div class="danger custom-block"><p class="custom-block-title custom-block-title-default">DANGER</p>
|
||||
<p>content of danger</p>
|
||||
</div>
|
||||
<div class="note custom-block"><p class="custom-block-title custom-block-title-default">NOTE</p>
|
||||
<p>content of note</p>
|
||||
</div>
|
||||
<div class="important custom-block"><p class="custom-block-title custom-block-title-default">IMPORTANT</p>
|
||||
<p>content of important</p>
|
||||
</div>
|
||||
<div class="caution custom-block"><p class="custom-block-title custom-block-title-default">CAUTION</p>
|
||||
<p>content of caution</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('renders details as a disclosure with summary', async () => {
|
||||
expect(await render('::: details\nhidden content\n:::'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"<details class="details custom-block"><summary>Details</summary>
|
||||
<p>hidden content</p>
|
||||
</details>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('renders custom titles, including inline markdown', async () => {
|
||||
const src = [
|
||||
'::: danger STOP',
|
||||
'Danger zone, do not proceed',
|
||||
':::',
|
||||
'',
|
||||
'::: tip A **bold** _title_ with `code`',
|
||||
'content',
|
||||
':::',
|
||||
'',
|
||||
'::: details Click me to toggle the code',
|
||||
'```js',
|
||||
"console.log('hi')",
|
||||
'```',
|
||||
':::'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<div class="danger custom-block"><p class="custom-block-title">STOP</p>
|
||||
<p>Danger zone, do not proceed</p>
|
||||
</div>
|
||||
<div class="tip custom-block"><p class="custom-block-title">A <strong>bold</strong> <em>title</em> with <code>code</code></p>
|
||||
<p>content</p>
|
||||
</div>
|
||||
<details class="details custom-block"><summary>Click me to toggle the code</summary>
|
||||
<div class="language-js"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">js</span><pre><code class="language-js">console.log('hi')
|
||||
</code></pre>
|
||||
</div></details>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('resolves reference links in titles', async () => {
|
||||
const src = [
|
||||
'::: tip See [the guide][guide]',
|
||||
'content',
|
||||
':::',
|
||||
'',
|
||||
'[guide]: /guide/'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block"><p class="custom-block-title">See <a href="/guide/">the guide</a></p>
|
||||
<p>content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('respects custom labels from container options', async () => {
|
||||
const src = '::: tip\n提示内容\n:::\n\n::: details\n详情内容\n:::'
|
||||
expect(
|
||||
await render(src, {
|
||||
container: { tipLabel: '提示', detailsLabel: '详细信息' }
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default">提示</p>
|
||||
<p>提示内容</p>
|
||||
</div>
|
||||
<details class="details custom-block"><summary>详细信息</summary>
|
||||
<p>详情内容</p>
|
||||
</details>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('registers custom containers from container options', async () => {
|
||||
const src = [
|
||||
'::: success',
|
||||
'You have completed the walkthrough!',
|
||||
':::',
|
||||
'',
|
||||
'::: success Well done {no-title}',
|
||||
'content',
|
||||
':::'
|
||||
].join('\n')
|
||||
expect(
|
||||
await render(src, {
|
||||
container: { customContainers: { success: 'SUCCESS' } }
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"<div class="success custom-block"><p class="custom-block-title custom-block-title-default">SUCCESS</p>
|
||||
<p>You have completed the walkthrough!</p>
|
||||
</div>
|
||||
<div class="success custom-block">
|
||||
<p>content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('rejects invalid custom container names', async () => {
|
||||
for (const name of ['raw', 'v-pre', 'code-group', 'Bad Name', 'UPPER']) {
|
||||
await expect(
|
||||
render('text', { container: { customContainers: { [name]: 'X' } } })
|
||||
).rejects.toThrow('Invalid custom container name')
|
||||
}
|
||||
})
|
||||
|
||||
test('supports attrs on the fence line', async () => {
|
||||
const src = [
|
||||
'::: details Click me {open}',
|
||||
'content',
|
||||
':::',
|
||||
'',
|
||||
'::: tip Custom {.extra-class #custom-id}',
|
||||
'content',
|
||||
':::'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<details open="" class="details custom-block"><summary>Click me</summary>
|
||||
<p>content</p>
|
||||
</details>
|
||||
<div class="extra-class tip custom-block" id="custom-id"><p class="custom-block-title">Custom</p>
|
||||
<p>content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('supports quoted and bare attr values on the fence line', async () => {
|
||||
expect(await render('::: tip Custom {data-a="b c" data-d=e}\ncontent\n:::'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"<div data-a="b c" data-d="e" class="tip custom-block"><p class="custom-block-title">Custom</p>
|
||||
<p>content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('skips the title element with a no-title attr', async () => {
|
||||
const src = [
|
||||
'::: tip {no-title}',
|
||||
'content',
|
||||
':::',
|
||||
'',
|
||||
'::: warning Discarded {no-title .extra-class}',
|
||||
'content',
|
||||
':::',
|
||||
'',
|
||||
'::: details {no-title}',
|
||||
'still needs its summary',
|
||||
':::'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block">
|
||||
<p>content</p>
|
||||
</div>
|
||||
<div class="extra-class warning custom-block">
|
||||
<p>content</p>
|
||||
</div>
|
||||
<details class="details custom-block"><summary>Details</summary>
|
||||
<p>still needs its summary</p>
|
||||
</details>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('respects attrs plugin options on the fence line', async () => {
|
||||
const delimiters = await render(
|
||||
'::: tip Custom %(.extra-class)%\ncontent\n:::',
|
||||
{ attrs: { left: '%(', right: ')%' } }
|
||||
)
|
||||
expect(delimiters).toContain('<div class="extra-class tip custom-block">')
|
||||
|
||||
const allowed = await render(
|
||||
'::: tip Custom {.extra-class data-x=1}\ncontent\n:::',
|
||||
{ attrs: { allowed: ['class'] } }
|
||||
)
|
||||
expect(allowed).toContain('<div class="extra-class tip custom-block">')
|
||||
expect(allowed).not.toContain('data-x')
|
||||
})
|
||||
|
||||
test('keeps fence line braces verbatim when attrs are disabled', async () => {
|
||||
expect(
|
||||
await render('::: details Click me {open}\ncontent\n:::', {
|
||||
attrs: false
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"<details class="details custom-block"><summary>Click me {open}</summary>
|
||||
<p>content</p>
|
||||
</details>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('renders v-pre and raw containers as plain wrappers', async () => {
|
||||
const src = [
|
||||
'::: v-pre',
|
||||
'{{ this will be displayed as-is }}',
|
||||
':::',
|
||||
'',
|
||||
'::: raw',
|
||||
'Wraps in a `<div class="vp-raw">`',
|
||||
':::'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<div v-pre>
|
||||
<p>{{ this will be displayed as-is }}</p>
|
||||
</div>
|
||||
<div class="vp-raw">
|
||||
<p>Wraps in a <code><div class="vp-raw"></code></p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('renders code groups with tabs', async () => {
|
||||
const src = [
|
||||
'::: code-group',
|
||||
'',
|
||||
'```js [config.js]',
|
||||
'const a = 1',
|
||||
'```',
|
||||
'',
|
||||
'```ts [config.ts]',
|
||||
'const a: number = 1',
|
||||
'```',
|
||||
'',
|
||||
':::'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<div class="vp-code-group"><div class="tabs"><input type="radio" name="group-0" id="tab-1" checked><label data-title="config.js" for="tab-1">config.js</label><input type="radio" name="group-0" id="tab-2" ><label data-title="config.ts" for="tab-2">config.ts</label></div><div class="blocks">
|
||||
<div class="language-js active"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">js</span><pre><code class="language-js">const a = 1
|
||||
</code></pre>
|
||||
</div><div class="language-ts"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">ts</span><pre><code class="language-ts">const a: number = 1
|
||||
</code></pre>
|
||||
</div></div></div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('supports nesting via longer fences', async () => {
|
||||
const src = [
|
||||
':::: info Outer',
|
||||
'outer content',
|
||||
'',
|
||||
'::: details Inner',
|
||||
'inner content',
|
||||
':::',
|
||||
'::::'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<div class="info custom-block"><p class="custom-block-title">Outer</p>
|
||||
<p>outer content</p>
|
||||
<details class="details custom-block"><summary>Inner</summary>
|
||||
<p>inner content</p>
|
||||
</details>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('auto-closes unclosed containers', async () => {
|
||||
expect(await render('::: warning\nno closing fence'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"<div class="warning custom-block"><p class="custom-block-title custom-block-title-default">WARNING</p>
|
||||
<p>no closing fence</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('parses fences without a space before the name', async () => {
|
||||
expect(await render(':::tip\ncontent\n:::')).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default">TIP</p>
|
||||
<p>content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('leaves non-container fence lines alone', async () => {
|
||||
expect(await render('::: unknown\ncontent\n:::')).toMatchInlineSnapshot(`
|
||||
"<p>::: unknown
|
||||
content
|
||||
:::</p>
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('node/markdown/plugins/containers (github alerts)', () => {
|
||||
test('renders github alerts like containers', async () => {
|
||||
const src = [
|
||||
'> [!NOTE]',
|
||||
'> note content',
|
||||
'',
|
||||
'> [!TIP]',
|
||||
'> tip content',
|
||||
'',
|
||||
'> [!IMPORTANT]',
|
||||
'> important content',
|
||||
'',
|
||||
'> [!WARNING]',
|
||||
'> warning content',
|
||||
'',
|
||||
'> [!CAUTION]',
|
||||
'> caution content'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<div class="note custom-block github-alert"><p class="custom-block-title">NOTE</p>
|
||||
<p>note content</p>
|
||||
</div>
|
||||
<div class="tip custom-block github-alert"><p class="custom-block-title">TIP</p>
|
||||
<p>tip content</p>
|
||||
</div>
|
||||
<div class="important custom-block github-alert"><p class="custom-block-title">IMPORTANT</p>
|
||||
<p>important content</p>
|
||||
</div>
|
||||
<div class="warning custom-block github-alert"><p class="custom-block-title">WARNING</p>
|
||||
<p>warning content</p>
|
||||
</div>
|
||||
<div class="caution custom-block github-alert"><p class="custom-block-title">CAUTION</p>
|
||||
<p>caution content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('matches markers case-insensitively', async () => {
|
||||
expect(await render('> [!tip]\n> content')).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block github-alert"><p class="custom-block-title">TIP</p>
|
||||
<p>content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('supports custom titles after the marker', async () => {
|
||||
expect(await render('> [!WARNING] Custom Title\n> content'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"<div class="warning custom-block github-alert"><p class="custom-block-title">Custom Title</p>
|
||||
<p>content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('respects custom labels from container options', async () => {
|
||||
expect(
|
||||
await render('> [!TIP]\n> content', { container: { tipLabel: '提示' } })
|
||||
).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block github-alert"><p class="custom-block-title">提示</p>
|
||||
<p>content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('renders custom containers as alerts', async () => {
|
||||
expect(
|
||||
await render('> [!SUCCESS]\n> done\n\n> [!success] With title\n> done', {
|
||||
container: { customContainers: { success: 'SUCCESS' } }
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"<div class="success custom-block github-alert"><p class="custom-block-title">SUCCESS</p>
|
||||
<p>done</p>
|
||||
</div>
|
||||
<div class="success custom-block github-alert"><p class="custom-block-title">With title</p>
|
||||
<p>done</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('supports block content and lazy continuation', async () => {
|
||||
const src = [
|
||||
'> [!NOTE]',
|
||||
'> first paragraph',
|
||||
'lazy continuation',
|
||||
'>',
|
||||
'> - list item',
|
||||
'>',
|
||||
'> ```js',
|
||||
'> const a = 1',
|
||||
'> ```'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<div class="note custom-block github-alert"><p class="custom-block-title">NOTE</p>
|
||||
<p>first paragraph
|
||||
lazy continuation</p>
|
||||
<ul>
|
||||
<li>list item</li>
|
||||
</ul>
|
||||
<div class="language-js"><button title="Copy code" data-copied="Copied" class="copy"></button><span class="lang">js</span><pre><code class="language-js">const a = 1
|
||||
</code></pre>
|
||||
</div></div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('converts markers without content', async () => {
|
||||
expect(await render('> [!NOTE]')).toMatchInlineSnapshot(`
|
||||
"<div class="note custom-block github-alert"><p class="custom-block-title">NOTE</p>
|
||||
<p></p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('leaves regular blockquotes and unknown markers alone', async () => {
|
||||
const src = [
|
||||
'> just a quote',
|
||||
'',
|
||||
'> [!FOO]',
|
||||
'> not an alert',
|
||||
'',
|
||||
'paragraph [!NOTE] not at blockquote start'
|
||||
].join('\n')
|
||||
expect(await render(src)).toMatchInlineSnapshot(`
|
||||
"<blockquote>
|
||||
<p>just a quote</p>
|
||||
</blockquote>
|
||||
<blockquote>
|
||||
<p>[!FOO]
|
||||
not an alert</p>
|
||||
</blockquote>
|
||||
<p>paragraph [!NOTE] not at blockquote start</p>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('can be disabled via gfmAlerts: false', async () => {
|
||||
expect(await render('> [!NOTE]\n> content', { gfmAlerts: false }))
|
||||
.toMatchInlineSnapshot(`
|
||||
"<blockquote>
|
||||
<p>[!NOTE]
|
||||
content</p>
|
||||
</blockquote>
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('node/markdown/plugins/containers (locales)', () => {
|
||||
const options: MarkdownOptions = {
|
||||
container: {
|
||||
tipLabel: 'ROOT TIP',
|
||||
customContainers: { success: 'SUCCESS' }
|
||||
},
|
||||
locales: {
|
||||
zh: {
|
||||
container: {
|
||||
tipLabel: '提示',
|
||||
detailsLabel: '详细信息',
|
||||
customContainers: { success: '成功' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('resolves container titles for the active locale', async () => {
|
||||
const src =
|
||||
'::: tip\n内容\n:::\n\n::: details\n内容\n:::\n\n::: success\n内容\n:::'
|
||||
expect(await render(src, options, { localeIndex: 'zh' }))
|
||||
.toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default">提示</p>
|
||||
<p>内容</p>
|
||||
</div>
|
||||
<details class="details custom-block"><summary>详细信息</summary>
|
||||
<p>内容</p>
|
||||
</details>
|
||||
<div class="success custom-block"><p class="custom-block-title custom-block-title-default">成功</p>
|
||||
<p>内容</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('falls back to root titles for other locales', async () => {
|
||||
const src = '::: tip\ncontent\n:::'
|
||||
const root = await render(src, options)
|
||||
expect(await render(src, options, { localeIndex: 'es' })).toBe(root)
|
||||
expect(root).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default">ROOT TIP</p>
|
||||
<p>content</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('explicit titles win over locale defaults', async () => {
|
||||
expect(
|
||||
await render('::: tip Custom Title\n内容\n:::', options, {
|
||||
localeIndex: 'zh'
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block"><p class="custom-block-title">Custom Title</p>
|
||||
<p>内容</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('resolves alert titles for the active locale', async () => {
|
||||
expect(
|
||||
await render('> [!TIP]\n> 内容\n\n> [!SUCCESS]\n> 内容', options, {
|
||||
localeIndex: 'zh'
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"<div class="tip custom-block github-alert"><p class="custom-block-title">提示</p>
|
||||
<p>内容</p>
|
||||
</div>
|
||||
<div class="success custom-block github-alert"><p class="custom-block-title">成功</p>
|
||||
<p>内容</p>
|
||||
</div>
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
test('rejects locale titles for unregistered containers', async () => {
|
||||
await expect(
|
||||
render('text', {
|
||||
locales: { zh: { container: { customContainers: { nope: 'X' } } } }
|
||||
})
|
||||
).rejects.toThrow('is not registered in the root markdown config')
|
||||
})
|
||||
|
||||
test('resolves the code copy button strings for the active locale', async () => {
|
||||
const src = '```js\nconst a = 1\n```'
|
||||
const opts: MarkdownOptions = {
|
||||
codeCopyButton: { copiedText: 'Copied!' },
|
||||
locales: {
|
||||
zh: {
|
||||
codeCopyButton: { tooltipText: '复制代码', copiedText: '已复制' }
|
||||
}
|
||||
}
|
||||
}
|
||||
const zh = await render(src, opts, { localeIndex: 'zh' })
|
||||
expect(zh).toContain('title="复制代码"')
|
||||
expect(zh).toContain('data-copied="已复制"')
|
||||
const es = await render(src, opts, { localeIndex: 'es' })
|
||||
expect(es).toContain('title="Copy code"')
|
||||
expect(es).toContain('data-copied="Copied!"')
|
||||
const root = await render(src, opts)
|
||||
expect(root).toContain('title="Copy code"')
|
||||
expect(root).toContain('data-copied="Copied!"')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,141 @@
|
||||
import path from 'node:path'
|
||||
import { MarkdownItAsync } from 'markdown-it-async'
|
||||
import { attrs as attrsPlugin } from '@mdit/plugin-attrs'
|
||||
import { imagePlugin, type Options } from 'node/markdown/plugins/image'
|
||||
|
||||
const srcDir = path.resolve(import.meta.dirname, '../../../../e2e')
|
||||
const publicDir = path.join(srcDir, 'public')
|
||||
const env = { path: path.join(srcDir, 'index.md') }
|
||||
|
||||
function createRenderer(options?: Options) {
|
||||
const md = new MarkdownItAsync()
|
||||
|
||||
// same registration order as createMarkdownRenderer
|
||||
imagePlugin(md, publicDir, options)
|
||||
attrsPlugin(md as any)
|
||||
|
||||
return md
|
||||
}
|
||||
|
||||
describe('node/markdown/plugins/image', () => {
|
||||
const md = createRenderer()
|
||||
|
||||
describe('src normalization', () => {
|
||||
test('default image output', async () => {
|
||||
const html = await md.renderAsync('')
|
||||
|
||||
expect(html.trim()).toMatchInlineSnapshot(
|
||||
`"<p><img src="./foo.png" alt="logo"></p>"`
|
||||
)
|
||||
})
|
||||
|
||||
test.for([
|
||||
['foo.png', './foo.png'],
|
||||
['./foo.png', './foo.png'],
|
||||
['../foo.png', '../foo.png'],
|
||||
['../../foo.png', '../../foo.png'],
|
||||
['/foo.png', '/foo.png'],
|
||||
['https://example.com/foo.png', 'https://example.com/foo.png']
|
||||
])('normalizes image src: %s → %s', async ([src, expected]) => {
|
||||
const html = await md.renderAsync(``)
|
||||
|
||||
expect(html).toContain(`src="${expected}"`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dimensions', () => {
|
||||
test('adds width and height from local image dimensions', async () => {
|
||||
const html = await md.renderAsync('', env)
|
||||
|
||||
expect(html).toContain('width="48"')
|
||||
expect(html).toContain('height="48"')
|
||||
})
|
||||
|
||||
test('adds width and height from public image dimensions', async () => {
|
||||
const html = await md.renderAsync('', env)
|
||||
|
||||
expect(html).toContain('width="48"')
|
||||
expect(html).toContain('height="48"')
|
||||
})
|
||||
|
||||
test('adds width and height when the image url is encoded', async () => {
|
||||
const html = await md.renderAsync(
|
||||
'',
|
||||
env
|
||||
)
|
||||
|
||||
expect(html).toContain('src="./assets/vitepress logo.png"')
|
||||
expect(html).toContain('width="48"')
|
||||
expect(html).toContain('height="48"')
|
||||
})
|
||||
|
||||
test('does not override explicit width and height', async () => {
|
||||
const html = await md.renderAsync(
|
||||
'{width=100 height=200}',
|
||||
env
|
||||
)
|
||||
|
||||
expect(html).toContain('width="100"')
|
||||
expect(html).toContain('height="200"')
|
||||
})
|
||||
|
||||
test('scales height proportionally when only width is set', async () => {
|
||||
const html = await md.renderAsync(
|
||||
'{width=96}',
|
||||
env
|
||||
)
|
||||
|
||||
// 48x48 image scaled to width=96 → height=96
|
||||
expect(html).toContain('width="96"')
|
||||
expect(html).toContain('height="96"')
|
||||
})
|
||||
|
||||
test('scales width proportionally when only height is set', async () => {
|
||||
const html = await md.renderAsync(
|
||||
'{height=24}',
|
||||
env
|
||||
)
|
||||
|
||||
// 48x48 image scaled to height=24 → width=24
|
||||
expect(html).toContain('width="24"')
|
||||
expect(html).toContain('height="24"')
|
||||
})
|
||||
|
||||
test('ignores non-numeric width when scaling', async () => {
|
||||
const html = await md.renderAsync(
|
||||
'{width=50%}',
|
||||
env
|
||||
)
|
||||
|
||||
expect(html).toContain('width="50%"')
|
||||
expect(html).not.toContain('height=')
|
||||
})
|
||||
|
||||
test('does not add dimensions for external images', async () => {
|
||||
const html = await md.renderAsync(
|
||||
'',
|
||||
env
|
||||
)
|
||||
|
||||
expect(html).not.toContain('width=')
|
||||
expect(html).not.toContain('height=')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lazy loading', () => {
|
||||
const mdLazy = createRenderer({ lazyLoad: true })
|
||||
|
||||
test('adds loading="lazy" when lazy loading is enabled', async () => {
|
||||
const html = await mdLazy.renderAsync('')
|
||||
|
||||
expect(html).toContain('loading="lazy"')
|
||||
})
|
||||
|
||||
test('does not override user-specified loading strategy', async () => {
|
||||
const html = await mdLazy.renderAsync('{loading=eager}')
|
||||
|
||||
expect(html).toContain('loading="eager"')
|
||||
expect(html).not.toContain('loading="lazy"')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,534 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
createMarkdownRenderer,
|
||||
disposeMdItInstance,
|
||||
type MarkdownOptions
|
||||
} from 'node/markdown/markdown'
|
||||
import { slash, type MarkdownEnv } from 'node/shared'
|
||||
|
||||
describe('node/markdown/plugins/include', () => {
|
||||
let root: string
|
||||
let warnings: string[]
|
||||
|
||||
const logger = {
|
||||
warn: (msg: string) => {
|
||||
warnings.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'vitepress-include-'))
|
||||
warnings = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function write(name: string, src: string) {
|
||||
const file = path.join(root, name)
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, src)
|
||||
}
|
||||
|
||||
async function render(
|
||||
src: string,
|
||||
options: MarkdownOptions = {},
|
||||
env: Partial<MarkdownEnv> = {}
|
||||
) {
|
||||
disposeMdItInstance()
|
||||
const md = await createMarkdownRenderer(
|
||||
root,
|
||||
{ highlight: (code) => code, ...options },
|
||||
'/',
|
||||
logger
|
||||
)
|
||||
const fullEnv: MarkdownEnv = {
|
||||
path: path.join(root, 'index.md'),
|
||||
relativePath: 'index.md',
|
||||
cleanUrls: false,
|
||||
includes: [],
|
||||
...env
|
||||
}
|
||||
const html = await md.renderAsync(src, fullEnv)
|
||||
return { html, env: fullEnv }
|
||||
}
|
||||
|
||||
test('includes a relative markdown file', async () => {
|
||||
await write('b.md', 'B-content\n')
|
||||
|
||||
const { html, env } = await render('# A\n\n<!-- @include: ./b.md -->\n')
|
||||
expect(html).toContain('B-content')
|
||||
expect(env.includes).toEqual([slash(path.join(root, 'b.md'))])
|
||||
expect(env.src).toContain('B-content')
|
||||
})
|
||||
|
||||
test('resolves @ against srcDir', async () => {
|
||||
await write('dir/c.md', 'C-content\n')
|
||||
|
||||
const { html } = await render(
|
||||
'<!-- @include: @/dir/c.md -->\n',
|
||||
{},
|
||||
{ path: path.join(root, 'sub/index.md') }
|
||||
)
|
||||
expect(html).toContain('C-content')
|
||||
})
|
||||
|
||||
test('resolves @ without a slash against srcDir', async () => {
|
||||
await write('dir/c.md', 'C-content\n')
|
||||
|
||||
const { html } = await render(
|
||||
'<!-- @include: @dir/c.md -->\n',
|
||||
{},
|
||||
{ path: path.join(root, 'sub/index.md') }
|
||||
)
|
||||
expect(html).toContain('C-content')
|
||||
})
|
||||
|
||||
test('resolves relative includes against the real file path', async () => {
|
||||
await write('sub/part.md', 'real-content\n')
|
||||
|
||||
const { html } = await render(
|
||||
'<!-- @include: ./part.md -->\n',
|
||||
{},
|
||||
{
|
||||
path: path.join(root, 'rewritten/index.md'),
|
||||
realPath: path.join(root, 'sub/index.md')
|
||||
}
|
||||
)
|
||||
expect(html).toContain('real-content')
|
||||
})
|
||||
|
||||
test.runIf(process.platform === 'win32')(
|
||||
'resolves windows-style paths',
|
||||
async () => {
|
||||
await write('dir/c.md', 'C-content\n')
|
||||
|
||||
const relative = await render('<!-- @include: .\\dir\\c.md -->\n')
|
||||
expect(relative.html).toContain('C-content')
|
||||
|
||||
const rooted = await render('<!-- @include: @\\dir\\c.md -->\n')
|
||||
expect(rooted.html).toContain('C-content')
|
||||
|
||||
// watched paths are posix-style for includes
|
||||
expect(relative.env.includes).toEqual([
|
||||
slash(path.join(root, 'dir/c.md'))
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
test('handles CRLF sources', async () => {
|
||||
await write('b.md', 'B-content\n')
|
||||
|
||||
const { html } = await render('# A\r\n\r\n<!-- @include: ./b.md -->\r\n')
|
||||
expect(html).toContain('B-content')
|
||||
})
|
||||
|
||||
test('expands nested includes with relative resolution', async () => {
|
||||
await write(
|
||||
'sub/inside.md',
|
||||
'inside\n\n<!-- @include: ./subsub/deep.md -->\n'
|
||||
)
|
||||
await write('sub/subsub/deep.md', 'deep-content\n')
|
||||
|
||||
const { html, env } = await render('<!-- @include: ./sub/inside.md -->\n')
|
||||
expect(html).toContain('inside')
|
||||
expect(html).toContain('deep-content')
|
||||
expect(env.includes).toEqual([
|
||||
slash(path.join(root, 'sub/inside.md')),
|
||||
slash(path.join(root, 'sub/subsub/deep.md'))
|
||||
])
|
||||
})
|
||||
|
||||
test('leaves a self-include unexpanded', async () => {
|
||||
const src = '# A\n\n<!-- @include: ./index.md -->\n'
|
||||
await write('index.md', src)
|
||||
|
||||
const { html } = await render(src)
|
||||
expect(html).toContain('@include: ./index.md')
|
||||
})
|
||||
|
||||
test('leaves circular includes unexpanded', async () => {
|
||||
await write('a.md', 'A-content\n\n<!-- @include: ./b.md -->\n')
|
||||
await write('b.md', 'B-content\n\n<!-- @include: ./a.md -->\n')
|
||||
|
||||
const { html } = await render(
|
||||
'A-content\n\n<!-- @include: ./b.md -->\n',
|
||||
{},
|
||||
{ path: path.join(root, 'a.md') }
|
||||
)
|
||||
expect(html).toContain('B-content')
|
||||
expect(html).toContain('@include: ./a.md')
|
||||
})
|
||||
|
||||
test('expands repeated includes outside the ancestor chain', async () => {
|
||||
await write('b.md', 'B-content\n\n<!-- @include: ./d.md -->\n')
|
||||
await write('c.md', 'C-content\n\n<!-- @include: ./d.md -->\n')
|
||||
await write('d.md', 'D-content\n')
|
||||
|
||||
const { html } = await render(
|
||||
'<!-- @include: ./b.md -->\n<!-- @include: ./c.md -->\n'
|
||||
)
|
||||
expect(html.match(/D-content/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('strips frontmatter of whole-file markdown includes', async () => {
|
||||
await write('b.md', '---\ntitle: B\n---\n\nB-content\n')
|
||||
|
||||
const { html } = await render('<!-- @include: ./b.md -->\n')
|
||||
expect(html).toContain('B-content')
|
||||
expect(html).not.toContain('title: B')
|
||||
})
|
||||
|
||||
test('keeps frontmatter lines in range-only includes', async () => {
|
||||
await write('b.md', '---\ntitle: B\n---\nline-4\nline-5\n')
|
||||
|
||||
const { html } = await render('<!-- @include: ./b.md{4,4} -->\n')
|
||||
expect(html).toContain('line-4')
|
||||
expect(html).not.toContain('line-5')
|
||||
})
|
||||
|
||||
test('includes regions and strips frontmatter before locating them', async () => {
|
||||
await write(
|
||||
'b.md',
|
||||
[
|
||||
'---',
|
||||
'title: B',
|
||||
'---',
|
||||
'<!-- #region part -->',
|
||||
'region-content',
|
||||
'<!-- #endregion part -->',
|
||||
'outside-content',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const { html } = await render('<!-- @include: ./b.md#part -->\n')
|
||||
expect(html).toContain('region-content')
|
||||
expect(html).not.toContain('outside-content')
|
||||
})
|
||||
|
||||
test('concatenates all regions with the requested name', async () => {
|
||||
await write(
|
||||
'b.md',
|
||||
[
|
||||
'<!-- #region part -->',
|
||||
'first',
|
||||
'<!-- #endregion part -->',
|
||||
'outside',
|
||||
'<!-- #region part -->',
|
||||
'second',
|
||||
'<!-- #endregion -->',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const { html } = await render('<!-- @include: ./b.md#part -->\n')
|
||||
expect(html).toContain('first')
|
||||
expect(html).toContain('second')
|
||||
expect(html).not.toContain('outside')
|
||||
})
|
||||
|
||||
test('applies ranges within the extracted region', async () => {
|
||||
await write(
|
||||
'b.md',
|
||||
[
|
||||
'<!-- #region part -->',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'<!-- #endregion part -->',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const { html } = await render('<!-- @include: ./b.md#part{2,2} -->\n')
|
||||
expect(html).toContain('two')
|
||||
expect(html).not.toContain('one')
|
||||
expect(html).not.toContain('three')
|
||||
})
|
||||
|
||||
test('supports ranges with open ends', async () => {
|
||||
await write('b.md', 'one\ntwo\nthree\n')
|
||||
|
||||
const from = await render('<!-- @include: ./b.md{2,} -->\n')
|
||||
expect(from.html).toContain('two')
|
||||
expect(from.html).toContain('three')
|
||||
expect(from.html).not.toContain('one')
|
||||
|
||||
const to = await render('<!-- @include: ./b.md{,2} -->\n')
|
||||
expect(to.html).toContain('one')
|
||||
expect(to.html).toContain('two')
|
||||
expect(to.html).not.toContain('three')
|
||||
|
||||
const both = await render('<!-- @include: ./b.md{2,3} -->\n')
|
||||
expect(both.html).toContain('two')
|
||||
expect(both.html).toContain('three')
|
||||
expect(both.html).not.toContain('one')
|
||||
})
|
||||
|
||||
test('includes heading sections by anchor', async () => {
|
||||
await write(
|
||||
'source.md',
|
||||
[
|
||||
'---',
|
||||
'description: Source description',
|
||||
'---',
|
||||
'# Intro',
|
||||
'',
|
||||
'intro text',
|
||||
'',
|
||||
'## Shared',
|
||||
'',
|
||||
'shared before target',
|
||||
'',
|
||||
'## Target',
|
||||
'',
|
||||
'target text',
|
||||
'',
|
||||
'### Child',
|
||||
'',
|
||||
'child text',
|
||||
'',
|
||||
'## Shared',
|
||||
'',
|
||||
'shared after target',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const { html } = await render('<!-- @include: ./source.md#target -->\n')
|
||||
expect(html).toContain('target text')
|
||||
expect(html).toContain('child text')
|
||||
expect(html).not.toContain('Source description')
|
||||
expect(html).not.toContain('intro text')
|
||||
expect(html).not.toContain('shared before target')
|
||||
expect(html).not.toContain('shared after target')
|
||||
})
|
||||
|
||||
test('includes heading sections with custom ids up to EOF', async () => {
|
||||
await write(
|
||||
'source.md',
|
||||
['## My Section {#custom-id}', '', 'section text', ''].join('\n')
|
||||
)
|
||||
|
||||
const { html } = await render('<!-- @include: ./source.md#custom-id -->\n')
|
||||
expect(html).toContain('section text')
|
||||
})
|
||||
|
||||
test('includes non-markdown files verbatim, also inside fences', async () => {
|
||||
await write('code.ts', 'const a = 1\nconst b = 2\nconst c = 3\n')
|
||||
|
||||
const fenced = await render(
|
||||
'```ts\n<!-- @include: ./code.ts{2,3} -->\n```\n'
|
||||
)
|
||||
expect(fenced.html).toContain('language-ts')
|
||||
expect(fenced.html).toContain('const b = 2')
|
||||
expect(fenced.html).toContain('const c = 3')
|
||||
expect(fenced.html).not.toContain('const a = 1')
|
||||
})
|
||||
|
||||
test('includes regions of non-markdown files', async () => {
|
||||
await write(
|
||||
'code.ts',
|
||||
[
|
||||
'// #region part',
|
||||
'region line',
|
||||
'// #endregion part',
|
||||
'outside line',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const { html } = await render(
|
||||
'```ts\n<!-- @include: ./code.ts#part -->\n```\n'
|
||||
)
|
||||
expect(html).toContain('region line')
|
||||
expect(html).not.toContain('outside line')
|
||||
})
|
||||
|
||||
test('leaves empty include paths untouched', async () => {
|
||||
const { html } = await render('<!-- @include: -->\n')
|
||||
expect(html).toContain('@include:')
|
||||
})
|
||||
|
||||
test('skips expansion without a file path in env', async () => {
|
||||
disposeMdItInstance()
|
||||
const md = await createMarkdownRenderer(
|
||||
root,
|
||||
{ highlight: (code) => code },
|
||||
'/',
|
||||
logger
|
||||
)
|
||||
const html = await md.renderAsync('<!-- @include: ./b.md -->\n')
|
||||
expect(html).toContain('@include: ./b.md')
|
||||
})
|
||||
|
||||
test('can be disabled', async () => {
|
||||
await write('b.md', 'B-content\n')
|
||||
|
||||
const { html } = await render('<!-- @include: ./b.md -->\n', {
|
||||
include: false
|
||||
})
|
||||
expect(html).not.toContain('B-content')
|
||||
expect(html).toContain('@include: ./b.md')
|
||||
})
|
||||
|
||||
test('throws when the file is missing, recording it as a dependency', async () => {
|
||||
const env: MarkdownEnv = {
|
||||
path: path.join(root, 'index.md'),
|
||||
relativePath: 'index.md',
|
||||
cleanUrls: false,
|
||||
includes: []
|
||||
}
|
||||
disposeMdItInstance()
|
||||
const md = await createMarkdownRenderer(
|
||||
root,
|
||||
{ highlight: (code) => code },
|
||||
'/',
|
||||
logger
|
||||
)
|
||||
await expect(
|
||||
md.renderAsync('<!-- @include: ./missing.md -->\n', env)
|
||||
).rejects.toThrow(/Include file not found/)
|
||||
// the missing file is watched so that creating it recovers the page
|
||||
expect(env.includes).toEqual([slash(path.join(root, 'missing.md'))])
|
||||
})
|
||||
|
||||
test('throws when neither region nor heading matches', async () => {
|
||||
await write('b.md', '## Some Heading\n\ncontent\n')
|
||||
|
||||
await expect(render('<!-- @include: ./b.md#nope -->\n')).rejects.toThrow(
|
||||
/region or heading "nope" not found/i
|
||||
)
|
||||
})
|
||||
|
||||
test('throws when the range is out of bounds', async () => {
|
||||
await write('b.md', 'one\ntwo\nthree\n')
|
||||
|
||||
await expect(render('<!-- @include: ./b.md{10,20} -->\n')).rejects.toThrow(
|
||||
/range/i
|
||||
)
|
||||
await expect(render('<!-- @include: ./b.md{3,1} -->\n')).rejects.toThrow(
|
||||
/range/i
|
||||
)
|
||||
await expect(render('<!-- @include: ./b.md{0,2} -->\n')).rejects.toThrow(
|
||||
/range/i
|
||||
)
|
||||
})
|
||||
|
||||
test('silent mode renders nothing on errors and warns', async () => {
|
||||
await write('b.md', 'one\ntwo\n')
|
||||
|
||||
const missing = await render(
|
||||
'before\n\n<!-- @include: ./missing.md -->\n\nafter\n',
|
||||
{ include: { silent: true } }
|
||||
)
|
||||
expect(missing.html).toContain('before')
|
||||
expect(missing.html).toContain('after')
|
||||
expect(missing.html).not.toContain('@include')
|
||||
|
||||
const region = await render('<!-- @include: ./b.md#nope -->\n', {
|
||||
include: { silent: true }
|
||||
})
|
||||
expect(region.html).not.toContain('@include')
|
||||
|
||||
const range = await render('<!-- @include: ./b.md{5,9} -->\n', {
|
||||
include: { silent: true }
|
||||
})
|
||||
expect(range.html).not.toContain('@include')
|
||||
|
||||
expect(warnings).toHaveLength(3)
|
||||
expect(warnings[0]).toContain('missing.md')
|
||||
expect(warnings[1]).toContain('nope')
|
||||
expect(warnings[2]).toContain('b.md')
|
||||
})
|
||||
|
||||
test('keeps relative urls as is with rebaseRelativeUrls: false', async () => {
|
||||
await write('sub/part.md', '\n\n[link](./target.md)\n')
|
||||
|
||||
const { html } = await render('<!-- @include: ./sub/part.md -->\n', {
|
||||
include: { rebaseRelativeUrls: false }
|
||||
})
|
||||
expect(html).toContain('src="./img.png"')
|
||||
expect(html).toContain('href="./target.html"')
|
||||
expect(html).not.toContain('@include-')
|
||||
})
|
||||
|
||||
test('rebases relative urls inside included files by default', async () => {
|
||||
await write('sub/part.md', '\n\n[link](./target.md)\n')
|
||||
|
||||
const { html } = await render(
|
||||
'<!-- @include: ./sub/part.md -->\n\n[after](./after.md)\n'
|
||||
)
|
||||
expect(html).toContain('src="./sub/img.png"')
|
||||
expect(html).toContain('href="./sub/target.html"')
|
||||
// links outside the included content are unaffected
|
||||
expect(html).toContain('href="./after.html"')
|
||||
// the internal markers never reach the output
|
||||
expect(html).not.toContain('@include')
|
||||
})
|
||||
|
||||
test('rebases urls through nested includes', async () => {
|
||||
await write(
|
||||
'a/one.md',
|
||||
'one\n\n<!-- @include: ../b/two.md -->\n\n\n'
|
||||
)
|
||||
await write('b/two.md', '\n')
|
||||
|
||||
const { html } = await render('<!-- @include: ./a/one.md -->\n')
|
||||
expect(html).toContain('src="./b/two.png"')
|
||||
expect(html).toContain('src="./a/one.png"')
|
||||
})
|
||||
|
||||
test('rebases urls after an include ending with an html block', async () => {
|
||||
await write(
|
||||
'sub/part.md',
|
||||
'\n\n<div class="card">\ntail\n</div>\n'
|
||||
)
|
||||
|
||||
const { html } = await render(
|
||||
'<!-- @include: ./sub/part.md -->\n\n\n\n[after](./after.md)\n'
|
||||
)
|
||||
expect(html).toContain('src="./sub/inside.png"')
|
||||
// the stack must be popped even though the marker follows an html block
|
||||
expect(html).toContain('src="./after.png"')
|
||||
expect(html).toContain('href="./after.html"')
|
||||
expect(html).not.toContain('@include-')
|
||||
})
|
||||
|
||||
test('does not leak rebase markers into fenced includes', async () => {
|
||||
await write('sub/part.md', 'partial line\n')
|
||||
|
||||
const { html } = await render(
|
||||
'```md\n<!-- @include: ./sub/part.md -->\n```\n'
|
||||
)
|
||||
expect(html).toContain('partial line')
|
||||
expect(html).not.toContain('@include-')
|
||||
})
|
||||
|
||||
test('does not leak rebase markers for inline includes', async () => {
|
||||
await write('sub/part.md', 'partial line\n')
|
||||
|
||||
const { html } = await render(
|
||||
'before <!-- @include: ./sub/part.md --> after\n\n[link](./x.md)\n'
|
||||
)
|
||||
expect(html).toContain('partial line')
|
||||
expect(html).not.toContain('@include-')
|
||||
// an inline include leaves the surrounding page urls untouched
|
||||
expect(html).toContain('href="./x.html"')
|
||||
})
|
||||
|
||||
test('does not rebase absolute or external urls', async () => {
|
||||
await write(
|
||||
'sub/part.md',
|
||||
'[ext](https://example.com/x)\n\n[abs](/abs/target.md)\n'
|
||||
)
|
||||
|
||||
const { html } = await render('<!-- @include: ./sub/part.md -->\n')
|
||||
expect(html).toContain('href="https://example.com/x"')
|
||||
expect(html).toContain('href="/abs/target.html"')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,529 @@
|
||||
import {
|
||||
dedent,
|
||||
findRegions,
|
||||
markers,
|
||||
stripRegionMarkers
|
||||
} from 'node/markdown/regions'
|
||||
|
||||
const extract = (lines: string[], name: string) =>
|
||||
findRegions(lines, name)
|
||||
.flatMap((r) => lines.slice(r.start, r.end))
|
||||
.join('\n')
|
||||
|
||||
describe('node/markdown/regions', () => {
|
||||
describe('dedent', () => {
|
||||
test('keeps lines when 0-level is minimal', () => {
|
||||
expect(dedent(['fn main() {', ' println!("Hello");', '}'])).toEqual([
|
||||
'fn main() {',
|
||||
' println!("Hello");',
|
||||
'}'
|
||||
])
|
||||
})
|
||||
|
||||
test('removes the common minimal indent', () => {
|
||||
expect(dedent([' let a = {', ' value: 42', ' };'])).toEqual([
|
||||
'let a = {',
|
||||
' value: 42',
|
||||
'};'
|
||||
])
|
||||
})
|
||||
|
||||
test('dedents a single line', () => {
|
||||
expect(dedent([' let a = 42;'])).toEqual(['let a = 42;'])
|
||||
})
|
||||
|
||||
test('handles tabs', () => {
|
||||
expect(dedent(['\tlet a = {', '\t\tvalue: 42', '\t};'])).toEqual([
|
||||
'let a = {',
|
||||
'\tvalue: 42',
|
||||
'};'
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores blank lines when computing the minimal indent', () => {
|
||||
expect(dedent([' a', '', ' b'])).toEqual(['a', '', 'b'])
|
||||
})
|
||||
|
||||
test('keeps whitespace-only input as is', () => {
|
||||
expect(dedent(['', ' '])).toEqual(['', ' '])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findRegions', () => {
|
||||
it('returns no regions without markers', () => {
|
||||
const lines = ['function foo() {', ' console.log("hello");', '}']
|
||||
expect(findRegions(lines, 'foo')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores non-matching and prefix-matching region names', () => {
|
||||
const lines = [
|
||||
'// #region regionA',
|
||||
'some code here',
|
||||
'// #endregion regionA'
|
||||
]
|
||||
expect(findRegions(lines, 'regionC')).toHaveLength(0)
|
||||
expect(findRegions(lines, 'region')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns no regions for a start marker without a matching end', () => {
|
||||
const lines = [
|
||||
'// #region missingEnd',
|
||||
'console.log("inside region");',
|
||||
'console.log("still inside");'
|
||||
]
|
||||
expect(findRegions(lines, 'missingEnd')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns no regions for an end marker without a preceding start', () => {
|
||||
const lines = [
|
||||
'// #endregion ghostRegion',
|
||||
'console.log("stray end marker");'
|
||||
]
|
||||
expect(findRegions(lines, 'ghostRegion')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores non-marker lines containing the word region', () => {
|
||||
const lines = [
|
||||
'const region = "region"',
|
||||
'// #region hello',
|
||||
'const x = 1',
|
||||
'// endregion hello is mentioned here without a comment prefix'
|
||||
]
|
||||
expect(findRegions(lines, 'hello')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('detects C#-style markers', () => {
|
||||
const lines = [
|
||||
'Console.WriteLine("Before region");',
|
||||
'#region hello',
|
||||
'Console.WriteLine("Hello, World!");',
|
||||
'#endregion hello',
|
||||
'Console.WriteLine("After region");'
|
||||
]
|
||||
expect(extract(lines, 'hello')).toBe(
|
||||
'Console.WriteLine("Hello, World!");'
|
||||
)
|
||||
})
|
||||
|
||||
it('closes a named region with an anonymous end marker', () => {
|
||||
const lines = [
|
||||
'#region hello',
|
||||
'Console.WriteLine("Hello, World!");',
|
||||
'#endregion',
|
||||
'Console.WriteLine("After region");'
|
||||
]
|
||||
expect(extract(lines, 'hello')).toBe(
|
||||
'Console.WriteLine("Hello, World!");'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not close a region with a differently named end marker', () => {
|
||||
const lines = [
|
||||
'#region hello',
|
||||
'Console.WriteLine("Hello, World!");',
|
||||
'#endregion world'
|
||||
]
|
||||
expect(findRegions(lines, 'hello')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps indentation of indented markers and content', () => {
|
||||
const lines = [
|
||||
' #region hello',
|
||||
' Console.WriteLine("Hello, World!");',
|
||||
' #endregion hello'
|
||||
]
|
||||
expect(extract(lines, 'hello')).toBe(
|
||||
' Console.WriteLine("Hello, World!");'
|
||||
)
|
||||
})
|
||||
|
||||
it('detects double-slash markers with and without spacing', () => {
|
||||
const lines = [
|
||||
'let regexp: RegExp[] = [];',
|
||||
'// #region foo',
|
||||
'let start = -1;',
|
||||
'//#endregion foo'
|
||||
]
|
||||
expect(extract(lines, 'foo')).toBe('let start = -1;')
|
||||
})
|
||||
|
||||
it('detects hash-less double-slash markers like VS Code', () => {
|
||||
const lines = ['// region foo', 'let start = -1;', '// endregion foo']
|
||||
expect(extract(lines, 'foo')).toBe('let start = -1;')
|
||||
})
|
||||
|
||||
it('detects CSS-style markers', () => {
|
||||
const lines = [
|
||||
'/* #region foo */',
|
||||
' padding-left: 15px;',
|
||||
'/*#endregion foo*/'
|
||||
]
|
||||
expect(extract(lines, 'foo')).toBe(' padding-left: 15px;')
|
||||
})
|
||||
|
||||
it('detects HTML-style markers, with the hash optional', () => {
|
||||
const lines = [
|
||||
'<!-- #region foo -->',
|
||||
' <h1>Hello world</h1>',
|
||||
'<!--#endregion foo-->',
|
||||
'<!-- region bar -->',
|
||||
' <h2>Other</h2>',
|
||||
'<!-- endregion bar -->'
|
||||
]
|
||||
expect(extract(lines, 'foo')).toBe(' <h1>Hello world</h1>')
|
||||
expect(extract(lines, 'bar')).toBe(' <h2>Other</h2>')
|
||||
})
|
||||
|
||||
it('detects Visual Basic-style markers', () => {
|
||||
const lines = [
|
||||
'#Region VBRegion',
|
||||
' Console.WriteLine("Inside region")',
|
||||
'#End Region VBRegion'
|
||||
]
|
||||
expect(extract(lines, 'VBRegion')).toBe(
|
||||
' Console.WriteLine("Inside region")'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the quotes around a Visual Basic region name', () => {
|
||||
const lines = [
|
||||
'#Region "Quoted Name"',
|
||||
' Console.WriteLine("Inside region")',
|
||||
'#End Region',
|
||||
'#Region "Other"',
|
||||
' Console.WriteLine("Other region")',
|
||||
'#End Region "Other"'
|
||||
]
|
||||
expect(extract(lines, 'Quoted Name')).toBe(
|
||||
' Console.WriteLine("Inside region")'
|
||||
)
|
||||
expect(extract(lines, 'Other')).toBe(
|
||||
' Console.WriteLine("Other region")'
|
||||
)
|
||||
})
|
||||
|
||||
it('detects bat-style markers with case-insensitive REM', () => {
|
||||
const lines = [
|
||||
'@REM #region hello',
|
||||
'@ECHO OFF',
|
||||
'::#endregion hello',
|
||||
'echo out',
|
||||
'rem #region hello',
|
||||
'exit 0',
|
||||
'Rem #endregion hello'
|
||||
]
|
||||
expect(extract(lines, 'hello')).toBe('@ECHO OFF\nexit 0')
|
||||
})
|
||||
|
||||
it('detects dash-dash markers, which require the hash', () => {
|
||||
const lines = [
|
||||
'-- #region foo',
|
||||
'select 1;',
|
||||
'-- #endregion foo',
|
||||
'--#region bar',
|
||||
'select 2;',
|
||||
'--#endregion bar'
|
||||
]
|
||||
expect(extract(lines, 'foo')).toBe('select 1;')
|
||||
expect(extract(lines, 'bar')).toBe('select 2;')
|
||||
|
||||
// prose comments are not markers
|
||||
expect(findRegions(['-- region of interest', 'select 1;'], '')).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('detects pragma markers, allowing space after the hash', () => {
|
||||
const lines = [
|
||||
'#pragma region foo',
|
||||
'int a = 1;',
|
||||
'#pragma endregion foo',
|
||||
'# pragma region bar',
|
||||
'int b = 2;',
|
||||
'# pragma endregion bar'
|
||||
]
|
||||
expect(extract(lines, 'foo')).toBe('int a = 1;')
|
||||
expect(extract(lines, 'bar')).toBe('int b = 2;')
|
||||
})
|
||||
|
||||
it('detects paren-star markers', () => {
|
||||
const lines = ['(* #region foo *)', 'let a = 1', '(* #endregion foo *)']
|
||||
expect(extract(lines, 'foo')).toBe('let a = 1')
|
||||
})
|
||||
|
||||
it('detects shell and python style hash markers', () => {
|
||||
const lines = [
|
||||
'# region hello',
|
||||
'echo "inside"',
|
||||
'#\tendregion hello',
|
||||
'# #region hello',
|
||||
'exit 0',
|
||||
'# #endregion'
|
||||
]
|
||||
expect(extract(lines, 'hello')).toBe('echo "inside"\nexit 0')
|
||||
})
|
||||
|
||||
it('detects JSON key-style markers with two or more slashes', () => {
|
||||
const lines = [
|
||||
'{',
|
||||
' "// #region hello": "",',
|
||||
' "one": true,',
|
||||
' "//#endregion hello": "",',
|
||||
' "two": false,',
|
||||
' "/// #region hello": "",',
|
||||
' "three": true,',
|
||||
' "//// #endregion hello": ""',
|
||||
'}'
|
||||
]
|
||||
expect(extract(lines, 'hello')).toBe(' "one": true,\n "three": true,')
|
||||
})
|
||||
|
||||
it('concatenates multiple same-named regions in document order', () => {
|
||||
const lines = [
|
||||
'// #region hello',
|
||||
'first region content',
|
||||
'// #endregion hello',
|
||||
'other content',
|
||||
'// #region hello',
|
||||
'second region content',
|
||||
'// #endregion',
|
||||
'// #region hello',
|
||||
'third region content',
|
||||
'// #endregion hello'
|
||||
]
|
||||
expect(extract(lines, 'hello')).toBe(
|
||||
'first region content\nsecond region content\nthird region content'
|
||||
)
|
||||
})
|
||||
|
||||
it('merges same-named regions across different comment styles', () => {
|
||||
const lines = [
|
||||
'<template>',
|
||||
' <!-- #region shared -->',
|
||||
' <div>template part</div>',
|
||||
' <!-- #endregion shared -->',
|
||||
'</template>',
|
||||
'<script>',
|
||||
'// #region shared',
|
||||
'const scriptPart = true',
|
||||
'// #endregion shared',
|
||||
'/* #region shared */',
|
||||
'console.log(scriptPart)',
|
||||
'/* #endregion shared */',
|
||||
'</script>',
|
||||
'<style>',
|
||||
'/* #region shared */',
|
||||
'.style-part {}',
|
||||
'/* #endregion shared */',
|
||||
'</style>'
|
||||
]
|
||||
const regions = findRegions(lines, 'shared')
|
||||
expect(regions).toHaveLength(4)
|
||||
expect(extract(lines, 'shared')).toBe(
|
||||
[
|
||||
' <div>template part</div>',
|
||||
'const scriptPart = true',
|
||||
'console.log(scriptPart)',
|
||||
'.style-part {}'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks nesting of same-named regions across styles', () => {
|
||||
const lines = [
|
||||
'// #region foo',
|
||||
"console.log('double-slash only');",
|
||||
'/* #region foo */',
|
||||
"console.log('nested in both');",
|
||||
'// #endregion foo',
|
||||
"console.log('still in outer');",
|
||||
'/* #endregion foo */',
|
||||
"console.log('outside');"
|
||||
]
|
||||
const regions = findRegions(lines, 'foo')
|
||||
expect(regions).toHaveLength(1)
|
||||
expect(regions[0]).toMatchObject({ start: 1, end: 6 })
|
||||
})
|
||||
|
||||
it('closes the innermost open region with an anonymous end marker', () => {
|
||||
const lines = [
|
||||
'<!-- #region demo -->',
|
||||
'<template><div /></template>',
|
||||
'<script setup>',
|
||||
'// #region state',
|
||||
'const count = ref(0)',
|
||||
'// #endregion',
|
||||
'function inc() {}',
|
||||
'</script>',
|
||||
'<!-- #endregion demo -->'
|
||||
]
|
||||
expect(extract(lines, 'demo')).toBe(
|
||||
[
|
||||
'<template><div /></template>',
|
||||
'<script setup>',
|
||||
'// #region state',
|
||||
'const count = ref(0)',
|
||||
'// #endregion',
|
||||
'function inc() {}',
|
||||
'</script>'
|
||||
].join('\n')
|
||||
)
|
||||
expect(extract(lines, 'state')).toBe('const count = ref(0)')
|
||||
})
|
||||
|
||||
it('is not closed by an anonymous end marker of a nested region in a fence', () => {
|
||||
const lines = [
|
||||
'<!-- #region sample -->',
|
||||
'Use markers like this:',
|
||||
'',
|
||||
'```js',
|
||||
'// #region foo',
|
||||
'const a = 1',
|
||||
'// #endregion',
|
||||
'```',
|
||||
'<!-- #endregion sample -->'
|
||||
]
|
||||
expect(extract(lines, 'sample')).toBe(
|
||||
[
|
||||
'Use markers like this:',
|
||||
'',
|
||||
'```js',
|
||||
'// #region foo',
|
||||
'const a = 1',
|
||||
'// #endregion',
|
||||
'```'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('is closed by an anonymous end marker of its own style', () => {
|
||||
// a region documenting region syntax: the marker inside the fence is
|
||||
// never closed, and the anonymous end marker belongs to the region
|
||||
const lines = [
|
||||
'<!-- #region real -->',
|
||||
'How regions work:',
|
||||
'',
|
||||
'```js',
|
||||
'// #region example',
|
||||
'const a = 1',
|
||||
'```',
|
||||
'<!-- #endregion -->'
|
||||
]
|
||||
expect(extract(lines, 'real')).toBe(
|
||||
[
|
||||
'How regions work:',
|
||||
'',
|
||||
'```js',
|
||||
'// #region example',
|
||||
'const a = 1',
|
||||
'```'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores an anonymous end marker with nothing open in its style', () => {
|
||||
const lines = [
|
||||
'<!-- #region real -->',
|
||||
'// #endregion',
|
||||
'body',
|
||||
'<!-- #endregion -->'
|
||||
]
|
||||
expect(extract(lines, 'real')).toBe('// #endregion\nbody')
|
||||
})
|
||||
|
||||
it('tolerates an unclosed region nested inside the requested one', () => {
|
||||
const lines = [
|
||||
'// #region outer',
|
||||
'const a = 1',
|
||||
'// #region inner',
|
||||
'const b = 2',
|
||||
'// #endregion outer'
|
||||
]
|
||||
expect(extract(lines, 'outer')).toBe(
|
||||
['const a = 1', '// #region inner', 'const b = 2'].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps differently named nested regions verbatim', () => {
|
||||
const lines = [
|
||||
'// #region foo',
|
||||
"console.log('line before nested');",
|
||||
'// #region bar',
|
||||
"console.log('nested content');",
|
||||
'// #endregion bar',
|
||||
'// #endregion foo'
|
||||
]
|
||||
expect(extract(lines, 'foo')).toBe(
|
||||
[
|
||||
"console.log('line before nested');",
|
||||
'// #region bar',
|
||||
"console.log('nested content');",
|
||||
'// #endregion bar'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('supports special characters in region names', () => {
|
||||
const lines = [
|
||||
'// #region complex-name_123',
|
||||
'const x = 1;',
|
||||
'// #endregion complex-name_123',
|
||||
'// #region my.region',
|
||||
'const y = 2;',
|
||||
'// #endregion my.region'
|
||||
]
|
||||
expect(extract(lines, 'complex-name_123')).toBe('const x = 1;')
|
||||
expect(extract(lines, 'my.region')).toBe('const y = 2;')
|
||||
})
|
||||
|
||||
it('reports the marker style that opened each region', () => {
|
||||
const lines = ['// #region foo', 'const x = 1;', '// #endregion foo']
|
||||
const [region] = findRegions(lines, 'foo')
|
||||
expect(markers).toContain(region.marker)
|
||||
expect(region.marker.start.test('// #region other')).toBe(true)
|
||||
expect(region.marker.start.test('# region other')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripRegionMarkers', () => {
|
||||
it('strips marker lines of every style and name by default', () => {
|
||||
const lines = [
|
||||
'// #region name',
|
||||
'const a = 0;',
|
||||
'/* #region HELLO */',
|
||||
'const b = 0;',
|
||||
'//\t#endregion complex_name-123',
|
||||
'const c = 0;',
|
||||
'/*#endregion*/'
|
||||
]
|
||||
expect(stripRegionMarkers(lines)).toEqual([
|
||||
'const a = 0;',
|
||||
'const b = 0;',
|
||||
'const c = 0;'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps non-marker lines mentioning regions', () => {
|
||||
const lines = ['const region = "region"', 'let a = 1']
|
||||
expect(stripRegionMarkers(lines)).toEqual(lines)
|
||||
})
|
||||
|
||||
it('strips only the given marker styles when provided', () => {
|
||||
const lines = [
|
||||
'// #region a',
|
||||
'const x = 1',
|
||||
'// #endregion a',
|
||||
'# region b',
|
||||
'const y = 2',
|
||||
'# endregion b'
|
||||
]
|
||||
const [region] = findRegions(lines, 'a')
|
||||
expect(stripRegionMarkers(lines, [region.marker])).toEqual([
|
||||
'const x = 1',
|
||||
'# region b',
|
||||
'const y = 2',
|
||||
'# endregion b'
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,34 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { readFile, readTextFile, readTextFileSync } from 'node/utils/fs'
|
||||
|
||||
describe('node/utils/fs', () => {
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'vitepress-fs-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('readFile keeps line endings as is', async () => {
|
||||
const file = path.join(root, 'crlf.txt')
|
||||
await writeFile(file, 'a\r\nb\rc\nd')
|
||||
expect(await readFile(file)).toBe('a\r\nb\rc\nd')
|
||||
})
|
||||
|
||||
test('readTextFile normalizes CRLF and CR to LF', async () => {
|
||||
const file = path.join(root, 'crlf.txt')
|
||||
await writeFile(file, 'a\r\nb\rc\nd')
|
||||
expect(await readTextFile(file)).toBe('a\nb\nc\nd')
|
||||
})
|
||||
|
||||
test('readTextFileSync normalizes CRLF and CR to LF', async () => {
|
||||
const file = path.join(root, 'crlf.txt')
|
||||
await writeFile(file, 'a\r\nb\rc\nd')
|
||||
expect(readTextFileSync(file)).toBe('a\nb\nc\nd')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"node",
|
||||
"vitest/globals",
|
||||
"vite/client",
|
||||
"../../src/client/shims.d.ts"
|
||||
],
|
||||
"paths": {
|
||||
"client/*": ["../../src/client/*"],
|
||||
"node/*": ["../../src/node/*"],
|
||||
"shared/*": ["../../src/shared/*"],
|
||||
"vitepress": ["../../src/client/index.ts"],
|
||||
"vitepress/theme": ["../../theme.d.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue