/**
* Embed Link 是属于 obsidian 官方扩展的 markdown 语法
*
* - ![[文件名]] ![[文件名#标题]] ![[文件名#标题#标题]]
* - ![[资源链接]]:
* - ![[图片]] ![[图片|width]] ![[图片|widthxheight]]
* - ![[pdf]] ![[pdf#page=1#height=300]]
* - ![[音频]]
* - ![[视频]]
*
* @see - https://obsidian.md/zh/help/embeds
* @see - https://obsidian.md/zh/help/file-formats
*
* 插件提供的是对该语法的兼容性支持,并非实现其完全的功能。
*/
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
import type { App } from 'vuepress'
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import { attempt } from '@pengzhanbo/utils'
import grayMatter from 'gray-matter'
import Token from 'markdown-it/lib/token.mjs'
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
import { fs, hash, path } from 'vuepress/utils'
import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js'
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
import { parseRect } from '../utils/parseRect.js'
import { slugify } from '../utils/slugify.js'
import { findFirstPage } from './findFirstPage.js'
interface EmbedLinkMeta {
filename: string
hashes: string[]
settings: string
}
const EXTENSION_IMAGES: string[] = ['.jpg', '.jpeg', '.png', '.gif', '.avif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.apng', '.jfif', '.pjpeg', '.pjp', '.xbm']
const EXTENSION_AUDIOS: string[] = ['.mp3', '.flac', '.wav', '.ogg', '.opus', '.webm', '.acc']
const EXTENSION_VIDEOS: string[] = SUPPORTED_VIDEO_TYPES.map(ext => `.${ext}`)
const embedLinkDef: RuleBlock = (state, startLine, endLine, silent) => {
const start = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine]
// - ![[]]
if (max - start < 6)
return false
// 是否以 `![[` 开头
if (
state.src.charCodeAt(start) !== 0x21 // \!
|| state.src.charCodeAt(start + 1) !== 0x5B // [
|| state.src.charCodeAt(start + 2) !== 0x5B // [
) {
return false
}
const line = state.src.slice(start, max).trim()
// 是否以 `]]` 结尾
if (
line.charCodeAt(line.length - 1) !== 0x5D // ]
|| line.charCodeAt(line.length - 2) !== 0x5D // ]
) {
return false
}
/* istanbul ignore if -- @preserve */
if (silent)
return true
// ![[xxxx]]
// ^^^^ <- content
const content = line.slice(3, -2).trim()
const [file, settings] = content.split('|').map(x => x.trim())
const [filename, ...hashes] = file.trim().split('#').map(x => x.trim())
const extname = path.extname(filename).toLowerCase()
// 渲染为 图片
if (EXTENSION_IMAGES.includes(extname)) {
const token = state.push('image', 'img', 1)
token.content = filename
token.attrSet('src', resolveFilenameToAssetPath(filename))
token.attrSet('alt', filename)
if (settings) {
const [width, height] = settings.split('x').map(x => x.trim())
const styles: string[] = []
if (width)
styles.push(`width: ${parseRect(width)}`)
if (height)
styles.push(`height: ${parseRect(height)}`)
token.attrSet('style', styles.join(';'))
}
const text = new Token('text', '', 0)
text.content = filename
token.children = [text]
}
// 渲染为音频
else if (EXTENSION_AUDIOS.includes(extname)) {
const token = state.push('audio_open', 'audio', 1)
token.content = filename
token.attrSet('controls', 'true')
token.attrSet('preload', 'metadata')
token.attrSet('aria-label', filename)
const sourceToken = state.push('source_open', 'source', 1)
sourceToken.attrSet('src', resolveFilenameToAssetPath(filename))
state.push('audio_close', 'audio', -1)
}
// 渲染为视频,使用 ArtPlayer
else if (EXTENSION_VIDEOS.includes(extname)) {
const token = state.push('video_artPlayer_open', 'ArtPlayer', 1)
const type = extname.slice(1)
checkSupportType(type)
token.attrSet('src', resolveFilenameToAssetPath(filename))
token.attrSet('type', type)
token.attrSet('width', '100%')
token.attrSet(':fullscreen', 'true')
token.attrSet(':flip', 'true')
token.attrSet(':playback-rate', 'true')
token.attrSet(':aspect-ratio', 'true')
token.attrSet(':setting', 'true')
token.attrSet(':pip', 'true')
token.attrSet(':volume', '0.75')
token.content = filename
state.push('video_artPlayer_close', 'ArtPlayer', -1)
}
// 渲染为 pdf
else if (extname === '.pdf') {
const token = state.push('pdf_open', 'PDFViewer', 1)
token.attrSet('src', resolveFilenameToAssetPath(filename))
token.attrSet('width', '100%')
for (const hash of hashes) {
const [key, value] = hash.split('=').map(x => x.trim())
token.attrSet(key, value)
}
token.content = filename
state.push('pdf_close', 'PDFViewer', -1)
}
// 非受支持的外部资源,渲染为链接
else if (isLinkHttp(filename) || (extname && extname !== '.md')) {
const token = state.push('link_open', 'a', 1)
token.attrSet('href', filename)
token.attrSet('target', '_blank')
token.attrSet('rel', 'noopener noreferrer')
token.content = filename
const content = state.push('text', '', 0)
content.content = filename
state.push('link_close', 'a', -1)
}
// 剩余情况,如内部的 markdown 文件
// 在 obsidian_embed_link renderer rule 中处理
else {
const token = state.push('obsidian_embed_link', '', 0)
token.markup = '![[]]'
token.meta = {
filename: filename.trim(),
hashes: hashes.map(hash => hash.trim()),
settings: settings?.trim(),
} as EmbedLinkMeta
token.content = content
}
state.line = startLine + 1
return true
}
export function embedLinkPlugin(md: Markdown, app: App): void {
md.block.ruler.before(
'import_code',
'obsidian_embed_link',
embedLinkDef,
{ alt: ['paragraph', 'reference', 'blockquote', 'list'] },
)
md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => {
const token = tokens[idx]
const { filename, hashes, settings } = token.meta as EmbedLinkMeta
const pagePath = findFirstPage(filename, env.filePathRelative ?? '')
// 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面
if (pagePath) {
const [error, markdown] = attempt(() => fs.readFileSync(app.dir.source(pagePath), 'utf-8'))
if (error) {
console.warn(`[embedLinkPlugin] can not read file: ${pagePath}`)
return ''
}
const { content: rawContent } = grayMatter(markdown)
if (!rawContent) {
console.warn(`[embedLinkPlugin] file ${pagePath} is empty`)
return ''
}
const content = extractContentByHeadings(rawContent, hashes)
pagePath && (env.importedFiles ??= []).push(pagePath)
return md.render(content, cleanMarkdownEnv(env))
}
// 其他资源,解析为链接
const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename)
const anchor = hashes.at(-1)
const slug = anchor ? `#${slugify(anchor)}` : ''
const text = settings || (filename + (hashes.length ? ` > ${hashes.join(' > ')}` : ''))
return `${
md.utils.escapeHtml(text)
}`
}
}
function resolveFilenameToAssetPath(filename: string): string {
if (isLinkHttp(filename) || filename[0] === '.' || filename[0] === '/') {
return filename
}
return `/${filename}`
}
interface ParsedHeading {
lineIndex: number
level: number
text: string
}
// 支持: ## 标题 {#id .class key=value} 或 ## 标题 {#id}
const HEADING_HASH_REG = /^#+/
const HEADING_ATTRS_REG = /(?:\{[^}]*\})?$/
function extractContentByHeadings(content: string, headings: string[]): string {
if (!headings.length)
return content
const containers: Record = {}
content = content.replaceAll(/(?:{3,})[\s\S]*?\k/g, (matched) => {
const key = hash(matched)
containers[key] = matched
return ``
})
const lines = content.split(/\r?\n/)
const allHeadings: ParsedHeading[] = []
for (let i = 0; i < lines.length; i++) {
let text = lines[i].trimEnd()
let level = 0
text = text.replace(HEADING_HASH_REG, (matched) => {
level = matched.length
return ''
})
if (level) {
text = text.replace(HEADING_ATTRS_REG, '').trim()
allHeadings.push({ lineIndex: i, level, text })
}
}
// 查找匹配的标题序列(逻辑同上)
let targetHeadingIndex = -1
let currentLevel = 0
let headingPointer = 0
for (let i = 0; i < allHeadings.length; i++) {
const heading = allHeadings[i]
if (headingPointer === 0) {
if (heading.text === headings[0]) {
headingPointer++
currentLevel = heading.level
if (headingPointer === headings.length) {
targetHeadingIndex = i
break
}
}
}
else {
if (heading.level > currentLevel && heading.text === headings[headingPointer]) {
headingPointer++
currentLevel = heading.level
if (headingPointer === headings.length) {
targetHeadingIndex = i
break
}
}
else if (heading.level <= currentLevel) {
if (heading.text === headings[0]) {
headingPointer = 1
currentLevel = heading.level
}
else {
headingPointer = 0
currentLevel = 0
}
}
}
}
if (targetHeadingIndex === -1) {
console.warn(`No heading found for ${headings.join(' > ')}`)
return ''
}
const targetHeading = allHeadings[targetHeadingIndex]
const startLine = targetHeading.lineIndex + 1
const targetLevel = targetHeading.level
let endLine = lines.length
for (let i = targetHeadingIndex + 1; i < allHeadings.length; i++) {
if (allHeadings[i].level <= targetLevel) {
endLine = allHeadings[i].lineIndex
break
}
}
const result = lines.slice(startLine, endLine).join('\n').trim()
return result.replaceAll(//g, (_, key) => containers[key] ?? '')
}