// 只读分享页(/s/{token}):取回笔记内容,用与编辑器同一套解析器渲染为 HTML。不加载编辑器
import { copyText } from '../clipboard'
import { readingStats } from '../app/reading'
import { applyDisplaySettings, sanitizeDisplaySettings } from '../themes/typography'
import { requestTokens, type TokenRun } 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
applyDisplaySettings(sanitizeDisplaySettings(appearance))
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)
}
const listing = document.createElement('section')
listing.className = 'rm-share-listing'
listing.append(navigation(), heading, list)
root.replaceChildren(publicHeader(document.title), listing)
if (!directory.entries.length) root.append(Object.assign(document.createElement('p'), { textContent: '此文件夹为空' }))
}
function action(text: string, title: string, run: () => void) {
const button = Object.assign(document.createElement('button'), { type: 'button', textContent: text, title })
button.addEventListener('click', run)
return button
}
function publicHeader(title: string) {
const bar = document.createElement('header')
bar.className = 'rm-public-header'
const brand = Object.assign(document.createElement('a'), { className: 'rm-public-brand', href: pageUrl('', null), textContent: 'RawMark' })
const badge = Object.assign(document.createElement('span'), { className: 'rm-public-badge', textContent: '只读知识库' })
const current = Object.assign(document.createElement('span'), { className: 'rm-public-current', textContent: title })
const controls = document.createElement('div')
controls.className = 'rm-public-actions'
const toc = action('大纲', '显示 / 隐藏文章大纲', () => { const open = root.classList.toggle('toc-open'); toc.setAttribute('aria-expanded', String(open)) })
toc.className = 'rm-reading-action rm-toc-toggle'
toc.setAttribute('aria-expanded', 'false')
const focus = action('专注', '专注阅读', () => { const on = root.classList.toggle('rm-share-focus'); focus.setAttribute('aria-pressed', String(on)); focus.textContent = on ? '退出专注' : '专注' })
focus.className = 'rm-reading-action'
focus.setAttribute('aria-pressed', 'false')
const copy = action('复制链接', '复制当前分享链接', () => { void copyText(location.href).then(ok => { copy.textContent = ok ? '已复制' : '复制失败' }).catch(() => { copy.textContent = '复制失败' }) })
controls.append(toc, focus, copy, action('打印', '打印文章', () => window.print()))
bar.append(brand, badge, current, controls)
return bar
}
let stopReading: (() => void) | undefined
function render(note: SharedNote) {
stopReading?.()
document.title = note.title
const aside = document.createElement('aside')
aside.className = 'rm-share-sidebar'
const header = document.createElement('header')
header.className = 'rm-share-header'
const article = document.createElement('article')
article.className = 'rm-share-content'
// 安全模式:原始 HTML 被转义,危险协议的链接被过滤。
article.innerHTML = renderMarkdown(note.content)
const headings = buildToc(article)
const first = article.firstElementChild
const title = first?.tagName === 'H1' ? first.textContent ?? note.title : note.title.replace(/\.md$/, '')
if (first?.tagName === 'H1') first.remove()
const stats = readingStats(article.textContent ?? '')
header.append(
Object.assign(document.createElement('span'), { className: 'rm-share-kicker', textContent: 'PUBLIC NOTE · 公开分享' }),
first?.tagName === 'H1' ? first : Object.assign(document.createElement('h1'), { textContent: title }),
Object.assign(document.createElement('p'), { className: 'rm-share-time', textContent: `更新于 ${dateFormat.format(note.updatedAt)} · ${stats.words.toLocaleString('zh-CN')} 字词 · 预计阅读 ${stats.minutes} 分钟` }),
)
const surface = document.createElement('div')
surface.className = 'rm-reading-card'
const footer = Object.assign(document.createElement('footer'), { className: 'rm-reading-footer', textContent: '由 RawMark 分享 · 内容来自源文件的最新版本' })
surface.append(header, article, footer)
const layout = document.createElement('div')
layout.className = 'rm-reading-layout'
layout.append(aside, surface)
root.replaceChildren(publicHeader(title), ...(selectedPath || selectedItem !== null ? [navigation()] : []), layout)
const backdrop = action('', '关闭文章大纲', () => root.classList.remove('toc-open'))
backdrop.className = 'rm-share-sidebar-backdrop'
root.append(backdrop)
resolveImages(article)
resolveLinks(article)
resolveLinks(header)
const toc = document.createElement('nav')
toc.className = 'rm-public-toc'
toc.setAttribute('aria-label', '文章大纲')
toc.append(Object.assign(document.createElement('h2'), { textContent: '文章大纲' }))
const min = Math.min(...headings.map(h => Number(h.tagName[1])))
for (const heading of headings) {
const link = Object.assign(document.createElement('a'), { href: '#' + heading.id, textContent: heading.textContent })
link.style.paddingLeft = (Number(heading.tagName[1]) - min) * 14 + 12 + 'px'
link.addEventListener('click', () => root.classList.remove('toc-open'))
toc.append(link)
}
if (!headings.length) toc.append(Object.assign(document.createElement('p'), { textContent: '此文章没有小标题' }))
const progressLabel = Object.assign(document.createElement('div'), { className: 'rm-reading-progress-label' })
const progress = document.createElement('progress'); progress.max = 100; progress.value = 0
progress.setAttribute('aria-label', '阅读进度')
toc.append(progressLabel, progress)
aside.append(toc)
const hint = Object.assign(document.createElement('p'), { className: 'rm-reading-hint', textContent: '当前内容为只读分享,修改由知识库作者在源文件中完成。' })
aside.append(hint)
void sharedNavigation(aside)
hydrate(article)
for (const code of article.querySelectorAll('pre > code')) void decorateCode(code).catch(error => {
code.title = '代码高亮失败,已保留源码'
console.error('分享页代码高亮失败', error)
})
let frame = 0
function update() {
frame = 0
const rect = surface.getBoundingClientRect()
const value = Math.round(Math.max(0, Math.min(100, (innerHeight - rect.top) / Math.max(1, rect.height) * 100)))
progress.value = value
progressLabel.textContent = '阅读进度 · ' + value + '%'
let active = headings[0]
for (const heading of headings) { if (heading.getBoundingClientRect().top < 160) active = heading }
for (const link of toc.querySelectorAll('a')) { const on = link.getAttribute('href') === '#' + active?.id; link.classList.toggle('on', on); if (on) link.setAttribute('aria-current', 'location'); else link.removeAttribute('aria-current') }
}
const schedule = () => { if (!frame) frame = requestAnimationFrame(update) }
window.addEventListener('scroll', schedule, { passive: true })
window.addEventListener('resize', schedule)
const observer = new ResizeObserver(schedule)
observer.observe(surface)
stopReading = () => { observer.disconnect(); window.removeEventListener('scroll', schedule); window.removeEventListener('resize', schedule); cancelAnimationFrame(frame) }
schedule()
if (location.hash) {
try { const id = decodeURIComponent(location.hash.slice(1)); requestAnimationFrame(() => document.getElementById(id)?.scrollIntoView()) }
catch { root.append(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '链接锚点编码无效' })) }
}
}
// 目录导航只读取当前分享主体,不请求登录后的仓库元数据。
async function sharedNavigation(aside: HTMLElement) {
if (!selectedPath) return
try {
const response = await fetch(api + (selectedItem !== null ? '?item=' + encodeURIComponent(selectedItem) : ''), { cache: 'no-store' })
if (!response.ok) throw new Error(`读取分享目录失败(${response.status})`)
const directory = await response.json() as SharedNote | SharedDirectory
if (!directory.dir) return
const nav = document.createElement('nav')
nav.className = 'rm-public-file-nav'
nav.setAttribute('aria-label', '分享目录')
nav.append(Object.assign(document.createElement('h2'), { textContent: directory.title }))
for (const entry of directory.entries) {
if (entry.path.split('/').some(part => part === 'assets' || part.endsWith('.assets')) || !entry.dir && !entry.path.endsWith('.md')) continue
const link = Object.assign(document.createElement('a'), { href: pageUrl(entry.path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') })
if (entry.path === selectedPath) link.className = 'on'
nav.append(link)
}
aside.prepend(nav)
} catch (error) { aside.append(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: error instanceof Error ? error.message : '分享目录读取失败' })) }
}
// 相对引用始终经当前分享读取,范围由服务器按分享主体校验。
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')
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))
return headings
}
async function decorateCode(code: HTMLElement) {
const lang = /language-(\S+)/.exec(code.className)?.[1]?.toLowerCase()
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 pre = code.parentElement!
const tools = document.createElement('div'); tools.className = 'rm-public-code-tools'
const copy = action('复制', '复制代码', () => { void copyText(text).then(ok => { copy.textContent = ok ? '已复制' : '复制失败' }).catch(() => { copy.textContent = '复制失败' }) })
tools.append(Object.assign(document.createElement('span'), { textContent: lang || 'text' }), copy)
pre.prepend(tools)
if (!lang) return
const run = await requestTokens(lang, text)
if (!run) return
const out = document.createDocumentFragment()
function appendTokens(parent: DocumentFragment | HTMLElement, tokens: TokenRun) {
for (const token of tokens) {
if (typeof token === 'string') parent.append(token)
else {
const span = document.createElement('span')
span.className = token.classes
appendTokens(span, token.content)
parent.append(span)
}
}
}
appendTokens(out, run)
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 = '图表渲染失败,请刷新重试' })
}
})
window.addEventListener('keydown', event => { if (event.key === 'Escape') root.classList.remove('toc-open') })
void load()