/** * 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] ?? '') }