feat(plugin-md-power): add timeline syntax support (#529)

This commit is contained in:
pengzhanbo 2025-03-22 00:07:35 +08:00 committed by GitHub
parent de69b22dcc
commit 5173e7f2ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1218 additions and 0 deletions

View File

@ -43,6 +43,7 @@ export const themeGuide = defineNoteConfig({
'steps',
'file-tree',
'tabs',
'timeline',
'demo-wrapper',
'npm-to',
'caniuse',

View File

@ -28,6 +28,7 @@ export const theme: Theme = plumeTheme({
annotation: true,
abbr: true,
timeline: true,
imageSize: 'all',
pdf: true,
caniuse: true,

View File

@ -155,6 +155,12 @@ export default defineUserConfig({
- **默认值**: `true`
- **详情**: 是否启用文件树容器语法
### timeline
- **类型**: `boolean`
- **默认值**: `false`
- **详情**: 是否启用时间线容器语法
### demo
- **类型**: `boolean`

View File

@ -0,0 +1,508 @@
---
title: 时间线
icon: mdi:timeline-text-outline
createTime: 2025/03/20 18:05:29
permalink: /guide/markdown/timeline/
badge:
text: 1.0.0-rc.137 +
type: tip
---
## 概述
在 markdown 中,使用 `::: timeline` 容器,包含 markdown 无序列表语法,即可实现 ==时间线== 的 渲染效果。
- 支持 ==水平方向== 和 ==垂直方向==
- 垂直方向支持 __左对齐____右对齐__ 和 __两端对齐__
- 支持 __图标__ 和 __线条样式__
- 支持 通过预设 __类型__ 设置 __颜色__,支持自定义颜色
## 启用
该功能默认不启用,你需要在 `theme` 配置中启用。
```ts title=".vuepress/config.ts"
export default defineUserConfig({
theme: plumeTheme({
markdown: {
timeline: true, // [!code ++]
}
})
})
```
## 使用
`::: timeline` 容器中,使用 markdown 无序列表语法,列表的每一个项即 时间线上的每一个点。
```md{1,9} title="timeline.md"
::: timeline 配置
- 标题 配置
正文内容
- 标题 配置
正文内容
:::
```
对于列表的每一个项:
- __第一行__: 从起始位置定义 __标题__,在标题之后跟着 `key=value` 的格式配置时间点的 __属性__
- __后续行__: 正文内容,==请注意添加正确的缩进=={.important}。
__一个简单的例子__
__输入__
```md
::: timeline
- 节点一 time=2025-03-20 type=success
正文内容
- 节点二 time=2025-02-21 type=warning
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
:::
```
__输出__
::: timeline
- 节点一 time=2025-03-20 type=success
正文内容
- 节点二 time=2025-02-21 type=warning
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
:::
::: important 时间线默认为垂直方向
:::
## 配置
__时间线__ 支持非常灵活且灵活的配置项,配置主要分为两个部分:
- __容器配置__`::: timeline` 容器上的配置,配置项跟随在 `::: timeline` 之后,如:
`::: timeline horizontal` 表示 渲染为 水平方向的时间线。
- __列表项配置__ 在列表的每一个项上的配置,配置项列表项的第一行,跟随在标题之后,如:
`- 节点一 time=2025-03-20 type=success` 表示 时间点为 `2025-03-20`,节点类型为 `success`
### 容器配置
#### horizontal
- __类型:__ `boolean`
- __默认值:__ `false`
渲染为 水平方向的时间线。
#### card
- __类型:__ `boolean`
- __默认值:__ `false`
每个时间节点默认渲染为卡片样式(可在列表项配置中覆盖)。
#### placement
- __类型:__ `'left' | 'right' | 'between'`
- __默认值:__ `'left'`
时间节点的对齐方式。==仅在垂直方向时生效=={.warning}
- `left` : 时间轴左侧对齐
- `right` : 时间轴右侧对齐
- `between` : 时间轴两端对齐 (通过列表项配置中的 `placement` 定义位置,默认为 `left`)
#### line
- __类型:__ `'solid' | 'dashed' | 'dotted'`
- __默认值:__ `'solid'`
线条样式(可在列表项配置中覆盖)
### 列表项配置
#### time
- __类型:__ `string`
- __默认值:__ `''`
时间点,可以是任何字符串,比如 `2025-03-20` `Q1` 等。
#### type
- __类型:__ `'info' | 'tip' | 'success' | 'warning' | 'danger' | 'caution' | 'important'`
- __默认值:__ `'info'`
时间节点的类型。
#### card
- __类型:__ `boolean`
- __默认值:__ `false` 从 容器配置 `card` 中继承
当前 时间节点渲染为卡片样式。
#### line
- __类型:__ `'solid' | 'dashed' | 'dotted'`
- __默认值:__ `'solid'` 从 容器配置 `line` 中继承
线条样式
#### icon
- __类型:__ `string`
- __默认值:__ `''`
时间节点的图标,支持所有的 [iconify](https://icon-sets.iconify.design/) 图标。
#### placement
- __类型:__ `'left' | 'right'`
- __默认值:__ `'left'`
当 容器配置为 `between` 时,定义当前时间节点的位置。
- `left` : 在时间轴左侧
- `right` : 在时间轴右侧
#### color
- __类型:__ `string`
- __默认值:__ `''`
时间节点线条颜色,可以是任何有效的颜色值。
## 示例
### 水平方向
`:::timeline` 后跟随声明 `horizontal` , 即可将时间线渲染为 水平方向。
__输入__
```md /horizontal/
::: timeline horizontal
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=success
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
```
__输出__
::: timeline horizontal
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=success
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
### 右对齐
`:::timeline` 后跟随声明 `placement="right"` , 即可将时间线渲染为 右对齐。
__输入__
```md /placement="right"/
::: timeline placement="right"
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=success
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
```
__输出__
::: timeline placement="right"
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=success
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
### 两端对齐
`:::timeline` 后跟随声明 `placement="between"` , 即可将时间线渲染为 两端对齐。
列表项默认位于时间线的左侧,可以通过 `placement="right"` 为列表项设置右侧位置。
__输入__
```md /placement="between"/ /placement=right/
::: timeline placement="between"
- 节点一 time=2025-03-20 placement=right
正文内容
- 节点二 time=2025-04-20 type=success
正文内容
- 节点三 time=2025-01-22 type=danger placement=right
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
```
__输出__
::: timeline placement="between"
- 节点一 time=2025-03-20 placement=right
正文内容
- 节点二 time=2025-04-20 type=success
正文内容
- 节点三 time=2025-01-22 type=danger placement=right
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
### 节点类型
在列表项首行标题之后,添加 `type=节点类型` 可以为当前节点设置节点类型。
__输入__
```md /type=success/ /type=warning/ /type=danger/ /type=important/
::: timeline
- 节点一 time=2025-03-20 type=success
正文内容
- 节点二 time=2025-04-20 type=warning
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
```
__输出__
::: timeline
- 节点一 time=2025-03-20 type=success
正文内容
- 节点二 time=2025-04-20 type=warning
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
### 线条风格
- 在容器配置中添加 `line=线条风格` 可以为所有节点设置默认线条风格。
- 在列表项首行标题之后,添加 `line=线条风格` 可以为节点设置线条风格。
__输入__
```md /line="dotted"/ /line=solid/ /line=dashed/
::: timeline line="dotted"
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=success
正文内容
- 节点三 time=2025-01-22 type=danger line=dashed
正文内容
- 节点四 time=2025-01-22 type=important line=solid
正文内容
:::
```
__输出__
::: timeline line="dotted"
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=success
正文内容
- 节点三 time=2025-01-22 type=danger line=dashed
正文内容
- 节点四 time=2025-01-22 type=important line=solid
正文内容
:::
### 带图标的节点
在列表项首行标题之后,添加 `icon=图标名称` 可以为节点添加图标。
图标名称支持 [iconify](https://icon-sets.iconify.design/) 的图标名称。
__输入__
```md /icon=mdi:balloon/ /icon=mdi:bookmark/
::: timeline placement="between"
- 节点一 time=2025-03-20 placement=right icon=mdi:balloon
正文内容
- 节点二 time=2025-04-20 type=success icon=mdi:bookmark
正文内容
- 节点三 time=2025-01-22 type=danger placement=right icon=mdi:bullhorn-variant-outline
正文内容
- 节点四 time=2025-01-22 type=important card=true icon="mdi:cake-variant-outline"
正文内容
:::
```
__输出__
::: timeline placement="between"
- 节点一 time=2025-03-20 placement=right icon=mdi:balloon
正文内容
- 节点二 time=2025-04-20 type=success icon=mdi:bookmark
正文内容
- 节点三 time=2025-01-22 type=danger placement=right icon=mdi:bullhorn-variant-outline
正文内容
- 节点四 time=2025-01-22 type=important card=true icon="mdi:cake-variant-outline"
正文内容
:::
### 卡片节点
卡片节点可以很灵活的进行控制:
- 在 容器配置中添加 `card` 即可使每个列表项都是卡片节点。
- 在列表项首行标题之后,添加 `card=true` 即可为节点设置为卡片节点。
- 在列表项首行标题之后,添加 `card=false` 即可为节点设置为非卡片节点。
卡片节点的样式会受到 `type` 配置的影响。
::: tip 在列表项首行标题之后添加 `card=true` / `card=false` 可以覆盖容器节点的 `card` 配置
:::
__输入__
```md{1} /card=false/
::: timeline card
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=success card=false
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
```
__输出__
::: timeline card
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=success card=false
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
- 节点四 time=2025-01-22 type=important
正文内容
:::
## 自定义节点类型
时间轴的节点类型是通过 CSS Variables 控制的,主题提供了以下的 CSS 变量:
```css
:root {
--vp-timeline-c-line: var(--vp-c-border); /* 线条颜色 */
--vp-timeline-c-point: var(--vp-c-border); /* 点颜色 */
--vp-timeline-c-title: var(--vp-c-text-1); /* 标题文本颜色 */
--vp-timeline-c-text: var(--vp-c-text-1); /* 正文文本颜色 */
--vp-timeline-c-time: var(--vp-c-text-3); /* 时间文本颜色 */
--vp-timeline-c-icon: var(--vp-c-bg); /* 图标颜色 */
--vp-timeline-bg-card: var(--vp-c-bg-soft); /* 卡片节点的背景颜色 */
}
```
比如主题内置的节点类型 `tip`:
```css /.tip/
.vp-timeline-item.tip {
--vp-timeline-c-line: var(--vp-c-tip-1);
--vp-timeline-c-point: var(--vp-c-tip-1);
--vp-timeline-bg-card: var(--vp-c-tip-soft);
}
```
可以在 [自定义样式](../custom/style.md) 中,覆盖内置的类型,或者添加新的类型。
__示例__
```css title=".vuepress/styles/index.css"
.vp-timeline-item.your-type {
--vp-timeline-c-line: #3cf;
--vp-timeline-c-point: #3cf;
--vp-timeline-bg-card: rgba(60, 252, 255, 0.314);
}
```
```md /type=your-type/
::: timeline
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=your-type card=true
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
:::
```
::: timeline
- 节点一 time=2025-03-20
正文内容
- 节点二 time=2025-04-20 type=your-type card=true
正文内容
- 节点三 time=2025-01-22 type=danger
正文内容
:::
<style>
.vp-timeline-item.your-type {
--vp-timeline-c-line: #3cf;
--vp-timeline-c-point: #3cf;
--vp-timeline-bg-card: rgba(60, 252, 255, 0.314);
}
</style>

View File

@ -0,0 +1,40 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`timeline > timelinePlugin() > should work 1`] = `
"<VPTimeline :card="undefined"><VPTimelineItem :card="undefined">
<template #title>这是标题</template>
这是内容</VPTimelineItem><VPTimelineItem :card="undefined">
<template #title>这是标题</template>
这是内容</VPTimelineItem></VPTimeline><VPTimeline horizontal card line="dashed"><VPTimelineItem time="q1" :card="undefined">
<template #title>这是标题</template>
这是内容
<ul>
<li>1</li>
<li>2</li>
<li>3
<ul>
<li>1.1</li>
<li>1.2</li>
</ul>
</li>
</ul>
</VPTimelineItem><VPTimelineItem time="q2" color="red">
<template #title>这是标题</template>
这是内容</VPTimelineItem></VPTimeline><VPTimeline :card="undefined" placement="right"><VPTimelineItem type="warning" icon="xxx" card>
<template #title>这是标题</template>
<template #icon><VPIcon name="xxx"/></template>
这是内容</VPTimelineItem><VPTimelineItem type="danger" line="dotted" :card="undefined">
<template #title>这是标题</template>
这是内容</VPTimelineItem></VPTimeline><VPTimeline :card="undefined" placement="between"><VPTimelineItem card placement="right">
<template #title>这是标题</template>
这是内容</VPTimelineItem><VPTimelineItem card placement="left">
<template #title>这是标题</template>
这是内容</VPTimelineItem></VPTimeline>"
`;

View File

@ -0,0 +1,77 @@
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
import { extractTimelineAttributes, timelinePlugin } from '../src/node/container/timeline.js'
describe('timeline > extractTimelineAttributes()', () => {
it('should work', () => {
const { title, attrs } = extractTimelineAttributes('这是标题 time=Q1')
expect(title).toBe('这是标题')
expect(attrs).toEqual({ time: 'Q1' })
})
it('should work with multi attrs', () => {
const { title, attrs } = extractTimelineAttributes('这是标题 time=Q1 icon=ri:clockwise-line card=true placement=left line=dashed')
expect(title).toBe('这是标题')
expect(attrs).toEqual({ time: 'Q1', icon: 'ri:clockwise-line', card: 'true', placement: 'left', line: 'dashed' })
})
it('should work with title include space', () => {
const { title, attrs } = extractTimelineAttributes('这是标题 这也是标题 time=Q1 icon=ri:clockwise-line card=true placement=left line=dashed')
expect(title).toBe('这是标题 这也是标题')
expect(attrs).toEqual({ time: 'Q1', icon: 'ri:clockwise-line', card: 'true', placement: 'left', line: 'dashed' })
})
it('should work with unknown attr', () => {
const { title, attrs } = extractTimelineAttributes('这是标题 time=Q1 unknown=true card=true')
expect(title).toBe('这是标题 unknown=true card=true')
expect(attrs).toEqual({ time: 'Q1' })
})
})
describe('timeline > timelinePlugin()', () => {
const md = new MarkdownIt()
timelinePlugin(md)
it('should work', () => {
const source = `\
::: timeline
-
-
:::
::: timeline horizontal line="dashed" card
- time=q1
- 1
- 2
- 3
- 1.1
- 1.2
- time=q2 color=red card=false
:::
::: timeline placement="right"
- icon=xxx card=true type=warning
- type=danger line=dotted
:::
::: timeline placement="between"
- card=true placement=right
- card=true placement=left
:::
`
expect(md.render(source)).toMatchSnapshot()
})
})

View File

@ -0,0 +1,52 @@
<script lang="ts" setup>
import { computed, provide } from 'vue'
import { INJECT_TIMELINE_KEY } from '../options.js'
const props = defineProps<{
horizontal?: boolean
card?: boolean
placement?: 'left' | 'right' | 'between'
line?: 'solid' | 'dashed' | 'dotted'
}>()
provide(INJECT_TIMELINE_KEY, computed(() => ({
line: props.line || 'solid',
card: props.card ?? false,
horizontal: props.horizontal ?? false,
placement: props.placement || 'left',
})))
</script>
<template>
<div class="vp-timeline" :class="{ horizontal }">
<div class="vp-timeline-box">
<slot />
</div>
</div>
</template>
<style>
.vp-timeline {
position: relative;
margin: 32px 0;
}
.vp-timeline.horizontal {
padding-bottom: 7px;
overflow-x: auto;
}
.vp-timeline-box {
display: flex;
gap: 24px 36px;
}
.vp-timeline:not(.horizontal) .vp-timeline-box {
flex-direction: column;
}
.vp-timeline.horizontal .vp-timeline-box {
flex-direction: row;
width: max-content;
}
</style>

View File

@ -0,0 +1,330 @@
<script lang="ts" setup>
import type { ComputedRef } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { computed, inject } from 'vue'
import { INJECT_TIMELINE_KEY } from '../options.js'
const props = defineProps<{
time?: string
type?: 'info' | 'tip' | 'success' | 'warning' | 'danger' | 'caution' | 'important' | (string & {})
card?: boolean
line?: 'solid' | 'dashed' | 'dotted'
icon?: string
color?: string
placement?: 'left' | 'right'
}>()
const is639 = useMediaQuery('(max-width: 639px)')
const defaultOptions = inject<ComputedRef<{
line?: 'solid' | 'dashed' | 'dotted'
card?: boolean
horizontal?: boolean
placement?: 'left' | 'right' | 'between'
}>>(INJECT_TIMELINE_KEY)
const timeline = computed(() => {
const between = defaultOptions?.value.placement === 'between' && !is639.value
const placement = defaultOptions?.value.placement === 'between' ? 'left' : defaultOptions?.value.placement
return {
time: props.time,
type: props.type || 'info',
line: props.line || defaultOptions?.value.line || 'solid',
icon: props.icon,
color: props.color,
horizontal: defaultOptions?.value.horizontal ?? false,
between: between ? props.placement || 'left' : false,
placement: between ? '' : (placement || 'left'),
card: props.card ?? defaultOptions?.value.card ?? false,
}
})
</script>
<template>
<div
class="vp-timeline-item" :class="{
card: timeline.card,
horizontal: timeline.horizontal,
[timeline.type]: true,
[`line-${timeline.line}`]: true,
[`placement-${timeline.placement}`]: !timeline.horizontal && timeline.placement,
between: timeline.between,
[`between-${timeline.between}`]: timeline.between,
}"
:style="timeline.color ? {
'--vp-timeline-c-line': timeline.color,
'--vp-timeline-c-point': timeline.color,
} : null"
>
<div class="vp-timeline-line" :class="{ 'has-icon': timeline.icon }">
<span class="vp-timeline-point">
<slot name="icon">
<VPIcon v-if="timeline.icon" :name="timeline.icon" />
</slot>
</span>
</div>
<div class="vp-timeline-container">
<div class="vp-timeline-content">
<p class="vp-timeline-title">
<slot name="title" />
</p>
<slot />
</div>
<p v-if="timeline.time" class="vp-timeline-time">
{{ timeline.time }}
</p>
</div>
</div>
</template>
<style>
:root,
.vp-timeline-item.info {
--vp-timeline-c-line: var(--vp-c-border);
--vp-timeline-c-point: var(--vp-c-border);
--vp-timeline-c-title: var(--vp-c-text-1);
--vp-timeline-c-text: var(--vp-c-text-1);
--vp-timeline-c-time: var(--vp-c-text-3);
--vp-timeline-c-icon: var(--vp-c-bg);
--vp-timeline-bg-card: var(--vp-c-bg-soft);
}
.vp-timeline-item.tip {
--vp-timeline-c-line: var(--vp-c-tip-1);
--vp-timeline-c-point: var(--vp-c-tip-1);
--vp-timeline-bg-card: var(--vp-c-tip-soft);
}
.vp-timeline-item.success {
--vp-timeline-c-line: var(--vp-c-success-3);
--vp-timeline-c-point: var(--vp-c-success-3);
--vp-timeline-bg-card: var(--vp-c-success-soft);
}
.vp-timeline-item.warning {
--vp-timeline-c-line: var(--vp-c-warning-3);
--vp-timeline-c-point: var(--vp-c-warning-3);
--vp-timeline-bg-card: var(--vp-c-warning-soft);
}
.vp-timeline-item.danger {
--vp-timeline-c-line: var(--vp-c-danger-3);
--vp-timeline-c-point: var(--vp-c-danger-3);
--vp-timeline-bg-card: var(--vp-c-danger-soft);
}
.vp-timeline-item.caution {
--vp-timeline-c-line: var(--vp-c-caution-3);
--vp-timeline-c-point: var(--vp-c-caution-3);
--vp-timeline-bg-card: var(--vp-c-caution-soft);
}
.vp-timeline-item.important {
--vp-timeline-c-line: var(--vp-c-important-3);
--vp-timeline-c-point: var(--vp-c-important-3);
--vp-timeline-bg-card: var(--vp-c-important-soft);
}
.vp-timeline-item {
position: relative;
display: flex;
}
.vp-timeline-item:not(.horizontal).between {
width: calc(50% - 18px);
}
.vp-timeline-item.horizontal {
padding-top: 36px;
}
.vp-timeline-item > .vp-timeline-line {
position: absolute;
}
.vp-timeline-item:not(.horizontal).placement-left {
justify-content: flex-start;
padding-left: 36px;
}
.vp-timeline-item:not(.horizontal).placement-right,
.vp-timeline-item:not(.horizontal).between {
justify-content: flex-end;
padding-right: 36px;
text-align: right;
}
.vp-timeline-item:not(.horizontal) > .vp-timeline-line {
top: 0;
bottom: 0;
width: 0;
}
.vp-timeline-item.horizontal > .vp-timeline-line {
top: 12px;
right: 0;
left: 0;
height: 0;
}
.vp-timeline-item:not(.horizontal).card > .vp-timeline-line {
top: 14px;
}
.vp-timeline-item:not(.horizontal).placement-left > .vp-timeline-line {
left: 12px;
}
.vp-timeline-item:not(.horizontal).placement-right > .vp-timeline-line,
.vp-timeline-item:not(.horizontal).between > .vp-timeline-line {
right: 12px;
}
.vp-timeline-item > .vp-timeline-line::before {
position: absolute;
display: block;
content: "";
border: none;
}
.vp-timeline-item:not(.horizontal) > .vp-timeline-line::before {
top: 10px;
bottom: -48px;
border-left: 2px solid var(--vp-timeline-c-line);
}
.vp-timeline-item.horizontal > .vp-timeline-line::before {
right: -46px;
left: 8px;
border-top: 2px solid var(--vp-timeline-c-line);
}
.vp-timeline-item:not(.horizontal):last-of-type > .vp-timeline-line::before {
bottom: 0 !important;
}
.vp-timeline-item.horizontal:last-of-type > .vp-timeline-line::before {
right: 0 !important;
}
.vp-timeline-item:not(.horizontal).line-dashed > .vp-timeline-line::before {
border-left-style: dashed;
}
.vp-timeline-item:not(.horizontal).line-dotted > .vp-timeline-line::before {
border-left-style: dotted;
}
.vp-timeline-item.horizontal.line-dashed > .vp-timeline-line::before {
border-top-style: dashed;
}
.vp-timeline-item.horizontal.line-dotted > .vp-timeline-line::before {
border-top-style: dotted;
}
.vp-timeline-item > .vp-timeline-line .vp-timeline-point {
position: absolute;
width: 16px;
height: 16px;
background-color: var(--vp-timeline-c-point);
border-radius: 50%;
transition: background-color var(--vp-t-color);
}
.vp-timeline-item:not(.horizontal) > .vp-timeline-line .vp-timeline-point {
top: 4px;
left: -7px;
}
.vp-timeline-item.horizontal > .vp-timeline-line .vp-timeline-point {
top: -7px;
left: 0;
}
.vp-timeline-item > .vp-timeline-line.has-icon .vp-timeline-point {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.vp-timeline-item:not(.horizontal) > .vp-timeline-line.has-icon .vp-timeline-point {
top: -1px;
left: -11px;
}
.vp-timeline-item.horizontal > .vp-timeline-line.has-icon .vp-timeline-point {
top: -11px;
left: 0;
}
.vp-timeline-item > .vp-timeline-line.has-icon .vp-timeline-point .vp-icon {
width: 16px;
height: 16px;
margin: 0;
color: var(--vp-timeline-c-icon);
}
.vp-timeline-item .vp-timeline-container {
width: max-content;
max-width: 100%;
font-size: 16px;
line-height: 1.5;
color: var(--vp-timeline-c-text);
transition: color var(--vp-t-color);
}
.vp-timeline-item.horizontal .vp-timeline-container {
max-width: 240px;
}
.vp-timeline-item:not(.horizontal).between-right .vp-timeline-container {
text-align: left;
transform: translateX(calc(100% + 48px));
}
.vp-timeline-item.card .vp-timeline-content {
padding: 16px;
background-color: var(--vp-timeline-bg-card);
border-radius: 6px;
}
.vp-timeline-item .vp-timeline-content :where(p, ul, ol) {
margin: 8px 0;
line-height: 22px;
}
.vp-doc .vp-timeline-item .vp-timeline-content div[class*="language-"] {
margin: 16px 0;
}
.vp-timeline-item .vp-timeline-content li + li {
margin-top: 4px;
}
.vp-timeline-item .vp-timeline-content .vp-timeline-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 900;
color: var(--vp-timeline-c-title);
transition: color var(--vp-t-color);
}
.vp-timeline-item .vp-timeline-content > .vp-timeline-title + * {
margin-top: 0 !important;
}
.vp-timeline-item .vp-timeline-content > :last-child {
margin-bottom: 0 !important;
}
.vp-timeline-item .vp-timeline-time {
margin: 4px 0 0;
font-size: 14px;
font-weight: 500;
color: var(--vp-timeline-c-time);
transition: color var(--vp-t-color);
}
</style>

View File

@ -26,3 +26,7 @@ if (installed.hlsjs) {
if (installed.mpegtsjs) {
ART_PLAYER_SUPPORTED_VIDEO_TYPES.push('ts', 'flv')
}
export const INJECT_TIMELINE_KEY = Symbol(
__VUEPRESS_DEV__ ? 'timeline' : '',
)

View File

@ -11,6 +11,7 @@ import { langReplPlugin } from './langRepl.js'
import { npmToPlugins } from './npmTo.js'
import { stepsPlugin } from './steps.js'
import { tabs } from './tabs.js'
import { timelinePlugin } from './timeline.js'
export async function containerPlugin(
app: App,
@ -46,4 +47,7 @@ export async function containerPlugin(
// ::: file-tree
fileTreePlugin(md, isPlainObject(options.fileTree) ? options.fileTree : {})
}
if (options.timeline)
timelinePlugin(md)
}

View File

@ -0,0 +1,173 @@
/**
* ::: timeline
*
* - title time="Q1" icon="ri:clockwise-line" line="dashed" type="warning" color="red"
* xxx
* - title time="Q2" icon="ri:clockwise-line" line="dashed" type="warning" color="red"
* :::
*/
import type { Markdown } from 'vuepress/markdown'
import { resolveAttrs } from '.././utils/resolveAttrs.js'
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
import { createContainerPlugin } from './createContainer.js'
export interface TimelineAttrs {
horizontal?: boolean
card?: boolean
placement?: string
line?: string
}
export interface TimelineItemAttrs {
time?: string
type?: string
icon?: string
color?: string
line?: string
card?: string
placement?: string
}
export interface TimelineItemMeta extends TimelineItemAttrs {
title: string
}
const RE_KEY = /(\w+)=\s*/
const RE_SEARCH_KEY = /\s+\w+=\s*|$/
const RE_CLEAN_VALUE = /(?<quote>["'])(.*?)(\k<quote>)/
export function timelinePlugin(md: Markdown) {
createContainerPlugin(md, 'timeline', {
before(info, tokens, index) {
const listStack: number[] = [] // 记录列表嵌套深度
for (let i = index + 1; i < tokens.length; i++) {
const token = tokens[i]
if (token.type === 'container_timeline_close') {
break
}
// 列表层级追踪
if (token.type === 'bullet_list_open') {
listStack.push(0) // 每个新列表初始层级为0
if (listStack.length === 1)
token.hidden = true
}
else if (token.type === 'bullet_list_close') {
listStack.pop()
if (listStack.length === 0)
token.hidden = true
}
else if (token.type === 'list_item_open') {
const currentLevel = listStack.length
// 仅处理根级列表项层级1
if (currentLevel === 1) {
token.type = 'timeline_item_open'
const titleOpenToken = tokens[i + 1]
const titleCloseToken = tokens[i + 3]
titleOpenToken.hidden = true
titleCloseToken.hidden = true
const inlineToken = tokens[i + 2]
const firstChildToken = inlineToken.children?.shift()
const { title, attrs } = extractTimelineAttributes(firstChildToken!.content.trim())
token.meta = {
title,
...attrs,
} as TimelineItemMeta
}
}
else if (token.type === 'list_item_close') {
const currentLevel = listStack.length
if (currentLevel === 1) {
token.type = 'timeline_item_close'
}
}
}
const { attrs } = resolveAttrs<TimelineAttrs>(info)
const { horizontal, card, placement, line } = attrs
return `<VPTimeline${
horizontal ? ' horizontal' : ''
}${
card ? ' card' : ' :card="undefined"'
}${
placement ? ` placement="${placement}"` : ''
}${
line ? ` line="${line}"` : ''
}>`
},
after: () => '</VPTimeline>',
})
md.renderer.rules.timeline_item_open = (tokens, idx, _, env) => {
const token = tokens[idx]
const { title, time, type, icon, color, line, card, placement } = token.meta as TimelineItemMeta
return `<VPTimelineItem${
time ? ` time="${time}"` : ''
}${
type ? ` type="${type}"` : ''
}${
color ? ` color="${color}"` : ''
}${
line ? ` line="${line}"` : ''
}${icon ? ` icon="${icon}"` : ''}${
card === 'true' ? ' card' : card === 'false' ? '' : ' :card="undefined"'
}${
placement ? ` placement="${placement}"` : ''
}>
<template #title>${md.renderInline(title, cleanMarkdownEnv(env))}</template>
${icon ? `<template #icon><VPIcon name="${icon}"/></template>` : ''}`
}
md.renderer.rules.timeline_item_close = () => '</VPTimelineItem>'
}
// 核心属性扫描器
export function extractTimelineAttributes(rawText: string): {
title: string
attrs: TimelineItemAttrs
} {
const attrKeys = ['time', 'type', 'icon', 'line', 'color', 'card', 'placement'] as const
const attrs: Partial<TimelineItemAttrs> = {}
let buffer = rawText.trim()
const titleSegments: string[] = []
while (buffer.length) {
// 匹配属性键 (支持大小写)
const keyMatch = buffer.match(RE_KEY)
if (!keyMatch) {
titleSegments.push(buffer)
break
}
// 提取可能的关键字
const matchedKey = keyMatch[1].toLowerCase()
if (!attrKeys.includes(matchedKey as any)) {
titleSegments.push(buffer)
break
}
const keyStart = keyMatch.index!
// 记录非属性内容为标题
titleSegments.push(buffer.slice(0, keyStart).trim())
// 跳过已匹配的 key:
const keyEnd = keyStart + keyMatch[0].length
buffer = buffer.slice(keyEnd)
// 提取属性值 (到下一个属性或行尾)
let valueEnd = buffer.search(RE_SEARCH_KEY)
/* istanbul ignore if -- @preserve */
if (valueEnd === -1)
valueEnd = buffer.length
const value = buffer.slice(0, valueEnd).trim()
// 存储属性
attrs[matchedKey as keyof TimelineItemAttrs] = value.replace(RE_CLEAN_VALUE, '$2')
// 跳过已处理的值
buffer = buffer.slice(valueEnd)
}
return {
title: titleSegments.join(' ').trim(),
attrs,
}
}

View File

@ -102,6 +102,13 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
enhances.add(`app.component('Abbreviation', Abbreviation)`)
}
if (options.timeline) {
imports.add(`import VPTimeline from '${CLIENT_FOLDER}components/VPTimeline.vue'`)
imports.add(`import VPTimelineItem from '${CLIENT_FOLDER}components/VPTimelineItem.vue'`)
enhances.add(`app.component('VPTimeline', VPTimeline)`)
enhances.add(`app.component('VPTimelineItem', VPTimelineItem)`)
}
return app.writeTemp(
'md-power/config.js',
`\

View File

@ -56,6 +56,20 @@ export interface MarkdownPowerPluginOptions {
*/
plot?: boolean | PlotOptions
/**
* timeline
*
* ```md
* ::: timeline
* - title time="Q1" icon="ri:clockwise-line" line="dashed" type="warning" color="red"
* xxx
* :::
* ```
*
* @default false
*/
timeline?: boolean
// video embed
/**
* bilibili

View File

@ -57,6 +57,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [
'plot',
'repl',
'replit',
'timeline',
'youtube',
]