312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
/**
|
||
* 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] ?? '')
|
||
}
|