feat: add import code & hightlint line & code group

This commit is contained in:
YunYouJun 2023-10-05 02:31:23 +08:00
parent 56ad1dc824
commit 0862c12d8c
22 changed files with 921 additions and 155 deletions

View File

@ -1,50 +0,0 @@
---
title_zh-CN: 容器
title: Container
categories:
- guide
end: false
---
::: zh-CN
通过对 `markdownIt` 进行配置,你可以自由设置自定义块区域的文字以及图标及图标的颜色。
:::
::: en
By configuring `markdownIt`, you can set the text and icon (and its color) for
custom block.
:::
::: tip
tip
:::
::: warning
warning
:::
::: danger
danger
:::
::: info
info
:::
::: details Click to expand
details
:::

View File

@ -0,0 +1,419 @@
---
title_zh-CN: Markdown 扩展
title: Markdown Extensions
categories:
- guide
end: false
---
::: info
`Hexo` 不同,`Valaxy` 在框架层面实现了一些 Markdown 扩展(如 Container、数学公式而无需主题开发者再次实现。
这与 `VitePress` 许多功能类似,`Valaxy` 从 `VitePress` 中借鉴了许多,并复用了 [mdit-vue](https://github.com/mdit-vue/mdit-vue) 的插件。
但也存在一些不同之处,此前当 `Valaxy` 实现数学公式时 `VitePress` 尚未支持,目前 `Valaxy` 默认的数学公式基于 KaTeX`VitePress` 基于 MathJax。
> KaTeX 相对于 MathJax 有更快的渲染速度MathJax 则拥有更多的功能。
当然,你仍然可以在 Valaxy 中通过添加 MarkdownIt 插件来实现更多功能。
:::
## Emoji :tada:
**Input**
```
:tada: :100:
```
**Output**
:tada: :100:
A [list of all emojis](https://github.com/markdown-it/markdown-it-emoji/blob/master/lib/data/full.json) is available.
## Table of Contents
**Input**
```
[[toc]]
```
**Output**
[[toc]]
Rendering of the TOC can be configured using the `markdown.toc` option.
## 代码行高亮
**Input**
````
```js{4}
export default {
data () {
return {
msg: 'Highlighted!'
}
}
}
```
````
**Output**
```js{4}
export default {
data () {
return {
msg: 'Highlighted!'
}
}
}
```
**Input**
````md
```ts {1}
// line-numbers is disabled by default
const line2 = 'This is line 2'
const line3 = 'This is line 3'
```
```ts:line-numbers {1}
// line-numbers is enabled
const line2 = 'This is line 2'
const line3 = 'This is line 3'
```
```ts:line-numbers=2 {1}
// line-numbers is enabled and start from 2
const line3 = 'This is line 3'
const line4 = 'This is line 4'
```
````
**Output**
```ts {1}
// line-numbers is disabled by default
const line2 = 'This is line 2'
const line3 = 'This is line 3'
```
```ts:line-numbers {1}
// line-numbers is enabled
const line2 = 'This is line 2'
const line3 = 'This is line 3'
```
```ts:line-numbers=2 {1}
// line-numbers is enabled and start from 2
const line3 = 'This is line 3'
const line4 = 'This is line 4'
````
## Colored Diffs in Code Blocks
Adding the `// [!code --]` or `// [!code ++]` comments on a line will create a diff of that line, while keeping the colors of the codeblock.
**Input**
Note that only one space is required after `!code`, here are two to prevent processing.
````
```js
export default {
data () {
return {
msg: 'Removed' // [!code --]
msg: 'Added' // [!code ++]
}
}
}
```
````
**Output**
```js
export default {
data() {
return {
msg: 'Removed', // [!code --]
msg: 'Added', // [!code ++]
}
}
}
```
## Errors and Warnings in Code Blocks
Adding the `// [!code warning]` or `// [!code error]` comments on a line will color it accordingly.
**Input**
Note that only one space is required after `!code`, here are two to prevent processing.
````
```js
export default {
data () {
return {
msg: 'Error', // [!code error]
msg: 'Warning' // [!code warning]
}
}
}
```
````
**Output**
```js
export default {
data() {
return {
msg: 'Error', // [!code error]
msg: 'Warning' // [!code warning]
}
}
}
```
## Import Code Snippets
You can import code snippets from existing files via following syntax:
```md
<<< @/filepath
```
It also supports [line highlighting](#line-highlighting-in-code-blocks):
```md
<<< @/filepath{highlightLines}
```
**Input**
```md
<<< @/snippets/snippet.js{2}
```
**Code file**
<<< @/snippets/snippet.js
**Output**
<<< @/snippets/snippet.js
::: tip
The value of `@` corresponds to the source root. By default it's the blog root, unless `srcDir` is configured. Alternatively, you can also import from relative paths:
```md
<<< ../snippets/snippet.js
```
:::
You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/codebasics#_folding) to only include the corresponding part of the code file. You can provide a custom region name after a `#` following the filepath:
**Input**
```md
<<< @/snippets/snippet-with-region.js#snippet{1}
```
**Code file**
<<< @/snippets/snippet-with-region.js
**Output**
<<< @/snippets/snippet-with-region.js#snippet{1}
You can also specify the language inside the braces (`{}`) like this:
```md
<<< @/snippets/snippet.cs{c#}
<!-- with line highlighting: -->
<<< @/snippets/snippet.cs{1,2,4-6 c#}
<!-- with line numbers: -->
<<< @/snippets/snippet.cs{1,2,4-6 c#:line-numbers}
```
This is helpful if source language cannot be inferred from your file extension.
## Code Groups
You can group multiple code blocks like this:
**Input**
````md
::: code-group
```js [config.js]
/**
* @type {import('vitepress').UserConfig}
*/
const config = {
// ...
}
export default config
```
```ts [config.ts]
import type { UserConfig } from 'vitepress'
const config: UserConfig = {
// ...
}
export default config
```
:::
````
**Output**
::: code-group
```js [config.js]
/**
* @type {import('vitepress').UserConfig}
*/
const config = {
// ...
}
export default config
```
```ts [config.ts]
import type { UserConfig } from 'vitepress'
const config: UserConfig = {
// ...
}
export default config
```
:::
You can also [import snippets](#import-code-snippets) in code groups:
**Input**
```md
::: code-group
<!-- filename is used as title by default -->
<<< @/snippets/snippet.js
<!-- you can provide a custom one too -->
<<< @/snippets/snippet-with-region.js#snippet{1,2 ts:line-numbers} [snippet with region]
:::
```
**Output**
::: code-group
<<< @/snippets/snippet.js
<<< @/snippets/snippet-with-region.js#snippet{1,2 ts:line-numbers} [snippet with region]
:::
## Container
::: zh-CN
通过对 `markdownIt` 进行配置,你可以自由设置自定义块区域的文字以及图标及图标的颜色。
:::
::: en
By configuring `markdownIt`, you can set the text and icon (and its color) for
custom block.
:::
::: tip
tip
:::
::: warning
warning
:::
::: danger
danger
:::
::: info
info
:::
::: details Click to expand
details
:::
## KaTeX
**Input**
```md
When $a \ne 0$, there are two solutions to $(ax^2 + bx + c = 0)$ and they are
$$ x = {-b \pm \sqrt{b^2-4ac} \over 2a} $$
**Maxwell's equations:**
| equation | description |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| $\nabla \cdot \vec{\mathbf{B}} = 0$ | divergence of $\vec{\mathbf{B}}$ is zero |
| $\nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} = \vec{\mathbf{0}}$ | curl of $\vec{\mathbf{E}}$ is proportional to the rate of change of $\vec{\mathbf{B}}$ |
| $\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} = \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} = 4 \pi \rho$ | _wha?_ |
```
**Output**
When $a \ne 0$, there are two solutions to $(ax^2 + bx + c = 0)$ and they are
$$ x = {-b \pm \sqrt{b^2-4ac} \over 2a} $$
**Maxwell's equations:**
| equation | description |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| $\nabla \cdot \vec{\mathbf{B}} = 0$ | divergence of $\vec{\mathbf{B}}$ is zero |
| $\nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} = \vec{\mathbf{0}}$ | curl of $\vec{\mathbf{E}}$ is proportional to the rate of change of $\vec{\mathbf{B}}$ |
| $\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} = \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} = 4 \pi \rho$ | _wha?_ |

16
docs/snippets/init.ansi Normal file
View File

@ -0,0 +1,16 @@
┌ Welcome to VitePress!
│
◇ Where should VitePress initialize the config?
│ ./docs
│
◇ Site title:
│ My Awesome Project
│
◇ Site description:
│ A VitePress Site
│
◆ Theme:
│ ● Default Theme (Out of the box, good-looking docs)
│ ○ Default Theme + Customization
│ ○ Custom Theme
└

View File

@ -0,0 +1,7 @@
// #region snippet
function foo() {
// ..
}
// #endregion snippet
export default foo

3
docs/snippets/snippet.js Normal file
View File

@ -0,0 +1,3 @@
export default function () {
// ..
}

View File

@ -6,7 +6,7 @@ import 'vitepress/dist/client/theme-default/styles/vars.css'
// import 'vitepress/dist/client/theme-default/styles/base.css'
// import 'vitepress/dist/client/theme-default/styles/utils.css'
// import 'vitepress/dist/client/theme-default/styles/components/vp-code.css'
// import 'vitepress/dist/client/theme-default/styles/components/vp-code-group.css'
import 'vitepress/dist/client/theme-default/styles/components/vp-code-group.css'
import 'vitepress/dist/client/theme-default/styles/components/vp-doc.css'
import 'vitepress/dist/client/theme-default/styles/components/custom-block.css'

View File

@ -7,6 +7,8 @@ import { useBodyScrollLock, useSiteConfig } from 'valaxy'
import { useRouter } from 'vue-router'
import type { FuseListItem } from 'valaxy/types'
import { onClickOutside } from '@vueuse/core'
const props = defineProps<{
open: boolean
}>()
@ -69,6 +71,10 @@ function jumpToLink(link: string) {
router.push(link)
emit('close')
}
onClickOutside(searchInputRef, () => {
// emit('close')
})
</script>
<template>
@ -79,7 +85,9 @@ function jumpToLink(link: string) {
>
<div
v-if="open" ref="searchContainer"
class="yun-popup yun-search-popup yun-fuse-search flex-center" flex="col"
class="yun-popup yun-search-popup yun-fuse-search flex-center pointer-events-auto" flex="col"
justify="start"
pt-12
>
<div class="yun-search-input-container flex-center" w="full">
<input ref="searchInputRef" v-model="input" class="yun-search-input" :placeholder="t('search.placeholder')">
@ -87,27 +95,25 @@ function jumpToLink(link: string) {
<div v-if="input" class="flex-center" w="full" py="4">
{{ t('search.hits', results.length || 0) }}
</div>
<div overflow="auto" flex="~ 1" w="full">
<div v-if="results.length > 0" overflow="auto" flex="~" w="full">
<div class="yun-fuse-result-container" flex="~ col" w="full">
<template v-if="results.length > 0">
<div
v-for="result in results" :key="result.item.title"
:to="result.item.link"
class="yun-fuse-result-item text-$va-c-text hover:(text-$va-c-bg bg-$va-c-text-dark bg-opacity-100)"
flex="~ col" pb-2
@click="jumpToLink(result.item.link)"
>
<h3 font="serif black">
{{ result.item.title }}
</h3>
<span text="sm" opacity="80">
{{ result.item.excerpt }}
</span>
<span text-xs opacity-50 mt="1">
Score Index: {{ result.refIndex }}
</span>
</div>
</template>
<div
v-for="result in results" :key="result.item.title"
:to="result.item.link"
class="yun-fuse-result-item text-$va-c-text hover:(text-$va-c-bg bg-$va-c-text-dark bg-opacity-100)"
flex="~ col" pb-2
@click="jumpToLink(result.item.link)"
>
<h3 font="serif black">
{{ result.item.title }}
</h3>
<span text="sm" opacity="80">
{{ result.item.excerpt }}
</span>
<span text-xs opacity-50 mt="1">
Score Index: {{ result.refIndex }}
</span>
</div>
</div>
</div>
</div>
@ -126,7 +132,6 @@ function jumpToLink(link: string) {
-webkit-backdrop-filter: blur(30px);
text-align: center;
padding-top: 3.5rem;
margin: 0;
z-index: var(--yun-z-search-popup);
transition: 0.6s;

View File

@ -1,17 +1,26 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
withDefaults(defineProps<{
const props = withDefaults(defineProps<{
open?: boolean
}>(), {
open: false,
})
const emit = defineEmits(['close', 'open'])
const { t } = useI18n()
function onClick() {
if (props.open)
emit('close')
else
emit('open')
}
</script>
<template>
<button class="yun-search-btn popup-trigger yun-icon-btn" :title="t('menu.search')">
<button class="yun-search-btn popup-trigger yun-icon-btn" :title="t('menu.search')" @click="onClick">
<div v-if="!open" i-ri-search-line />
<div v-else text="!2xl" i-ri-close-line />
</button>

View File

@ -21,14 +21,22 @@ watch(Meta_K, (val) => {
togglePopup()
})
function openSearch() {
open.value = true
}
function closeSearch() {
open.value = false
}
const YunAlgoliaSearch = isAlgolia.value
? defineAsyncComponent(() => import('./third/YunAlgoliaSearch.vue'))
: () => null
</script>
<template>
<YunSearchBtn :open="open && !isAlgolia" @click="togglePopup" />
<YunSearchBtn :open="open && !isAlgolia" @open="openSearch" @close="closeSearch" />
<YunAlgoliaSearch v-if="isAlgolia" :open="open" @close="open = false" />
<YunFuseSearch v-else-if="isFuse" :open="open" @close="open = false" />
<YunAlgoliaSearch v-if="isAlgolia" :open="open" @close="closeSearch" />
<YunFuseSearch v-else-if="isFuse" :open="open" @close="closeSearch" />
</template>

View File

@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { onContentUpdated, runContentUpdated, useAplayer, useCodePen, useCopyCode, useMediumZoom, wrapTable } from 'valaxy'
import type { Post } from 'valaxy'
import { useVanillaLazyLoad } from '../composables/features/vanilla-lazyload'
import { useCodeGroups } from '../composables/codeGroups'
const props = defineProps<{
frontmatter: Post
@ -29,6 +30,7 @@ if (props.frontmatter.codepen)
useCodePen()
useCopyCode()
useCodeGroups()
if (typeof props.frontmatter.medium_zoom === 'undefined' || props.frontmatter.medium_zoom)
useMediumZoom()

View File

@ -0,0 +1,52 @@
import { onContentUpdated } from 'valaxy'
import { isClient } from '@vueuse/core'
export function useCodeGroups() {
if (import.meta.env.DEV) {
onContentUpdated(() => {
document.querySelectorAll('.vp-code-group > .blocks').forEach((el) => {
Array.from(el.children).forEach((child) => {
child.classList.remove('active')
})
el.children[0].classList.add('active')
})
})
}
if (isClient) {
window.addEventListener('click', (e) => {
const el = e.target as HTMLInputElement
if (el.matches('.vp-code-group input')) {
// input <- .tabs <- .vp-code-group
const group = el.parentElement?.parentElement
if (!group)
return
const i = Array.from(group.querySelectorAll('input')).indexOf(el)
if (i < 0)
return
const blocks = group.querySelector('.blocks')
if (!blocks)
return
const current = Array.from(blocks.children).find(child =>
child.classList.contains('active'),
)
if (!current)
return
const next = blocks.children[i]
if (!next || current === next)
return
current.classList.remove('active')
next.classList.add('active')
const label = group?.querySelector(`label[for="${el.id}"]`)
label?.scrollIntoView({ block: 'nearest' })
}
})
}
}

View File

@ -68,7 +68,7 @@ html:not(.dark) .vp-code-dark {
position: relative;
z-index: 1;
margin: 0;
padding: 1rem 0;
padding: 20px 0;
background: transparent;
overflow-x: auto;
@ -87,7 +87,7 @@ html:not(.dark) .vp-code-dark {
top: 0;
bottom: 0;
left: 0;
padding: 16px 0;
padding: 20px 0;
width: 100%;
line-height: var(--va-code-line-height);
font-family: var(--va-font-mono);

View File

@ -37,4 +37,5 @@ export interface MarkdownEnv {
relativePath: string
cleanUrls: CleanUrlsMode
links?: string[]
realPath?: string
}

View File

@ -30,8 +30,8 @@ import { highlightLinePlugin } from './plugins/markdown-it/highlightLines'
import { linkPlugin } from './plugins/link'
import { preWrapperPlugin } from './plugins/markdown-it/preWrapper'
// import { lineNumberPlugin } from "./plugins/lineNumbers";
import { lineNumberPlugin } from './plugins/markdown-it/lineNumbers'
import { snippetPlugin } from './plugins/markdown-it/snippet'
export * from './env'
@ -57,7 +57,11 @@ export async function setupMarkdownPlugins(
// custom plugins
md.use(highlightLinePlugin)
.use(preWrapperPlugin, { theme })
.use(containerPlugin, mdOptions.blocks)
.use(snippetPlugin, options?.userRoot)
.use(containerPlugin, {
...mdOptions.blocks,
theme,
})
.use(cssI18nContainer, {
languages: ['zh-CN', 'en'],
})
@ -76,8 +80,6 @@ export async function setupMarkdownPlugins(
if (!mdOptions.attrs?.disable)
md.use(attrsPlugin, mdOptions.attrs)
// .use(lineNumberPlugin)
md.use(Katex, mdOptions.katex)
md.use(emojiPlugin)
@ -142,15 +144,14 @@ export async function setupMarkdownPlugins(
slugify,
...mdOptions.toc,
} as TocPluginOptions)
// ref vitepress
md.use(lineNumberPlugin, mdOptions.lineNumbers)
md.use(TaskLists)
if (mdOptions.config)
mdOptions.config(md)
// if (options.lineNumbers)
// md.use(lineNumberPlugin)
return md as MarkdownRenderer
}

View File

@ -114,10 +114,13 @@ export async function createMarkdownToVueRenderFn(
file: string,
publicDir: string,
): Promise<MarkdownCompileResult> => {
const relativePath = slash(path.relative(srcDir, file))
const fileOrig = file
const dir = path.dirname(file)
const relativePath = slash(path.relative(srcDir, file))
const cached = cache.get(src)
const cacheKey = JSON.stringify({ src, file: fileOrig })
const cached = cache.get(cacheKey)
if (cached) {
debug(`[cache hit] ${relativePath}`)
return cached
@ -144,6 +147,7 @@ export async function createMarkdownToVueRenderFn(
path: file,
relativePath,
cleanUrls,
realPath: fileOrig,
}
const html = md.render(src, env)

View File

@ -89,17 +89,22 @@ export async function highlight(
const styleRE = /<pre[^>]*(style=".*?")/
const preRE = /^<pre(.*?)>/
const vueRE = /-vue$/
const lineNoRE = /:(no-)?line-numbers$/
const lineNoStartRE = /=(\d*)/
const lineNoRE = /:(no-)?line-numbers(=\d*)?$/
const mustacheRE = /\{\{.*?\}\}/g
return (str: string, lang: string, attrs: string) => {
const vPre = vueRE.test(lang) ? '' : 'v-pre'
lang
= lang.replace(lineNoRE, '').replace(vueRE, '').toLowerCase() || defaultLang
= lang
.replace(lineNoStartRE, '')
.replace(lineNoRE, '')
.replace(vueRE, '')
.toLowerCase() || defaultLang
if (lang) {
const langLoaded = highlighter.getLoadedLanguages().includes(lang as any)
if (!langLoaded && lang !== 'ansi' && lang !== 'txt') {
if (!langLoaded && !['ansi', 'plaintext', 'txt', 'text'].includes(lang)) {
logger.warn(
c.yellow(
`\nThe language '${lang}' is not loaded, falling back to '${
@ -150,7 +155,7 @@ export async function highlight(
)
}
str = removeMustache(str).trim()
str = removeMustache(str).trimEnd()
const codeToHtml = (theme: IThemeRegistration) => {
const res

View File

@ -5,6 +5,15 @@ import type MarkdownIt from 'markdown-it'
import type Token from 'markdown-it/lib/token'
import container from 'markdown-it-container'
import { nanoid } from 'nanoid'
import type {
Options,
} from './preWrapper'
import {
extractTitle,
getAdaptiveThemeMarker,
} from './preWrapper'
type ContainerArgs = [
typeof container,
string,
@ -67,7 +76,9 @@ export interface Blocks {
details?: BlockItem
}
const defaultBlocksOptions: Blocks = {
export type ContainerOptions = Blocks & Partial<Options>
const defaultBlocksOptions: ContainerOptions = {
tip: {
text: 'TIP',
langs: {
@ -100,7 +111,7 @@ const defaultBlocksOptions: Blocks = {
},
}
export function containerPlugin(md: MarkdownIt, options: Blocks = {}) {
export function containerPlugin(md: MarkdownIt, options: ContainerOptions = {}) {
Object.keys(defaultBlocksOptions).forEach((optionKey) => {
const option: BlockItem = {
...defaultBlocksOptions[optionKey as keyof Blocks],
@ -109,6 +120,7 @@ export function containerPlugin(md: MarkdownIt, options: Blocks = {}) {
md.use(...createContainer(optionKey, option))
})
md.use(...createCodeGroup(options))
// explicitly escape Vue syntax
md.use(container, 'v-pre', {
@ -122,3 +134,56 @@ export function containerPlugin(md: MarkdownIt, options: Blocks = {}) {
})
})
}
function createCodeGroup(options: ContainerOptions): ContainerArgs {
return [
container,
'code-group',
{
render(tokens, idx) {
if (tokens[idx].nesting === 1) {
const name = nanoid(5)
let tabs = ''
let checked = 'checked="checked"'
for (
let i = idx + 1;
!(
tokens[i].nesting === -1
&& tokens[i].type === 'container_code-group_close'
);
++i
) {
const isHtml = tokens[i].type === 'html_block'
if (
(tokens[i].type === 'fence' && tokens[i].tag === 'code')
|| isHtml
) {
const title = extractTitle(
isHtml ? tokens[i].content : tokens[i].info,
isHtml,
)
if (title) {
const id = nanoid(7)
tabs += `<input type="radio" name="group-${name}" id="tab-${id}" ${checked}><label for="tab-${id}">${title}</label>`
if (checked && !isHtml)
tokens[i].info += ' active'
checked = ''
}
}
}
return `<div class="vp-code-group${getAdaptiveThemeMarker(
{
theme: options.theme!,
},
)}"><div class="tabs">${tabs}</div><div class="blocks">\n`
}
return `</div></div>\n`
},
},
]
}

View File

@ -1,78 +1,44 @@
// Modified from https://github.com/egoist/markdown-it-highlight-lines
import type MarkdownIt from 'markdown-it'
const wrapperRE = /^<pre .*?><code>/
const RE = /{([\d,-]+)}/
export function highlightLinePlugin(md: MarkdownIt) {
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const [tokens, idx, options] = args
const [tokens, idx] = args
const token = tokens[idx]
// due to use of markdown-it-attrs, the {0} syntax would have been converted
// to attrs on the token
// due to use of markdown-it-attrs, the {0} syntax would have been
// converted to attrs on the token
const attr = token.attrs && token.attrs[0]
if (!attr)
return fence(...args)
const lines = attr[0]
if (!lines || !/[\d,-]+/.test(lines))
return fence(...args)
let lines = null
const lineNumbers = lines
.split(',')
.map(v => v.split('-').map(v => Number.parseInt(v, 10)))
if (!attr) {
// markdown-it-attrs maybe disabled
const rawInfo = token.info
const code = options.highlight
? options.highlight(token.content, token.info, '')
: token.content
if (!rawInfo || !RE.test(rawInfo))
return fence(...args)
const rawCode = code.replace(wrapperRE, '')
const highlightLinesCode = rawCode
.split('\n')
.map((split, index) => {
const lineNumber = index + 1
const inRange = lineNumbers.some(([start, end]) => {
if (start && end)
return lineNumber >= start && lineNumber <= end
const langName = rawInfo.replace(RE, '').trim()
return lineNumber === start
})
if (inRange)
return '<div class="highlighted">&nbsp;</div>'
// ensure the next plugin get the correct lang
token.info = langName
return '<br>'
})
.join('')
lines = RE.exec(rawInfo)![1]
}
const highlightLinesWrapperCode = `<div class="highlight-lines">${highlightLinesCode}</div>`
if (!lines) {
lines = attr![0]
return highlightLinesWrapperCode + code
}
}
// markdown-it plugin for generating line numbers.
// It depends on preWrapper plugin.
export function lineNumberPlugin(md: MarkdownIt) {
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const rawCode = fence(...args)
const code = rawCode.slice(
rawCode.indexOf('<code>'),
rawCode.indexOf('</code>'),
)
const lines = code.split('\n')
const lineNumbersCode = [...Array(lines.length - 1)]
.map((line, index) => `<span class="line-number">${index + 1}</span><br>`)
.join('')
const lineNumbersWrapperCode = `<div class="line-numbers-wrapper">${lineNumbersCode}</div>`
const finalCode = rawCode
.replace(/<\/div>$/, `${lineNumbersWrapperCode}</div>`)
.replace(/"(language-\w+)"/, '"$1 line-numbers-mode"')
return finalCode
if (!lines || !/[\d,-]+/.test(lines))
return fence(...args)
}
token.info += ` ${lines}`
return fence(...args)
}
}

View File

@ -0,0 +1,46 @@
// markdown-it plugin for generating line numbers.
// It depends on preWrapper plugin.
import type MarkdownIt from 'markdown-it'
export function lineNumberPlugin(md: MarkdownIt, enable = false) {
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const rawCode = fence(...args)
const [tokens, idx] = args
const info = tokens[idx].info
if (
(!enable && !/:line-numbers($| |=)/.test(info))
|| (enable && /:no-line-numbers($| )/.test(info))
)
return rawCode
let startLineNumber = 1
const matchStartLineNumber = info.match(/=(\d*)/)
if (matchStartLineNumber && matchStartLineNumber[1])
startLineNumber = Number.parseInt(matchStartLineNumber[1])
const code = rawCode.slice(
rawCode.indexOf('<code>'),
rawCode.indexOf('</code>'),
)
const lines = code.split('\n')
const lineNumbersCode = [...Array(lines.length)]
.map(
(_, index) => `<span class="line-number">${index + startLineNumber}</span><br>`,
)
.join('')
const lineNumbersWrapperCode = `<div class="line-numbers-wrapper" aria-hidden="true">${lineNumbersCode}</div>`
const finalCode = rawCode
.replace(/<\/div>$/, `${lineNumbersWrapperCode}</div>`)
.replace(/"(language-[^"]*?)"/, '"$1 line-numbers-mode"')
return finalCode
}
}

View File

@ -9,9 +9,11 @@ export interface Options {
export function extractLang(info: string) {
return info
.trim()
.replace(/:(no-)?line-numbers({| |$).*/, '')
.replace(/=(\d*)/, '')
.replace(/:(no-)?line-numbers({| |$|=\d*).*/, '')
.replace(/(-vue|{| ).*$/, '')
.replace(/^vue-html$/, 'template')
.replace(/^ansi$/, '')
}
export function getAdaptiveThemeMarker(options: Options) {
@ -27,6 +29,15 @@ export function getAdaptiveThemeMarker(options: Options) {
return marker
}
export function extractTitle(info: string, html = false) {
if (html) {
return (
info.replace(/<!--[^]*?-->/g, '').match(/data-title="(.*?)"/)?.[1] || ''
)
}
return info.match(/\[(.*)\]/)?.[1] || extractLang(info) || 'txt'
}
// markdown-it plugin for wrapping <pre> ... </pre>.
//
// If your plugin was chained before preWrapper, you can add additional element directly.

View File

@ -0,0 +1,194 @@
import path from 'node:path'
import fs from 'fs-extra'
import type MarkdownIt from 'markdown-it'
import type { RuleBlock } from 'markdown-it/lib/parser_block'
import type { MarkdownEnv } from '../..'
/**
* raw path format: "/path/to/file.extension#region {meta} [title]"
* where #region, {meta} and [title] are optional
* meta can be like '1,2,4-6 lang', 'lang' or '1,2,4-6'
* lang can contain special characters like C++, C#, F#, etc.
* path can be relative to the current file or absolute
* file extension is optional
* path can contain spaces and dots
*
* captures: ['/path/to/file.extension', 'extension', '#region', '{meta}', '[title]']
*/
export const rawPathRegexp
= /^(.+?(?:(?:\.([a-z0-9]+))?))(?:(#[\w-]+))?(?: ?(?:{(\d+(?:[,-]\d+)*)? ?(\S+)?}))? ?(?:\[(.+)\])?$/
export function rawPathToToken(rawPath: string) {
const [
filepath = '',
extension = '',
region = '',
lines = '',
lang = '',
rawTitle = '',
] = (rawPathRegexp.exec(rawPath) || []).slice(1)
const title = rawTitle || filepath.split('/').pop() || ''
return { filepath, extension, region, lines, lang, title }
}
export function dedent(text: string): string {
const lines = text.split('\n')
const minIndentLength = lines.reduce((acc, line) => {
for (let i = 0; i < line.length; i++) {
if (line[i] !== ' ' && line[i] !== '\t')
return Math.min(i, acc)
}
return acc
}, Number.POSITIVE_INFINITY)
if (minIndentLength < Number.POSITIVE_INFINITY)
return lines.map(x => x.slice(minIndentLength)).join('\n')
return text
}
function testLine(
line: string,
regexp: RegExp,
regionName: string,
end: boolean = false,
) {
const [full, tag, name] = regexp.exec(line.trim()) || []
return (
full
&& tag
&& name === regionName
&& tag.match(end ? /^[Ee]nd ?[rR]egion$/ : /^[rR]egion$/)
)
}
function findRegion(lines: Array<string>, regionName: string) {
const regionRegexps = [
/^\/\/ ?#?((?:end)?region) ([\w*-]+)$/, // javascript, typescript, java
/^\/\* ?#((?:end)?region) ([\w*-]+) ?\*\/$/, // css, less, scss
/^#pragma ((?:end)?region) ([\w*-]+)$/, // C, C++
/^<!-- #?((?:end)?region) ([\w*-]+) -->$/, // HTML, markdown
/^#((?:End )Region) ([\w*-]+)$/, // Visual Basic
/^::#((?:end)region) ([\w*-]+)$/, // Bat
/^# ?((?:end)?region) ([\w*-]+)$/, // C#, PHP, Powershell, Python, perl & misc
]
let regexp = null
let start = -1
for (const [lineId, line] of lines.entries()) {
if (regexp === null) {
for (const reg of regionRegexps) {
if (testLine(line, reg, regionName)) {
start = lineId + 1
regexp = reg
break
}
}
}
else if (testLine(line, regexp, regionName, true)) {
return { start, end: lineId, regexp }
}
}
return null
}
export function snippetPlugin(md: MarkdownIt, srcDir: string) {
const parser: RuleBlock = (state, startLine, endLine, silent) => {
const CH = '<'.charCodeAt(0)
const pos = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine]
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4)
return false
for (let i = 0; i < 3; ++i) {
const ch = state.src.charCodeAt(pos + i)
if (ch !== CH || pos + i >= max)
return false
}
if (silent)
return true
const start = pos + 3
const end = state.skipSpacesBack(max, pos)
const rawPath = state.src
.slice(start, end)
.trim()
.replace(/^@/, srcDir)
.trim()
const { filepath, extension, region, lines, lang, title }
= rawPathToToken(rawPath)
state.line = startLine + 1
const token = state.push('fence', 'code', 0)
token.info = `${lang || extension}${lines ? `{${lines}}` : ''}${
title ? `[${title}]` : ''
}`
const { realPath, path: _path } = state.env as MarkdownEnv
const resolvedPath = path.resolve(path.dirname(realPath ?? _path), filepath)
// @ts-expect-error - hack
token.src = [resolvedPath, region.slice(1)]
token.markup = '```'
token.map = [startLine, startLine + 1]
return true
}
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const [tokens, idx, , { includes }] = args
const token = tokens[idx]
const [src, regionName] = token.src ?? []
if (!src)
return fence(...args)
if (includes)
includes.push(src)
const isAFile = fs.statSync(src).isFile()
if (!fs.existsSync(src) || !isAFile) {
token.content = isAFile
? `Code snippet path not found: ${src}`
: `Invalid code snippet option`
token.info = ''
return fence(...args)
}
let content = fs.readFileSync(src, 'utf8')
if (regionName) {
const lines = content.split(/\r?\n/)
const region = findRegion(lines, regionName)
if (region) {
content = dedent(
lines
.slice(region.start, region.end)
.filter(line => !region.regexp.test(line.trim()))
.join('\n'),
)
}
}
token.content = content
return fence(...args)
}
md.block.ruler.before('fence', 'snippet', parser)
}

View File

@ -54,6 +54,8 @@ export interface MarkdownOptions {
classes: string
}
lineNumbers?: boolean
katex?: KatexOptions
/**
* shiki