// 只读分享页(/s/{token}):取回笔记内容,用与编辑器同一套解析器渲染为 HTML。不加载编辑器 import { requestTokens } from '../highlight/client' import { renderMarkdown } from '../markdown/html' import { hydrate, renderMermaid } from '../markdown/render' import { applyAppearance, sanitizeAppearance } from '../themes/appearance' import '../themes/base.css' import './share.css' interface SharedNote { title: string dir: false content: string updatedAt: number } interface SharedDirectory { title: string dir: true entries: { path: string; dir: boolean }[] } interface SharedFile { title: string dir: false } interface SharedCollection { collection: true title: string items: { id: number; title: string; exists: boolean; dir: boolean }[] } const root = document.getElementById('share')! const token = decodeURIComponent(location.pathname.split('/')[2] ?? '') const api = '/api/v1/public/shares/' + encodeURIComponent(token) const selectedItem = new URL(location.href).searchParams.get('item') const selectedPath = new URL(location.href).searchParams.get('path') ?? '' const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/') function pageUrl(path: string, item: string | null = selectedItem) { const query = new URLSearchParams() if (item !== null) query.set('item', item) if (path) query.set('path', path) return location.pathname + (query.size ? '?' + query : '') } function downloadUrl() { const url = new URL(api + '/assets/' + encodePath(selectedPath), location.origin) url.searchParams.set('download', '1') if (selectedItem !== null) url.searchParams.set('item', selectedItem) return url.href } function message(text: string) { root.replaceChildren(Object.assign(document.createElement('p'), { className: 'rm-share-message', textContent: text })) } async function load() { let res: Response try { const query = new URLSearchParams() if (selectedPath) query.set('path', selectedPath) if (selectedItem !== null) query.set('item', selectedItem) res = await fetch(api + (query.size ? '?' + query : ''), { credentials: 'same-origin', cache: 'no-store' }) } catch { message('网络错误,请检查连接后刷新') return } if (res.ok) { const content = (await res.json()) as SharedNote | SharedDirectory | SharedFile | SharedCollection let appearanceError = false try { const response = await fetch(api + '/appearance', { cache: 'no-store' }) if (!response.ok) throw new Error('读取外观失败') const appearance = await response.json() as Record const rootStyle = document.documentElement.style if (typeof appearance.fontSize === 'number') rootStyle.setProperty('--rm-font-size', appearance.fontSize + 'px') if (typeof appearance.contentWidth === 'number') rootStyle.setProperty('--rm-content-width', appearance.contentWidth ? appearance.contentWidth + 'px' : 'none') applyAppearance(sanitizeAppearance(appearance), typeof appearance.themeCSS === 'string' ? appearance.themeCSS : '') } catch { appearanceError = true } if ('collection' in content) renderCollection(content) else if (content.dir) renderDirectory(content) else if ('content' in content) render(content) else { document.title = content.title const link = Object.assign(document.createElement('a'), { href: downloadUrl(), download: content.title, textContent: '下载 ' + content.title }) root.replaceChildren(navigation(), link) } if (appearanceError) root.prepend(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '外观加载失败,可刷新重试' })) } else if (res.status === 401) askPassword() else if (res.status === 404) { message(selectedPath || selectedItem !== null ? '此文件或文件夹已不存在,或不在分享范围内' : '分享不存在、已过期或已被撤销') if (selectedPath || selectedItem !== null) root.prepend(navigation()) } else message(`加载失败(${res.status})`) } function askPassword(error = '') { const form = document.createElement('form') form.className = 'rm-share-password' form.innerHTML = `

这篇分享需要访问密码

` const input = form.querySelector('input')! const button = form.querySelector('button')! form.querySelector('.rm-share-error')!.textContent = error form.addEventListener('submit', async (e) => { e.preventDefault() button.disabled = true try { const res = await fetch(api + '/unlock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.value }), }) if (res.ok) return void load() if (res.status === 404) return message('分享不存在、已过期或已被撤销') askPassword(res.status === 401 ? '密码错误' : res.status === 429 ? '尝试次数过多,请稍后再试' : `验证失败(${res.status})`) } catch { askPassword('网络错误,请重试') } }) root.replaceChildren(form) input.focus() } const dateFormat = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }) function navigation() { const nav = document.createElement('nav') nav.className = 'rm-share-nav' nav.setAttribute('aria-label', '分享导航') nav.append(Object.assign(document.createElement('a'), { href: pageUrl('', null), textContent: '分享首页' })) if (selectedItem !== null) nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(''), textContent: '主体首页' })) const parts = selectedPath.split('/').filter(Boolean) for (let i = 0; i < parts.length; i++) { nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(parts.slice(0, i + 1).join('/')), textContent: parts[i] })) } return nav } function renderCollection(collection: SharedCollection) { document.title = collection.title const heading = Object.assign(document.createElement('h1'), { textContent: collection.title }) const list = document.createElement('ul') list.className = 'rm-share-files' for (const item of collection.items) { const row = document.createElement('li') row.append(item.exists ? Object.assign(document.createElement('a'), { href: pageUrl('', String(item.id)), textContent: item.title + (item.dir ? '/' : '') }) : Object.assign(document.createElement('span'), { textContent: item.title + '(已不可用)' })) list.append(row) } root.replaceChildren(heading, list) } function renderDirectory(directory: SharedDirectory) { document.title = directory.title const heading = Object.assign(document.createElement('h1'), { textContent: directory.title }) const list = document.createElement('ul') list.className = 'rm-share-files' for (const entry of directory.entries) { const li = document.createElement('li') li.style.paddingLeft = (entry.path.split('/').length - 1) * 16 + 'px' const path = selectedPath ? selectedPath + '/' + entry.path : entry.path li.append(Object.assign(document.createElement('a'), { href: pageUrl(path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') })) list.append(li) } root.replaceChildren(navigation(), heading, list) if (!directory.entries.length) root.append(Object.assign(document.createElement('p'), { textContent: '此文件夹为空' })) } function render(note: SharedNote) { document.title = note.title const header = document.createElement('header') header.className = 'rm-share-header' header.append( Object.assign(document.createElement('span'), { className: 'rm-share-title', textContent: note.title }), Object.assign(document.createElement('span'), { className: 'rm-share-time', textContent: '更新于 ' + dateFormat.format(note.updatedAt) }), ) const article = document.createElement('article') article.className = 'rm-share-content' // 安全模式:原始 HTML 被转义,危险协议的链接被过滤 article.innerHTML = renderMarkdown(note.content) root.replaceChildren(...(selectedPath || selectedItem !== null ? [navigation()] : []), header, article) resolveImages(article) resolveLinks(article) buildToc(article) hydrate(article) for (const code of article.querySelectorAll('pre > code')) void decorateCode(code).catch((error) => { code.title = '代码高亮失败,已保留源码' console.error('分享页代码高亮失败', error) }) } // 相对引用始终经当前分享读取,范围由服务器按分享主体校验。 function resourceUrl(src: string): URL | null { try { const prefix = new URL(api + '/assets/', location.origin) const target = new URL(src, new URL(encodePath(selectedPath), prefix)) if (selectedItem !== null) target.searchParams.set('item', selectedItem) return target.origin === prefix.origin && target.pathname.startsWith(prefix.pathname) ? target : null } catch { return null } } function resolveImages(article: HTMLElement) { for (const img of article.querySelectorAll('img')) { const src = img.getAttribute('src') ?? '' if (src && !/^[a-z][a-z\d+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('#')) { img.src = resourceUrl(src)?.href ?? '#' } img.loading = 'lazy' } } function resolveLinks(article: HTMLElement) { for (const link of article.querySelectorAll('a[href]')) { const href = link.getAttribute('href')! if (!href || /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('/') || href.startsWith('#')) continue const target = resourceUrl(href) if (!target) { link.href = '#'; continue } if (selectedPath) { try { const path = decodeURIComponent(target.pathname.slice((api + '/assets/').length)).replace(/\/$/, '') link.href = pageUrl(path) + target.hash } catch { link.href = '#' } } else { target.searchParams.set('download', '1') link.href = target.href link.download = '' } } } function buildToc(article: HTMLElement) { const headings = [...article.querySelectorAll('h1, h2, h3, h4, h5, h6')] const used = new Set() for (const h of headings) { const base = (h.textContent ?? '').trim().toLowerCase().replace(/\s+/g, '-') || 'section' let id = base for (let i = 1; used.has(id) || document.getElementById(id); i++) id = `${base}-${i}` used.add(id) h.id = id } const navs = article.querySelectorAll('nav.toc') if (!navs.length) return const top = Math.min(...headings.map((h) => Number(h.tagName[1]))) const list = document.createElement('ul') for (const h of headings) { const li = document.createElement('li') li.style.paddingLeft = `${(Number(h.tagName[1]) - top) * 1.2}em` li.append(Object.assign(document.createElement('a'), { href: '#' + h.id, textContent: h.textContent })) list.append(li) } for (const nav of navs) nav.replaceChildren(list.cloneNode(true)) } async function decorateCode(code: HTMLElement) { const lang = /language-(\S+)/.exec(code.className)?.[1]?.toLowerCase() if (!lang) return const text = (code.textContent ?? '').replace(/\n$/, '') if (lang === 'mermaid') { const figure = document.createElement('div') figure.className = 'rm-share-mermaid' figure.dataset.code = text try { figure.innerHTML = await renderMermaid(text) code.parentElement!.replaceWith(figure) } catch { // 语法错误时保留源码 } return } const run = await requestTokens(lang, text) if (!run) return const out = document.createDocumentFragment() let at = 0 for (let i = 0; i < run.length; i += 4) { const from = run[i] as number const to = from + (run[i + 1] as number) if (from > at) out.append(text.slice(at, from)) const span = document.createElement('span') const fontStyle = run[i + 3] as number if (run[i + 2]) span.style.color = run[i + 2] as string if (fontStyle & 1) span.style.fontStyle = 'italic' if (fontStyle & 2) span.style.fontWeight = 'bold' if (fontStyle & 4) span.style.textDecoration = 'underline' span.textContent = text.slice(from, to) out.append(span) at = to } out.append(text.slice(at)) code.replaceChildren(out) } window.addEventListener('rawmark-appearance', () => { for (const el of root.querySelectorAll('.rm-share-mermaid')) { const code = el.dataset.code if (code !== undefined) void renderMermaid(code).then((svg) => { el.innerHTML = svg }).catch(() => { el.textContent = '图表渲染失败,请刷新重试' }) } }) void load()