Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 270 additions & 3 deletions .vitepress/theme/EditPage.vue

Large diffs are not rendered by default.

50 changes: 49 additions & 1 deletion .vitepress/theme/editor/codemirror.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Extension, Range } from '@codemirror/state'
import type { DecorationSet, ViewUpdate } from '@codemirror/view'
import type { MarkdownIssue } from '../../../utils/markdown-validation'
import { defaultKeymap, history, historyKeymap, indentLess, indentMore } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { syntaxTree } from '@codemirror/language'
import { Compartment, EditorSelection, EditorState, RangeSet } from '@codemirror/state'
import { Compartment, EditorSelection, EditorState, RangeSet, StateEffect, StateField } from '@codemirror/state'
import { Decoration, EditorView, keymap, placeholder, ViewPlugin } from '@codemirror/view'

const HEADING = /^ATXHeading([1-6])$/
Expand Down Expand Up @@ -109,6 +110,50 @@ function styleDoc(state: EditorState): DecorationSet {
return RangeSet.of(ranges, true)
}

function issueDecorations(state: EditorState, issues: MarkdownIssue[]): DecorationSet {
const ranges: Range<Decoration>[] = []
for (const problem of issues) {
if (!problem.line || problem.line > state.doc.lines)
continue
const line = state.doc.line(problem.line)
ranges.push(Decoration.line({ class: 'cm-nb-issue-line' }).range(line.from))
const start = Math.min(line.to, line.from + Math.max(0, problem.column - 1))
if (start < line.to)
ranges.push(Decoration.mark({ class: 'cm-nb-issue-text' }).range(start, Math.min(line.to, start + 1)))
else if (line.length)
ranges.push(Decoration.mark({ class: 'cm-nb-issue-text' }).range(line.to - 1, line.to))
}
return RangeSet.of(ranges, true)
}

const setIssues = StateEffect.define<MarkdownIssue[]>()

const issueField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(decorations, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setIssues))
return issueDecorations(transaction.state, effect.value)
}
return decorations.map(transaction.changes)
},
provide: field => EditorView.decorations.from(field),
})

export function setMarkdownIssues(view: EditorView, issues: MarkdownIssue[]): void {
view.dispatch({ effects: setIssues.of(issues) })
}

export function revealMarkdownIssue(view: EditorView, problem: MarkdownIssue): void {
const line = view.state.doc.line(Math.min(Math.max(1, problem.line), view.state.doc.lines))
const position = Math.min(line.to, line.from + Math.max(0, problem.column - 1))
view.dispatch({
selection: { anchor: position },
effects: EditorView.scrollIntoView(position, { y: 'center' }),
})
view.focus()
}

const liveStyling = ViewPlugin.fromClass(
class {
decorations: DecorationSet
Expand Down Expand Up @@ -155,6 +200,8 @@ const theme = EditorView.theme({
'.cm-deletedChunk': { backgroundColor: 'color-mix(in srgb, var(--vp-c-danger-1) 12%, transparent)' },
'.cm-changedLine': { backgroundColor: 'color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent)' },
'.cm-changedText': { backgroundColor: 'color-mix(in srgb, var(--vp-c-brand-1) 18%, transparent)' },
'.cm-nb-issue-line': { backgroundColor: 'color-mix(in srgb, var(--vp-c-danger-1) 7%, transparent)' },
'.cm-nb-issue-text': { textDecoration: 'underline wavy var(--vp-c-danger-1)', textUnderlineOffset: '3px' },
'.cm-nb-code': {
fontFamily: 'var(--nb-mono)',
fontSize: '0.9em',
Expand Down Expand Up @@ -330,6 +377,7 @@ export function mountEditor(
markdown({ base: markdownLanguage }),
EditorView.lineWrapping,
liveStyling,
issueField,
diffing.of([]),
theme,
EditorView.updateListener.of((update) => {
Expand Down
86 changes: 86 additions & 0 deletions .vitepress/theme/editor/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,85 @@ export interface Preview {
}

const DOC = '.vp-doc'
const MERMAID = 'pre > code[class~="language-mermaid" i], pre > code[class~="language-mmd" i]'
let previewNumber = 0

function errorText(error: unknown): string {
const text = error instanceof Error ? error.message : String(error)
return text.split('\n')[0] ?? '无法解析图表'
}

function diagramError(source: HTMLPreElement, error: unknown): HTMLElement {
const box = document.createElement('div')
box.className = 'nb-mermaid-error'
box.setAttribute('role', 'alert')

const title = document.createElement('strong')
title.textContent = 'Mermaid 图表语法错误'
const detail = document.createElement('p')
detail.textContent = errorText(error)

const more = document.createElement('details')
const summary = document.createElement('summary')
summary.textContent = '查看图表源码'
const copy = source.cloneNode(true) as HTMLPreElement
copy.removeAttribute('aria-busy')
copy.classList.remove('nb-mermaid-source')
more.append(summary, copy)
box.append(title, detail, more)
return box
}

async function renderMermaid(
host: HTMLElement,
current: () => boolean,
number: number,
): Promise<void> {
const blocks = [...host.querySelectorAll<HTMLElement>(MERMAID)]
if (!blocks.length)
return

const mermaid = (await import('mermaid')).default
if (!current())
return

mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default',
})

for (const [index, code] of blocks.entries()) {
if (!current() || !code.isConnected)
return
const source = code.closest('pre') as HTMLPreElement | null
if (!source)
continue
source.classList.add('nb-mermaid-source')
source.setAttribute('aria-busy', 'true')

try {
const definition = code.textContent ?? ''
await mermaid.parse(definition)
if (!current() || !source.isConnected)
return
const { svg } = await mermaid.render(`nb-mermaid-${number}-${index}`, definition)
if (!current() || !source.isConnected)
return
const figure = document.createElement('figure')
figure.className = 'nb-mermaid'
figure.setAttribute('role', 'img')
figure.setAttribute('aria-label', 'Mermaid 图表')
figure.innerHTML = svg
source.replaceWith(figure)
}
catch (error) {
if (!current() || !source.isConnected)
return
source.replaceWith(diagramError(source, error))
}
}
}

export async function openPreview(
markdown: string,
Expand All @@ -29,6 +108,9 @@ export async function openPreview(
article.after(host)
article.style.display = 'none'

const instance = ++previewNumber
let generation = 0
let closed = false
let app: App | undefined

// The built page already resolved ./assets/name.webp to a hashed URL.
Expand All @@ -43,6 +125,7 @@ export async function openPreview(

const render = (source: string) => {
app?.unmount()
const run = ++generation
const html = previewSources(renderMarkdown(stripFrontmatter(source)), published, pending)
const { code } = compile(`<div>${html}</div>`, {
mode: 'function',
Expand All @@ -57,6 +140,7 @@ export async function openPreview(
app.component(name, component)
app.config.warnHandler = () => {}
app.mount(host)
void renderMermaid(host, () => !closed && run === generation, instance * 1000 + run)
}

render(markdown)
Expand All @@ -65,6 +149,8 @@ export async function openPreview(
return {
update: render,
close() {
closed = true
generation++
app?.unmount()
host.remove()
article.style.display = ''
Expand Down
45 changes: 45 additions & 0 deletions .vitepress/theme/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1181,3 +1181,48 @@
text-decoration: none;
}
}

/* Mermaid is rendered in place of the editor preview's fenced source. */
.vp-doc .nb-mermaid-source {
opacity: 0.55;
}

.vp-doc .nb-mermaid {
display: flex;
justify-content: center;
width: 100%;
margin: 2rem 0;
overflow-x: auto;
}

.vp-doc .nb-mermaid svg {
max-width: 100%;
height: auto;
}

.vp-doc .nb-mermaid-error {
margin: 1.5rem 0;
padding: 1rem;
border: 1px solid color-mix(in srgb, var(--vp-c-danger-1) 35%, var(--vp-c-divider));
border-radius: 6px;
background: color-mix(in srgb, var(--vp-c-danger-1) 6%, var(--vp-c-bg-soft));
font-size: 0.875rem;
}

.vp-doc .nb-mermaid-error strong {
color: var(--vp-c-danger-1);
}

.vp-doc .nb-mermaid-error p {
margin: 0.35rem 0 0.75rem;
color: var(--vp-c-text-2);
}

.vp-doc .nb-mermaid-error details pre {
margin: 0.5rem 0 0;
}

.vp-doc .nb-mermaid-error summary {
cursor: pointer;
color: var(--vp-c-text-2);
}
24 changes: 24 additions & 0 deletions checks/editor-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { splitFrontmatter } from '../utils/markdown'
import { lintMarkdown } from '../utils/markdown-validation'
import { listDocs } from './content-contract'

describe('editor Markdown validation', () => {
it('accepts every current page body', async () => {
const failures: string[] = []

for (const doc of listDocs()) {
const issues = await lintMarkdown(splitFrontmatter(doc.content).body, {
archived: doc.category === 'archived',
mermaid: false,
})
if (issues.length) {
failures.push(`${doc.relativePath}: ${issues
.map(problem => `${problem.line}:${problem.column} ${problem.rule}`)
.join(', ')}`)
}
}

expect(failures).toEqual([])
}, 30_000)
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"markdown-it": "^14.3.1",
"markdown-it-anchor": "^9.2.1",
"markdown-it-container": "^4.0.0",
"markdownlint": "0.38.0",
"markdownlint-cli2": "^0.18.1",
"mermaid": "^11.16.1",
"postcss": "^8.5.26",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion tutorial/manual/writing-documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,19 @@ maintainers:

## 第三步:修改

单击“在本页编辑”,打开编辑框。左边编辑,右边实时预览。
单击“在本页编辑”,打开编辑框。

- 用 Markdown 语法编写。常用语法见 [Markdown](/tutorial/markdown)。
- 编辑器会自动检查 Markdown。发现问题时,编辑框上方会列出规则、行号和修改提示;修正前不能提交。
- 要插入图片,把图片拖进编辑框。系统自动把图片转为 WebP 格式并保存。
- 页面开头两行 `---` 之间是元信息。如果你不清楚每一项的作用,不要修改它们。
- 需要画流程图时,用标注为 `mermaid` 的代码块。单击“预览”后,它会像正式页面一样渲染成图表。

```mermaid
flowchart LR
A[编辑] --> B[预览]
B --> C[提交]
```

如果你不能确定某个事实,写清楚缺少什么,不要编造。归档页有专用的标记,见[编者按标记](/tutorial/vitepress#本站的编者按标记)。

Expand Down
78 changes: 78 additions & 0 deletions utils/markdown-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { lintMarkdown } from './markdown-validation'

describe('markdown validation', () => {
it('accepts formatting that the submit path fixes automatically', async () => {
expect(await lintMarkdown('# Title \n\n\nBody')).toEqual([])
})

it('reports markdownlint rules with their line and column', async () => {
const issues = await lintMarkdown('#Title\n\nBody\n')

expect(issues).toContainEqual(expect.objectContaining({
line: 1,
column: 1,
rule: 'MD018',
message: '井号后需要空格',
}))
})

it('rejects an unclosed fenced code block', async () => {
const issues = await lintMarkdown('# Title\n\n```text\ncode\n')

expect(issues).toContainEqual(expect.objectContaining({
line: 3,
rule: 'FENCE',
}))
})

it('rejects an unclosed container', async () => {
const issues = await lintMarkdown('# Title\n\n::: tip\nText\n')

expect(issues).toContainEqual(expect.objectContaining({
line: 3,
rule: 'CONTAINER',
}))
})

it('applies the archived directory overrides', async () => {
const source = '# Title\n\n## Same\n\nOne.\n\n## Same\n\nTwo.\n'
const active = await lintMarkdown(source, { mermaid: false })
const archived = await lintMarkdown(source, { archived: true, mermaid: false })

expect(active.some(problem => problem.rule === 'MD024')).toBe(true)
expect(archived.some(problem => problem.rule === 'MD024')).toBe(false)
})

it('validates Mermaid syntax at the source line', async () => {
const issues = await lintMarkdown([
'# Title',
'',
'```mermaid',
'graph TD',
'A-->[',
'```',
'',
].join('\n'))

expect(issues).toContainEqual(expect.objectContaining({
line: 5,
rule: 'MERMAID',
message: 'Mermaid 图表语法错误',
}))
})

it('accepts a valid Mermaid diagram', async () => {
const issues = await lintMarkdown([
'# Title',
'',
'```mermaid',
'graph TD',
'A-->B',
'```',
'',
].join('\n'))

expect(issues).toEqual([])
})
})
Loading
Loading