Files
RawMark/web/src/share/main.ts
T

189 lines
7.5 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.
// 只读分享页(/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
content: string
updatedAt: number
}
const root = document.getElementById('share')!
const token = decodeURIComponent(location.pathname.split('/')[2] ?? '')
const api = '/api/v1/public/shares/' + encodeURIComponent(token)
function message(text: string) {
root.replaceChildren(Object.assign(document.createElement('p'), { className: 'rm-share-message', textContent: text }))
}
async function load() {
let res: Response
try {
res = await fetch(api, { credentials: 'same-origin', cache: 'no-store' })
} catch {
message('网络错误,请检查连接后刷新')
return
}
if (res.ok) {
const note = (await res.json()) as SharedNote
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<string, unknown>
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
}
render(note)
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('分享不存在、已过期或已被撤销')
else message(`加载失败(${res.status})`)
}
function askPassword(error = '') {
const form = document.createElement('form')
form.className = 'rm-share-password'
form.innerHTML = `
<p>这篇分享需要访问密码</p>
<input type="password" autocomplete="current-password" required aria-label="访问密码" />
<p class="rm-share-error"></p>
<button type="submit">查看</button>`
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 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(header, article)
resolveImages(article)
buildToc(article)
hydrate(article)
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch((error) => {
code.title = '代码高亮失败,已保留源码'
console.error('分享页代码高亮失败', error)
})
}
// 相对地址的图片(笔记的 .assets)经分享接口读取;服务器只允许读取本篇的图片目录
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 = api + '/assets/' + src.replace(/^(\.\/)+/, '')
}
img.loading = 'lazy'
}
}
function buildToc(article: HTMLElement) {
const headings = [...article.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')]
const used = new Set<string>()
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<HTMLElement>('.rm-share-mermaid')) {
const code = el.dataset.code
if (code !== undefined) void renderMermaid(code).then((svg) => { el.innerHTML = svg }).catch(() => { el.textContent = '图表渲染失败,请刷新重试' })
}
})
void load()