Merge pull request #139 from pengzhanbo/code-block-collapsed-lines
Add code block support for collapsed lines
This commit is contained in:
commit
720cde6573
@ -20,12 +20,13 @@ Shiki 支持多种编程语言。
|
||||
[@vuepress/plugin-shiki](https://ecosystem.vuejs.press/zh/plugins/shiki.html) 两个代码高亮插件,
|
||||
提供了更为丰富的功能支持,包括:
|
||||
|
||||
- [代码行高亮](/guide/markdown/extensions/#在代码块中实现行高亮)
|
||||
- [代码聚焦](/guide/markdown/extensions/#代码块中聚焦)
|
||||
- [代码对比差异](/guide/markdown/extensions/#代码块中的颜色差异)
|
||||
- [代码高亮“错误”和“警告”](/guide/markdown/extensions/#高亮-错误-和-警告)
|
||||
- [代码词高亮](/guide/markdown/extensions/#代码块中-词高亮)
|
||||
- [twoslash](/guide/markdown/experiment/#twoslash) ,在代码块内提供内联类型提示。
|
||||
- [代码行高亮](../../guide/代码/特性支持.md#在代码块中实现行高亮)
|
||||
- [代码聚焦](../../guide/代码/特性支持.md#代码块中聚焦)
|
||||
- [代码对比差异](../../guide/代码/特性支持.md#代码块中的颜色差异)
|
||||
- [代码高亮“错误”和“警告”](../../guide/代码/特性支持.md#高亮-错误-和-警告)
|
||||
- [代码词高亮](../../guide/代码/特性支持.md#代码块中-词高亮)
|
||||
- [代码块折叠](../../guide/代码/特性支持.md#折叠代码块)
|
||||
- [twoslash](../../guide/代码/twoslash.md#twoslash) ,在代码块内提供内联类型提示。
|
||||
|
||||
默认配置:
|
||||
|
||||
@ -140,16 +141,18 @@ interface CopyCodeOptions {
|
||||
|
||||
### whitespace
|
||||
|
||||
- 类型: `boolean`
|
||||
- 类型: `boolean | 'all' | 'boundary' | 'trailing'`
|
||||
- 默认值: `false`
|
||||
|
||||
将空白字符(Tab 和空格)渲染为单独的标签(具有 tab 或 space 类名)。
|
||||
|
||||
效果:
|
||||
|
||||
```ts whitespace
|
||||
function block() {
|
||||
space()
|
||||
table()
|
||||
}
|
||||
```
|
||||
<!-- @include: ../../snippet/whitespace.snippet.md{18-24} -->
|
||||
|
||||
### collapseLines
|
||||
|
||||
- 类型: `boolean | number`
|
||||
- 默认值: `false`
|
||||
|
||||
将代码块折叠到指定行数。
|
||||
|
||||
@ -309,6 +309,8 @@ console.log(options.foo) // 这个不会被高亮显示
|
||||
|
||||
将空白字符(Tab 和空格)渲染为可见状态。
|
||||
|
||||
在 代码块 后面添加 `:whitespace`。
|
||||
|
||||
<!-- @include: ../../snippet/whitespace.snippet.md -->
|
||||
|
||||
还可以在 `theme.plugins.shiki` 中全局启用 `whitespace` 功能:
|
||||
@ -327,3 +329,158 @@ export default defineUserConfig({
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
全局启用时,可以使用 `:no-whitespace` 来单独为某一代码块禁用 `whitespace` 功能。
|
||||
|
||||
## 折叠代码块
|
||||
|
||||
有时候,代码块会很长,对于阅读其它部分的内容时,会显得很麻烦,影响阅读体验,这时候可以折叠代码块。
|
||||
|
||||
在 代码块 后面添加 `:collapsed-lines`,即可折叠代码块,默认从第 15 行开始折叠。
|
||||
|
||||
**输入:**
|
||||
|
||||
````txt
|
||||
```css :collapsed-lines
|
||||
html {
|
||||
margin: 0;
|
||||
background: black;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
... more code
|
||||
```
|
||||
````
|
||||
|
||||
**输出:**
|
||||
|
||||
```css :collapsed-lines
|
||||
html {
|
||||
margin: 0;
|
||||
background: black;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
/* the three main rows going down the page */
|
||||
|
||||
body > div {
|
||||
height: 25%;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
float: left;
|
||||
width: 25%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blowup {
|
||||
display: block;
|
||||
position: absolute;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.darken {
|
||||
opacity: 0.4;
|
||||
}
|
||||
```
|
||||
|
||||
还可以指定起始折叠行。`:collapsed-lines=10` 表示从第十行开始折叠。
|
||||
|
||||
**输入:**
|
||||
|
||||
````txt
|
||||
```css :collapsed-lines=10
|
||||
html {
|
||||
margin: 0;
|
||||
background: black;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
... more code
|
||||
```
|
||||
````
|
||||
|
||||
**输出:**
|
||||
|
||||
```css :collapsed-lines=10
|
||||
html {
|
||||
margin: 0;
|
||||
background: black;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
/* the three main rows going down the page */
|
||||
|
||||
body > div {
|
||||
height: 25%;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
float: left;
|
||||
width: 25%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blowup {
|
||||
display: block;
|
||||
position: absolute;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.darken {
|
||||
opacity: 0.4;
|
||||
}
|
||||
```
|
||||
|
||||
还可以在 `theme.plugins.shiki` 中全局启用 `collapsed-lines` 功能:
|
||||
|
||||
::: code-tabs
|
||||
@tab .vuepress/config.ts
|
||||
|
||||
```ts
|
||||
export default defineUserConfig({
|
||||
theme: plumeTheme({
|
||||
plugins: {
|
||||
shiki: { collapsedLines: true }
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
全局启用时,可以使用 `:no-collapsed-lines` 来单独为某一代码块禁用 `collapsed-lines` 功能。
|
||||
|
||||
@ -24,7 +24,7 @@ tags:
|
||||
|
||||
- 文件夹的名称将作为 `category` 即 __分类__。
|
||||
- 允许多级目录,子级目录将作为父目录对应的分类的子项。
|
||||
- 如果目录名称 在 [主题配置 notes](/vuepress-theme-plume/theme-config/#notes) 中声明用于 notes 文章管理,则默认不作为 分类目录。
|
||||
- 如果目录名称 在 [主题配置 notes](../config/notes配置.md) 中声明用于 notes 文章管理,则默认不作为 分类目录。
|
||||
|
||||
### 文件夹命名约定
|
||||
|
||||
@ -53,4 +53,4 @@ __example:__
|
||||
## 文章写作
|
||||
|
||||
你可以使用 `markdown` 语法开始在 `sourceDir` 下新建 `Markdown` 文件,编写你自己的文章了,
|
||||
关于 markdown 扩展的功能支持,请查看 [这个文档](/guide/markdown/extensions/)。
|
||||
关于 markdown 扩展的功能支持,请查看 [这个文档](./markdown/扩展.md)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
**输入:**
|
||||
|
||||
````
|
||||
```xml whitespace
|
||||
```xml :whitespace
|
||||
<catalog>
|
||||
<book>
|
||||
<title>Everyday Italian</title>
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
**输出:**
|
||||
|
||||
```xml whitespace
|
||||
```xml :whitespace :no-line-numbers
|
||||
<catalog>
|
||||
<book>
|
||||
<title>Everyday Italian</title>
|
||||
@ -28,7 +28,7 @@
|
||||
**输入:**
|
||||
|
||||
````
|
||||
```xml whitespace
|
||||
```xml :whitespace
|
||||
<catalog>
|
||||
<book>
|
||||
<title>Everyday Italian</title>
|
||||
@ -39,10 +39,31 @@
|
||||
|
||||
**输出:**
|
||||
|
||||
```xml whitespace
|
||||
```xml :whitespace :no-line-numbers
|
||||
<catalog>
|
||||
<book>
|
||||
<title>Everyday Italian</title>
|
||||
</book>
|
||||
</catalog>
|
||||
```
|
||||
|
||||
渲染所有的空格:
|
||||
|
||||
**输入:**
|
||||
|
||||
````
|
||||
```js :whitespace=all
|
||||
function foo( ) {
|
||||
return 'Hello World'
|
||||
}
|
||||
```
|
||||
```
|
||||
````
|
||||
|
||||
**输出:**
|
||||
|
||||
```js :whitespace=all :no-line-numbers
|
||||
function foo( ) {
|
||||
return 'Hello World'
|
||||
}
|
||||
```
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
export function useCollapsedLines({
|
||||
selector = 'div[class*="language-"] > .collapsed-lines',
|
||||
}: { selector?: string } = {}): void {
|
||||
useEventListener('click', (e) => {
|
||||
const el = e.target as HTMLElement
|
||||
if (el.matches(selector)) {
|
||||
const parent = el.parentElement
|
||||
if (parent?.classList.toggle('collapsed')) {
|
||||
parent.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,183 +0,0 @@
|
||||
import { colors as c, logger } from 'vuepress/utils'
|
||||
import { customAlphabet } from 'nanoid'
|
||||
import type { ShikiTransformer } from 'shiki'
|
||||
import {
|
||||
addClassToHast,
|
||||
bundledLanguages,
|
||||
createHighlighter,
|
||||
isPlainLang,
|
||||
isSpecialLang,
|
||||
} from 'shiki'
|
||||
import {
|
||||
transformerCompactLineOptions,
|
||||
transformerNotationDiff,
|
||||
transformerNotationErrorLevel,
|
||||
transformerNotationFocus,
|
||||
transformerNotationHighlight,
|
||||
transformerNotationWordHighlight,
|
||||
transformerRemoveNotationEscape,
|
||||
transformerRenderWhitespace,
|
||||
} from '@shikijs/transformers'
|
||||
import type { HighlighterOptions, ThemeOptions } from './types.js'
|
||||
import { attrsToLines, resolveLanguage } from './utils/index.js'
|
||||
import { defaultHoverInfoProcessor, transformerTwoslash } from './twoslash/rendererTransformer.js'
|
||||
|
||||
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)
|
||||
|
||||
const vueRE = /-vue$/
|
||||
const mustacheRE = /\{\{.*?\}\}/g
|
||||
const decorationsRE = /^\/\/ @decorations:(.*)\n/
|
||||
|
||||
export async function highlight(
|
||||
theme: ThemeOptions,
|
||||
options: HighlighterOptions,
|
||||
): Promise<(str: string, lang: string, attrs: string) => string> {
|
||||
const {
|
||||
defaultHighlightLang: defaultLang = '',
|
||||
codeTransformers: userTransformers = [],
|
||||
whitespace = false,
|
||||
languages = Object.keys(bundledLanguages),
|
||||
} = options
|
||||
|
||||
const highlighter = await createHighlighter({
|
||||
themes:
|
||||
typeof theme === 'object' && 'light' in theme && 'dark' in theme
|
||||
? [theme.light, theme.dark]
|
||||
: [theme],
|
||||
langs: languages,
|
||||
langAlias: options.languageAlias,
|
||||
})
|
||||
|
||||
await options?.shikiSetup?.(highlighter)
|
||||
|
||||
const transformers: ShikiTransformer[] = [
|
||||
transformerNotationDiff(),
|
||||
transformerNotationFocus({
|
||||
classActiveLine: 'has-focus',
|
||||
classActivePre: 'has-focused-lines',
|
||||
}),
|
||||
transformerNotationHighlight(),
|
||||
transformerNotationErrorLevel(),
|
||||
transformerNotationWordHighlight(),
|
||||
{
|
||||
name: 'vuepress:add-class',
|
||||
pre(node) {
|
||||
addClassToHast(node, 'vp-code')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vuepress:clean-up',
|
||||
pre(node) {
|
||||
delete node.properties.tabindex
|
||||
delete node.properties.style
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'shiki:inline-decorations',
|
||||
preprocess(code, options) {
|
||||
code = code.replace(decorationsRE, (match, decorations) => {
|
||||
options.decorations ||= []
|
||||
options.decorations.push(...JSON.parse(decorations))
|
||||
return ''
|
||||
})
|
||||
return code
|
||||
},
|
||||
},
|
||||
transformerRemoveNotationEscape(),
|
||||
]
|
||||
|
||||
const loadedLanguages = highlighter.getLoadedLanguages()
|
||||
|
||||
return (str: string, language: string, attrs: string) => {
|
||||
attrs = attrs || ''
|
||||
let lang = resolveLanguage(language) || defaultLang
|
||||
const vPre = vueRE.test(lang) ? '' : 'v-pre'
|
||||
|
||||
if (lang) {
|
||||
const langLoaded = loadedLanguages.includes(lang as any)
|
||||
if (!langLoaded && !isPlainLang(lang) && !isSpecialLang(lang)) {
|
||||
logger.warn(
|
||||
c.yellow(
|
||||
`\nThe language '${lang}' is not loaded, falling back to '${defaultLang || 'txt'
|
||||
}' for syntax highlighting.`,
|
||||
),
|
||||
)
|
||||
lang = defaultLang
|
||||
}
|
||||
}
|
||||
// const { attrs: attributes, rawAttrs } = resolveAttrs(attrs || '')
|
||||
const enabledTwoslash = attrs.includes('twoslash')
|
||||
const mustaches = new Map<string, string>()
|
||||
|
||||
const removeMustache = (s: string) => {
|
||||
return s.replace(mustacheRE, (match) => {
|
||||
let marker = mustaches.get(match)
|
||||
if (!marker) {
|
||||
marker = nanoid()
|
||||
mustaches.set(match, marker)
|
||||
}
|
||||
return marker
|
||||
})
|
||||
}
|
||||
|
||||
const restoreMustache = (s: string) => {
|
||||
mustaches.forEach((marker, match) => {
|
||||
s = s.replaceAll(marker, match)
|
||||
})
|
||||
|
||||
if (enabledTwoslash && options.twoslash)
|
||||
s = s.replace(/\{/g, '{')
|
||||
|
||||
return `${s}\n`
|
||||
}
|
||||
|
||||
str = removeMustache(str).trimEnd()
|
||||
|
||||
const inlineTransformers: ShikiTransformer[] = [
|
||||
transformerCompactLineOptions(attrsToLines(attrs)),
|
||||
]
|
||||
|
||||
if (enabledTwoslash && options.twoslash) {
|
||||
inlineTransformers.push(transformerTwoslash({
|
||||
processHoverInfo(info) {
|
||||
return defaultHoverInfoProcessor(info)
|
||||
},
|
||||
}))
|
||||
}
|
||||
else {
|
||||
inlineTransformers.push({
|
||||
name: 'vuepress:v-pre',
|
||||
pre(node) {
|
||||
if (vPre)
|
||||
node.properties['v-pre'] = ''
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (attrs.includes('whitespace') || whitespace)
|
||||
inlineTransformers.push(transformerRenderWhitespace({ position: 'boundary' }))
|
||||
|
||||
try {
|
||||
const highlighted = highlighter.codeToHtml(str, {
|
||||
lang,
|
||||
transformers: [
|
||||
...transformers,
|
||||
...inlineTransformers,
|
||||
...userTransformers,
|
||||
],
|
||||
meta: { __raw: attrs },
|
||||
...(typeof theme === 'object' && 'light' in theme && 'dark' in theme
|
||||
? { themes: theme, defaultColor: false }
|
||||
: { theme }),
|
||||
})
|
||||
|
||||
const rendered = restoreMustache(highlighted)
|
||||
|
||||
return rendered
|
||||
}
|
||||
catch (e) {
|
||||
logger.error(e)
|
||||
return str
|
||||
}
|
||||
}
|
||||
}
|
||||
25
plugins/plugin-shikiji/src/node/highlight/getLanguage.ts
Normal file
25
plugins/plugin-shikiji/src/node/highlight/getLanguage.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { isPlainLang, isSpecialLang } from 'shiki'
|
||||
import { colors as c, logger } from 'vuepress/utils'
|
||||
import { resolveLanguage } from '../utils/index.js'
|
||||
|
||||
export function getLanguage(
|
||||
loadedLanguages: string[],
|
||||
language: string,
|
||||
defaultLang: string,
|
||||
): string {
|
||||
let lang = resolveLanguage(language) || defaultLang
|
||||
|
||||
if (lang) {
|
||||
const langLoaded = loadedLanguages.includes(lang as any)
|
||||
if (!langLoaded && !isPlainLang(lang) && !isSpecialLang(lang)) {
|
||||
logger.warn(
|
||||
c.yellow(
|
||||
`\nThe language '${lang}' is not loaded, falling back to '${defaultLang || 'txt'
|
||||
}' for syntax highlighting.`,
|
||||
),
|
||||
)
|
||||
lang = defaultLang
|
||||
}
|
||||
}
|
||||
return lang
|
||||
}
|
||||
87
plugins/plugin-shikiji/src/node/highlight/highlight.ts
Normal file
87
plugins/plugin-shikiji/src/node/highlight/highlight.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { logger } from 'vuepress/utils'
|
||||
import { customAlphabet } from 'nanoid'
|
||||
import { bundledLanguages, createHighlighter } from 'shiki'
|
||||
import type { HighlighterOptions, ThemeOptions } from '../types.js'
|
||||
import { baseTransformers, getInlineTransformers } from './transformers.js'
|
||||
import { getLanguage } from './getLanguage.js'
|
||||
|
||||
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)
|
||||
const mustacheRE = /\{\{.*?\}\}/g
|
||||
|
||||
export async function highlight(
|
||||
theme: ThemeOptions,
|
||||
options: HighlighterOptions,
|
||||
): Promise<(str: string, lang: string, attrs: string) => string> {
|
||||
const {
|
||||
defaultHighlightLang: defaultLang = '',
|
||||
codeTransformers: userTransformers = [],
|
||||
whitespace = false,
|
||||
languages = Object.keys(bundledLanguages),
|
||||
} = options
|
||||
|
||||
const highlighter = await createHighlighter({
|
||||
themes:
|
||||
typeof theme === 'object' && 'light' in theme && 'dark' in theme
|
||||
? [theme.light, theme.dark]
|
||||
: [theme],
|
||||
langs: languages,
|
||||
langAlias: options.languageAlias,
|
||||
})
|
||||
|
||||
await options.shikiSetup?.(highlighter)
|
||||
|
||||
const loadedLanguages = highlighter.getLoadedLanguages()
|
||||
const removeMustache = (s: string, mustaches: Map<string, string>) => {
|
||||
return s.replace(mustacheRE, (match) => {
|
||||
let marker = mustaches.get(match)
|
||||
if (!marker) {
|
||||
marker = nanoid()
|
||||
mustaches.set(match, marker)
|
||||
}
|
||||
return marker
|
||||
})
|
||||
}
|
||||
|
||||
const restoreMustache = (s: string, mustaches: Map<string, string>, twoslash: boolean) => {
|
||||
mustaches.forEach((marker, match) => {
|
||||
s = s.replaceAll(marker, match)
|
||||
})
|
||||
|
||||
if (twoslash)
|
||||
s = s.replace(/\{/g, '{')
|
||||
|
||||
return `${s}\n`
|
||||
}
|
||||
|
||||
return (str: string, language: string, attrs: string = '') => {
|
||||
const lang = getLanguage(loadedLanguages, language, defaultLang)
|
||||
|
||||
const enabledTwoslash = attrs.includes('twoslash') && !!options.twoslash
|
||||
|
||||
const mustaches = new Map<string, string>()
|
||||
str = removeMustache(str, mustaches).trimEnd()
|
||||
|
||||
try {
|
||||
const highlighted = highlighter.codeToHtml(str, {
|
||||
lang,
|
||||
transformers: [
|
||||
...baseTransformers,
|
||||
...getInlineTransformers({ attrs, lang, enabledTwoslash, whitespace }),
|
||||
...userTransformers,
|
||||
],
|
||||
meta: { __raw: attrs },
|
||||
...(typeof theme === 'object' && 'light' in theme && 'dark' in theme
|
||||
? { themes: theme, defaultColor: false }
|
||||
: { theme }),
|
||||
})
|
||||
|
||||
const rendered = restoreMustache(highlighted, mustaches, enabledTwoslash)
|
||||
|
||||
return rendered
|
||||
}
|
||||
catch (e) {
|
||||
logger.error(e)
|
||||
return str
|
||||
}
|
||||
}
|
||||
}
|
||||
1
plugins/plugin-shikiji/src/node/highlight/index.ts
Normal file
1
plugins/plugin-shikiji/src/node/highlight/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './highlight.js'
|
||||
89
plugins/plugin-shikiji/src/node/highlight/transformers.ts
Normal file
89
plugins/plugin-shikiji/src/node/highlight/transformers.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type { ShikiTransformer } from 'shiki'
|
||||
import { addClassToHast } from 'shiki'
|
||||
import {
|
||||
transformerCompactLineOptions,
|
||||
transformerNotationDiff,
|
||||
transformerNotationErrorLevel,
|
||||
transformerNotationFocus,
|
||||
transformerNotationHighlight,
|
||||
transformerNotationWordHighlight,
|
||||
transformerRemoveNotationEscape,
|
||||
transformerRenderWhitespace,
|
||||
} from '@shikijs/transformers'
|
||||
import type { WhitespacePosition } from '../utils/index.js'
|
||||
import { attrsToLines, resolveWhitespacePosition } from '../utils/index.js'
|
||||
import { defaultHoverInfoProcessor, transformerTwoslash } from '../twoslash/rendererTransformer.js'
|
||||
|
||||
const decorationsRE = /^\/\/ @decorations:(.*)\n/
|
||||
|
||||
export const baseTransformers: ShikiTransformer[] = [
|
||||
transformerNotationDiff(),
|
||||
transformerNotationFocus({
|
||||
classActiveLine: 'has-focus',
|
||||
classActivePre: 'has-focused-lines',
|
||||
}),
|
||||
transformerNotationHighlight(),
|
||||
transformerNotationErrorLevel(),
|
||||
transformerNotationWordHighlight(),
|
||||
{
|
||||
name: 'vuepress:add-class',
|
||||
pre(node) {
|
||||
addClassToHast(node, 'vp-code')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vuepress:clean-up',
|
||||
pre(node) {
|
||||
delete node.properties.tabindex
|
||||
delete node.properties.style
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'shiki:inline-decorations',
|
||||
preprocess(code, options) {
|
||||
code = code.replace(decorationsRE, (match, decorations) => {
|
||||
options.decorations ||= []
|
||||
options.decorations.push(...JSON.parse(decorations))
|
||||
return ''
|
||||
})
|
||||
return code
|
||||
},
|
||||
},
|
||||
transformerRemoveNotationEscape(),
|
||||
]
|
||||
|
||||
const vueRE = /-vue$/
|
||||
export function getInlineTransformers({ attrs, lang, enabledTwoslash, whitespace }: {
|
||||
attrs: string
|
||||
lang: string
|
||||
enabledTwoslash: boolean
|
||||
whitespace: boolean | WhitespacePosition
|
||||
}): ShikiTransformer[] {
|
||||
const vPre = vueRE.test(lang) ? '' : 'v-pre'
|
||||
const inlineTransformers: ShikiTransformer[] = [
|
||||
transformerCompactLineOptions(attrsToLines(attrs)),
|
||||
]
|
||||
|
||||
if (enabledTwoslash) {
|
||||
inlineTransformers.push(transformerTwoslash({
|
||||
processHoverInfo(info) {
|
||||
return defaultHoverInfoProcessor(info)
|
||||
},
|
||||
}))
|
||||
}
|
||||
else {
|
||||
inlineTransformers.push({
|
||||
name: 'vuepress:v-pre',
|
||||
pre(node) {
|
||||
if (vPre)
|
||||
node.properties['v-pre'] = ''
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const position = resolveWhitespacePosition(attrs, whitespace)
|
||||
if (position)
|
||||
inlineTransformers.push(transformerRenderWhitespace({ position }))
|
||||
|
||||
return inlineTransformers
|
||||
}
|
||||
@ -2,9 +2,12 @@
|
||||
// v-pre block logic is in `../highlight.ts`
|
||||
import type { Markdown } from 'vuepress/markdown'
|
||||
import type { PreWrapperOptions } from '../types.js'
|
||||
import { resolveAttr, resolveLanguage } from '../utils/index.js'
|
||||
import { resolveAttr, resolveCollapsedLines, resolveLanguage } from '../utils/index.js'
|
||||
|
||||
export function preWrapperPlugin(md: Markdown, { preWrapper = true }: PreWrapperOptions = {}): void {
|
||||
export function preWrapperPlugin(
|
||||
md: Markdown,
|
||||
{ preWrapper = true, collapsedLines = false }: PreWrapperOptions = {},
|
||||
): void {
|
||||
const rawFence = md.renderer.rules.fence!
|
||||
|
||||
md.renderer.rules.fence = (...args) => {
|
||||
@ -16,17 +19,27 @@ export function preWrapperPlugin(md: Markdown, { preWrapper = true }: PreWrapper
|
||||
|
||||
const lang = resolveLanguage(info)
|
||||
const title = resolveAttr(info, 'title') || lang
|
||||
const languageClass = `${options.langPrefix}${lang}`
|
||||
const classes: string[] = [`${options.langPrefix}${lang}`]
|
||||
|
||||
let result = rawFence(...args)
|
||||
|
||||
if (!preWrapper) {
|
||||
// remove `<code>` attributes
|
||||
result = result.replace(/<code[\s\S]*?>/, '<code>')
|
||||
result = `<pre class="${languageClass}"${result.slice('<pre'.length)}`
|
||||
result = `<pre class="${classes.join(' ')}"${result.slice('<pre'.length)}`
|
||||
return result
|
||||
}
|
||||
const attrs: string[] = [
|
||||
`data-ext="${lang}"`,
|
||||
`data-title="${title}"`,
|
||||
]
|
||||
const collapsed = resolveCollapsedLines(info, collapsedLines)
|
||||
if (collapsed) {
|
||||
classes.push('has-collapsed', 'collapsed')
|
||||
attrs.push(`style="--vp-collapsed-lines:${collapsed}"`)
|
||||
result += `<div class="collapsed-lines"></div>`
|
||||
}
|
||||
|
||||
return `<div class="${languageClass}" data-ext="${lang}" data-title="${title}">${result}</div>`
|
||||
return `<div class="${classes.join(' ')}" ${attrs.join(' ')}>${result}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ export async function prepareClientConfigFile(app: App, {
|
||||
`\
|
||||
${twoslash ? `import { enhanceTwoslash } from '${CLIENT_FOLDER}composables/twoslash.js'` : ''}
|
||||
${copyCode ? `import { useCopyCode } from '${CLIENT_FOLDER}composables/copy-code.js'` : ''}
|
||||
import { useCollapsedLines } from '${CLIENT_FOLDER}composables/collapsed-lines.js'
|
||||
|
||||
export default {
|
||||
${twoslash
|
||||
@ -30,6 +31,7 @@ export default {
|
||||
selector: __CC_SELECTOR__,
|
||||
duration: __CC_DURATION__,
|
||||
})
|
||||
useCollapsedLines()
|
||||
},`
|
||||
: ''}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { Plugin } from 'vuepress/core'
|
||||
import { getDirname } from 'vuepress/utils'
|
||||
import { isPlainObject } from 'vuepress/shared'
|
||||
import { highlight } from './highlight.js'
|
||||
import { highlight } from './highlight/index.js'
|
||||
import type {
|
||||
CopyCodeOptions,
|
||||
HighlighterOptions,
|
||||
@ -16,7 +15,8 @@ import {
|
||||
import { copyCodeButtonPlugin } from './copy-code-button/index.js'
|
||||
import { prepareClientConfigFile } from './prepareClientConfigFile.js'
|
||||
|
||||
export interface ShikiPluginOptions extends HighlighterOptions, LineNumberOptions, PreWrapperOptions {
|
||||
export interface ShikiPluginOptions
|
||||
extends HighlighterOptions, LineNumberOptions, PreWrapperOptions {
|
||||
/**
|
||||
* Add copy code button
|
||||
*
|
||||
@ -25,12 +25,11 @@ export interface ShikiPluginOptions extends HighlighterOptions, LineNumberOption
|
||||
copyCode?: boolean | CopyCodeOptions
|
||||
}
|
||||
|
||||
const __dirname = getDirname(import.meta.url)
|
||||
|
||||
export function shikiPlugin({
|
||||
preWrapper = true,
|
||||
lineNumbers = true,
|
||||
copyCode = true,
|
||||
collapsedLines = false,
|
||||
...options
|
||||
}: ShikiPluginOptions = {}): Plugin {
|
||||
const copyCodeOptions: CopyCodeOptions = isPlainObject(copyCode) ? copyCode : {}
|
||||
@ -54,9 +53,11 @@ export function shikiPlugin({
|
||||
md.options.highlight = await highlight(theme, options)
|
||||
|
||||
md.use(highlightLinesPlugin)
|
||||
md.use<PreWrapperOptions>(preWrapperPlugin, {
|
||||
md.use(preWrapperPlugin, {
|
||||
preWrapper,
|
||||
collapsedLines,
|
||||
})
|
||||
|
||||
if (preWrapper) {
|
||||
copyCodeButtonPlugin(md, app, copyCode)
|
||||
md.use<LineNumberOptions>(lineNumberPlugin, { lineNumbers })
|
||||
|
||||
@ -79,7 +79,7 @@ export interface HighlighterOptions {
|
||||
* Enable transformerRenderWhitespace
|
||||
* @default false
|
||||
*/
|
||||
whitespace?: boolean
|
||||
whitespace?: boolean | 'all' | 'boundary' | 'trailing'
|
||||
}
|
||||
|
||||
export interface LineNumberOptions {
|
||||
@ -99,6 +99,15 @@ export interface PreWrapperOptions {
|
||||
* - Required for title display of default theme
|
||||
*/
|
||||
preWrapper?: boolean
|
||||
|
||||
/**
|
||||
* Hide extra rows when exceeding a specific number of lines.
|
||||
*
|
||||
* `true` is equivalent to `15` .
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
collapsedLines?: number | boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
18
plugins/plugin-shikiji/src/node/utils/collapsedLines.ts
Normal file
18
plugins/plugin-shikiji/src/node/utils/collapsedLines.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export const COLLAPSED_LINES_REGEXP = /:collapsed-lines(?:=(\d+))?\b/
|
||||
export const NO_COLLAPSED_LINES_REGEXP = /:no-collapsed-lines\b/
|
||||
|
||||
const DEFAULT_LINES = 15
|
||||
|
||||
export function resolveCollapsedLines(info: string, defaultLines: boolean | number): number | false {
|
||||
if (NO_COLLAPSED_LINES_REGEXP.test(info))
|
||||
return false
|
||||
|
||||
const lines = defaultLines === true ? DEFAULT_LINES : defaultLines
|
||||
|
||||
const match = info.match(COLLAPSED_LINES_REGEXP)
|
||||
|
||||
if (match) {
|
||||
return Number(match[1]) || lines || DEFAULT_LINES
|
||||
}
|
||||
return lines ?? false
|
||||
}
|
||||
@ -2,3 +2,5 @@ export * from './attrsToLines.js'
|
||||
export * from './resolveAttr.js'
|
||||
export * from './resolveLanguage.js'
|
||||
export * from './lru.js'
|
||||
export * from './whitespace.js'
|
||||
export * from './collapsedLines.js'
|
||||
|
||||
18
plugins/plugin-shikiji/src/node/utils/whitespace.ts
Normal file
18
plugins/plugin-shikiji/src/node/utils/whitespace.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export const WHITESPACE_REGEXP = /:whitespace(?:=(all|boundary|trailing)?)?\b/
|
||||
export const NO_WHITESPACE_REGEXP = /:no-whitespace\b/
|
||||
|
||||
export type WhitespacePosition = 'all' | 'boundary' | 'trailing'
|
||||
|
||||
export function resolveWhitespacePosition(info: string, defaultPosition?: boolean | WhitespacePosition): WhitespacePosition | false {
|
||||
if (NO_WHITESPACE_REGEXP.test(info)) {
|
||||
return false
|
||||
}
|
||||
|
||||
defaultPosition = defaultPosition === true ? undefined : defaultPosition
|
||||
|
||||
const match = info.match(WHITESPACE_REGEXP)
|
||||
if (match) {
|
||||
return (match[1] || defaultPosition || 'all') as WhitespacePosition
|
||||
}
|
||||
return defaultPosition ?? false
|
||||
}
|
||||
@ -21,6 +21,7 @@ export default defineConfig(() => {
|
||||
entry: [
|
||||
'copy-code.ts',
|
||||
'twoslash.ts',
|
||||
'collapsed-lines.ts',
|
||||
].map(file => `./src/client/composables/${file}`),
|
||||
outDir: './lib/client/composables',
|
||||
external: [/.*\.css$/],
|
||||
|
||||
@ -310,3 +310,85 @@ html:not(.dark) .vp-code span {
|
||||
/* rtl:ignore */
|
||||
transform: translateX(calc(-100% - 1px));
|
||||
}
|
||||
|
||||
/*
|
||||
Collapsed lines
|
||||
--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
.vp-doc div[class*="language-"].has-collapsed.collapsed {
|
||||
height: calc(var(--vp-collapsed-lines) * var(--vp-code-line-height) * var(--vp-code-font-size) + 62px);
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@property --vp-code-bg-collapsed-lines {
|
||||
inherits: false;
|
||||
initial-value: #fff;
|
||||
syntax: "<color>";
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"].has-collapsed .collapsed-lines {
|
||||
--vp-code-bg-collapsed-lines: var(--vp-code-block-bg);
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(to bottom, transparent 0%, var(--vp-code-bg-collapsed-lines) 50%, var(--vp-code-bg-collapsed-lines) 100%);
|
||||
transition: --vp-code-bg-collapsed-lines var(--t-color);
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"].has-collapsed .collapsed-lines:hover {
|
||||
--vp-code-bg-collapsed-lines: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"].has-collapsed .collapsed-lines::before {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-width='2' d='m18 12l-6 6l-6-6m12-6l-6 6l-6-6'/%3E%3C/svg%3E");
|
||||
--trans-rotate: 0deg;
|
||||
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background-color: var(--vp-code-block-color);
|
||||
-webkit-mask-image: var(--icon);
|
||||
mask-image: var(--icon);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: 50%;
|
||||
mask-position: 50%;
|
||||
-webkit-mask-size: 20px;
|
||||
mask-size: 20px;
|
||||
animation: code-collapsed-lines 1.2s infinite alternate-reverse ease-in-out;
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"].has-collapsed:not(.collapsed) code {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"].has-collapsed:not(.collapsed) .collapsed-lines:hover {
|
||||
--vp-code-bg-collapsed-lines: transparent;
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"].has-collapsed:not(.collapsed) .collapsed-lines::before {
|
||||
--trans-rotate: 180deg;
|
||||
}
|
||||
|
||||
@keyframes code-collapsed-lines {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(-2px) rotate(var(--trans-rotate));
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(2px) rotate(var(--trans-rotate));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user