312 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 `<a href="${url}${slug}" target="_blank" rel="noopener noreferrer">${
md.utils.escapeHtml(text)
}</a>`
}
}
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<string, string> = {}
content = content.replaceAll(/(?<mark>:{3,})[\s\S]*?\k<mark>/g, (matched) => {
const key = hash(matched)
containers[key] = matched
return `<!--container:${key}-->`
})
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(/<!--container:(.*?)-->/g, (_, key) => containers[key] ?? '')
}