Add read-only sharing, API tokens and OpenAPI spec
- Share a note as a live read-only page at /s/{token} with optional password and expiry
- Share page renders with the shared parser (TOC, footnotes, math, Mermaid, code highlighting)
and only serves images from the note's own .assets directory
- Shares follow renames made through the API or externally
- Manage shares and API tokens from the command palette and file tree
- Accept read/write API tokens as Bearer credentials; management endpoints require a session
- Serve a hand-written openapi.json, checked against the route table in tests
This commit is contained in:
1 parent
7b076346a4
commit
5ac01894e6
26 files changed
+3147
-52
No files matched your search
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
|
||||
<title>RawMark</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="share" class="rm-share"></main>
|
||||
<script type="module" src="/src/share/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,6 +14,8 @@ const paths = {
|
||||
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
|
||||
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
|
||||
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
|
||||
link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
||||
key: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zM15.5 7.5l3 3L22 7l-3-3',
|
||||
command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
|
||||
}
|
||||
defineProps<{ name: keyof typeof paths; size?: number }>()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { copyText } from '../editor/widgets'
|
||||
import { api, ApiError, type Share } from './api'
|
||||
import Icon from './Icon.vue'
|
||||
import { noteTitle } from './paths'
|
||||
import { toast } from './workspace'
|
||||
|
||||
// path 为空时列出全部分享(管理列表),否则管理这一篇笔记的分享
|
||||
const props = defineProps<{ path: string | null }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ label: '永久有效', seconds: 0 },
|
||||
{ label: '1 天', seconds: 86400 },
|
||||
{ label: '7 天', seconds: 7 * 86400 },
|
||||
{ label: '30 天', seconds: 30 * 86400 },
|
||||
]
|
||||
|
||||
const shares = ref<Share[] | null>(null)
|
||||
const password = ref('')
|
||||
const expiresIn = ref(0)
|
||||
const busy = ref(false)
|
||||
const title = computed(() => (props.path ? `分享“${noteTitle(props.path)}”` : '所有分享'))
|
||||
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
||||
|
||||
const linkOf = (s: Share) => `${location.origin}/s/${s.token}`
|
||||
const expired = (s: Share) => s.expiresAt !== 0 && s.expiresAt <= Date.now()
|
||||
|
||||
function errorText(err: unknown) {
|
||||
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
shares.value = await api.shares(props.path ?? undefined)
|
||||
} catch (err) {
|
||||
toast('读取分享失败:' + errorText(err))
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
busy.value = true
|
||||
try {
|
||||
const s = await api.createShare(props.path!, password.value, expiresIn.value)
|
||||
shares.value = [s, ...(shares.value ?? [])]
|
||||
password.value = ''
|
||||
await copy(s)
|
||||
} catch (err) {
|
||||
toast('创建分享失败:' + errorText(err))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(s: Share) {
|
||||
toast((await copyText(linkOf(s))) ? '链接已复制' : '复制失败,请手动复制链接')
|
||||
}
|
||||
|
||||
async function revoke(s: Share) {
|
||||
if (!confirm('撤销后该链接立即失效,确定撤销?')) return
|
||||
try {
|
||||
await api.deleteShare(s.token)
|
||||
shares.value = shares.value!.filter((x) => x.token !== s.token)
|
||||
} catch (err) {
|
||||
toast('撤销失败:' + errorText(err))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop" @click.self="emit('close')">
|
||||
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="title">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>{{ title }}</h2>
|
||||
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
<form v-if="path" class="rm-share-form" @submit.prevent="create">
|
||||
<input v-model="password" type="text" autocomplete="off" placeholder="访问密码(可选)" aria-label="访问密码" />
|
||||
<select v-model="expiresIn" aria-label="有效期">
|
||||
<option v-for="o in EXPIRY_OPTIONS" :key="o.seconds" :value="o.seconds">{{ o.label }}</option>
|
||||
</select>
|
||||
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建链接</button>
|
||||
</form>
|
||||
<p class="rm-share-note">分享页只读,显示笔记的当前内容及其 .assets 中的图片;拿到链接的人都能访问。</p>
|
||||
<p v-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何笔记' }}</p>
|
||||
<ul v-else class="rm-trash-list">
|
||||
<li v-for="s in shares ?? []" :key="s.token">
|
||||
<Icon name="link" />
|
||||
<div class="rm-trash-info">
|
||||
<span v-if="!path" class="rm-trash-path">{{ s.path.replace(/\.md$/, '') }}</span>
|
||||
<span class="rm-share-link" :class="{ 'rm-trash-path': path }">{{ linkOf(s) }}</span>
|
||||
<span class="rm-trash-time">
|
||||
{{ fmt.format(s.createdAt) }} 创建
|
||||
<template v-if="s.hasPassword"> · 有密码</template>
|
||||
<template v-if="expired(s)"> · <span class="rm-share-bad">已过期</span></template>
|
||||
<template v-else-if="s.expiresAt"> · {{ fmt.format(s.expiresAt) }} 到期</template>
|
||||
<template v-if="!s.exists"> · <span class="rm-share-bad">笔记不存在</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<button class="rm-btn" @click="copy(s)">复制链接</button>
|
||||
<button class="rm-btn rm-btn-danger" @click="revoke(s)">撤销</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { copyText } from '../editor/widgets'
|
||||
import { api, ApiError, type ApiToken } from './api'
|
||||
import Icon from './Icon.vue'
|
||||
import { toast } from './workspace'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const tokens = ref<ApiToken[] | null>(null)
|
||||
const name = ref('')
|
||||
const scope = ref<ApiToken['scope']>('read')
|
||||
const busy = ref(false)
|
||||
// 刚创建的令牌明文,只在本次对话框中显示
|
||||
const created = ref<string | null>(null)
|
||||
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
||||
|
||||
function errorText(err: unknown) {
|
||||
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
tokens.value = await api.tokens()
|
||||
} catch (err) {
|
||||
toast('读取 API 令牌失败:' + errorText(err))
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await api.createToken(name.value.trim(), scope.value)
|
||||
tokens.value = [res.info, ...(tokens.value ?? [])]
|
||||
created.value = res.token
|
||||
name.value = ''
|
||||
} catch (err) {
|
||||
toast('创建失败:' + errorText(err))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
toast((await copyText(created.value!)) ? '令牌已复制' : '复制失败,请手动选中复制')
|
||||
}
|
||||
|
||||
async function revoke(t: ApiToken) {
|
||||
if (!confirm(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
|
||||
try {
|
||||
await api.deleteToken(t.id)
|
||||
tokens.value = tokens.value!.filter((x) => x.id !== t.id)
|
||||
} catch (err) {
|
||||
toast('吊销失败:' + errorText(err))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop" @click.self="emit('close')">
|
||||
<div class="rm-dialog rm-share-dialog" role="dialog" aria-label="API 令牌">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>API 令牌</h2>
|
||||
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
<form class="rm-share-form" @submit.prevent="create">
|
||||
<input v-model="name" type="text" autocomplete="off" placeholder="名称,如:备份脚本" aria-label="名称" required maxlength="100" />
|
||||
<select v-model="scope" aria-label="权限">
|
||||
<option value="read">只读</option>
|
||||
<option value="write">读写</option>
|
||||
</select>
|
||||
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建</button>
|
||||
</form>
|
||||
<div v-if="created" class="rm-token-created">
|
||||
<p>令牌只显示这一次,请立即保存:</p>
|
||||
<div class="rm-token-value">
|
||||
<code>{{ created }}</code>
|
||||
<button class="rm-btn" @click="copy">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rm-share-note">
|
||||
调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见
|
||||
<a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。
|
||||
</p>
|
||||
<p v-if="tokens && !tokens.length" class="rm-tree-empty">还没有 API 令牌</p>
|
||||
<ul v-else class="rm-trash-list">
|
||||
<li v-for="t in tokens ?? []" :key="t.id">
|
||||
<Icon name="key" />
|
||||
<div class="rm-trash-info">
|
||||
<span class="rm-trash-path">{{ t.name }} · {{ t.scope === 'write' ? '读写' : '只读' }}</span>
|
||||
<span class="rm-trash-time">
|
||||
{{ fmt.format(t.createdAt) }} 创建 · {{ t.lastUsedAt ? fmt.format(t.lastUsedAt) + ' 最近使用' : '从未使用' }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="rm-btn rm-btn-danger" @click="revoke(t)">吊销</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,13 +41,14 @@ function commitRename() {
|
||||
if (target && target !== props.node.path) void move(props.node.path, target)
|
||||
}
|
||||
|
||||
function runMenu(action: 'note' | 'folder' | 'rename' | 'delete') {
|
||||
function runMenu(action: 'note' | 'folder' | 'rename' | 'share' | 'delete') {
|
||||
menuOpen.value = false
|
||||
const path = props.node.path
|
||||
if (action === 'note' || action === 'folder') {
|
||||
expanded.add(path)
|
||||
void (action === 'note' ? createNote(path) : createFolder(path))
|
||||
} else if (action === 'rename') ws.renaming = path
|
||||
else if (action === 'share') ws.shareDialog = { path }
|
||||
else void remove(path)
|
||||
}
|
||||
|
||||
@@ -114,6 +115,7 @@ function onDrop(e: DragEvent) {
|
||||
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
|
||||
</template>
|
||||
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
||||
<button v-if="!node.dir" @click="runMenu('share')"><Icon name="link" />分享</button>
|
||||
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
||||
</div>
|
||||
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
|
||||
|
||||
@@ -6,6 +6,8 @@ import EditorPane from './EditorPane.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import NoteBanner from './NoteBanner.vue'
|
||||
import QuickSwitcher from './QuickSwitcher.vue'
|
||||
import ShareDialog from './ShareDialog.vue'
|
||||
import TokensDialog from './TokensDialog.vue'
|
||||
import { noteTitle } from './paths'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import TabBar from './TabBar.vue'
|
||||
@@ -67,5 +69,7 @@ onBeforeUnmount(() => {
|
||||
</main>
|
||||
<QuickSwitcher v-if="ws.switcher !== null" />
|
||||
<TrashDialog v-if="ws.trashOpen" @close="ws.trashOpen = false" />
|
||||
<ShareDialog v-if="ws.shareDialog" :path="ws.shareDialog.path" @close="ws.shareDialog = null" />
|
||||
<TokensDialog v-if="ws.tokensOpen" @close="ws.tokensOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,6 +21,26 @@ export interface SearchResult {
|
||||
snippet: string
|
||||
}
|
||||
|
||||
export interface Share {
|
||||
token: string
|
||||
path: string
|
||||
createdAt: number
|
||||
// 0 为永久
|
||||
expiresAt: number
|
||||
hasPassword: boolean
|
||||
// 笔记是否仍存在
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
id: string
|
||||
name: string
|
||||
scope: 'read' | 'write'
|
||||
createdAt: number
|
||||
// 0 为从未使用
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
@@ -106,6 +126,19 @@ export const api = {
|
||||
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
|
||||
},
|
||||
|
||||
shares: async (path?: string) =>
|
||||
(await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares,
|
||||
// expiresIn 为有效时长(秒),0 为永久
|
||||
createShare: (path: string, password: string, expiresIn: number) =>
|
||||
json<Share>(request('POST', '/api/v1/shares', { json: { path, password, expiresIn } })),
|
||||
deleteShare: (token: string) => request('DELETE', '/api/v1/shares/' + encodeURIComponent(token)),
|
||||
|
||||
tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens,
|
||||
// 返回的 token 明文只有这一次
|
||||
createToken: (name: string, scope: ApiToken['scope']) =>
|
||||
json<{ token: string; info: ApiToken }>(request('POST', '/api/v1/tokens', { json: { name, scope } })),
|
||||
deleteToken: (id: string) => request('DELETE', '/api/v1/tokens/' + encodeURIComponent(id)),
|
||||
|
||||
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
|
||||
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
||||
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
|
||||
|
||||
@@ -354,6 +354,30 @@ button {
|
||||
}
|
||||
.rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; }
|
||||
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: 16px; }
|
||||
.rm-share-dialog { max-width: 640px; }
|
||||
.rm-share-form { display: flex; gap: 8px; padding: 4px 20px 0; }
|
||||
.rm-share-form input,
|
||||
.rm-share-form select {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--rm-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--rm-bg);
|
||||
color: var(--rm-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.rm-share-form input { flex: 1; min-width: 0; }
|
||||
.rm-share-form input:focus,
|
||||
.rm-share-form select:focus { border-color: var(--rm-accent); }
|
||||
.rm-share-note { margin: 10px 20px 4px; font-size: 12px; color: var(--rm-text-muted); line-height: 1.6; }
|
||||
.rm-share-note a { color: var(--rm-accent-strong); }
|
||||
.rm-share-link { font-family: var(--rm-font-mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rm-share-bad { color: var(--rm-danger); }
|
||||
.rm-token-created { margin: 12px 20px 0; padding: 10px 12px; border-radius: 8px; background: var(--rm-selection); font-size: 13px; }
|
||||
.rm-token-created p { margin: 0 0 6px; }
|
||||
.rm-token-value { display: flex; align-items: center; gap: 8px; }
|
||||
.rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: 12px; user-select: all; }
|
||||
.rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; }
|
||||
.rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); }
|
||||
.rm-trash-list li:hover { background: var(--rm-hover); }
|
||||
|
||||
@@ -46,6 +46,9 @@ export const ws = reactive({
|
||||
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
|
||||
switcher: null as string | null,
|
||||
trashOpen: false,
|
||||
// 分享对话框:path 为笔记路径,null 为全部分享的管理列表
|
||||
shareDialog: null as { path: string | null } | null,
|
||||
tokensOpen: false,
|
||||
// 最近打开的笔记,最新的在前
|
||||
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
||||
toasts: [] as Toast[],
|
||||
@@ -758,6 +761,9 @@ function registerBuiltinCommands() {
|
||||
// 浏览器不允许网页覆盖 Ctrl+W
|
||||
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
|
||||
{ id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) },
|
||||
{ id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.shareDialog = { path: ws.active }) },
|
||||
{ id: 'app.shares', title: '管理分享链接', run: () => (ws.shareDialog = { path: null }) },
|
||||
{ id: 'app.tokens', title: 'API 令牌', run: () => (ws.tokensOpen = true) },
|
||||
]) {
|
||||
registerCommand(cmd)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export class HorizontalRuleWidget extends WidgetType {
|
||||
}
|
||||
|
||||
// 写入剪贴板;Clipboard API 只在 HTTPS/localhost 可用,局域网 HTTP 访问时退回 execCommand
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('安全模式', () => {
|
||||
|
||||
describe('Typora 扩展', () => {
|
||||
it('高亮、上下标、脚注引用', () => {
|
||||
expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('<p><mark>a</mark> x<sup>2</sup> H<sub>2</sub>O<sup class="footnote-ref">1</sup></p>\n')
|
||||
expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('<p><mark>a</mark> x<sup>2</sup> H<sub>2</sub>O<sup class="footnote-ref"><a href="#fn-1">1</a></sup></p>\n')
|
||||
})
|
||||
it('公式与 emoji 输出待填充的占位元素,内容被转义', () => {
|
||||
expect(renderMarkdown('$a<b$ :smile:')).toBe('<p><span data-math="a<b">a<b</span> <span data-emoji="smile">:smile:</span></p>\n')
|
||||
@@ -67,7 +67,7 @@ describe('Typora 扩展', () => {
|
||||
)
|
||||
})
|
||||
it('脚注定义、front matter 与 [TOC]', () => {
|
||||
expect(renderMarkdown('---\ntitle: x\n---\n[TOC]\n\n[^1]: 说明 **重点**')).toBe('<div class="footnote"><sup>1</sup> 说明 <strong>重点</strong></div>\n')
|
||||
expect(renderMarkdown('---\ntitle: x\n---\n[TOC]\n\n[^1]: 说明 **重点**')).toBe('<nav class="toc"></nav>\n<div class="footnote" id="fn-1"><sup>1</sup> 说明 <strong>重点</strong></div>\n')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ interface LinkDefinition {
|
||||
|
||||
const BLOCK_CONTAINERS = new Set(['Blockquote', 'ListItem'])
|
||||
|
||||
const footnoteId = (label: string) => 'fn-' + label.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"'))
|
||||
}
|
||||
@@ -275,8 +277,10 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
const raw = text(node.from, node.to)
|
||||
return `<span data-emoji="${escapeHtml(raw.slice(1, -1))}">${escapeHtml(raw)}</span>`
|
||||
}
|
||||
case 'FootnoteReference':
|
||||
return `<sup class="footnote-ref">${escapeHtml(text(node.from + 2, node.to - 1))}</sup>`
|
||||
case 'FootnoteReference': {
|
||||
const label = text(node.from + 2, node.to - 1)
|
||||
return `<sup class="footnote-ref"><a href="#${escapeAttr(footnoteId(label))}">${escapeHtml(label)}</a></sup>`
|
||||
}
|
||||
case 'InlineCode': {
|
||||
const marks = node.getChildren('CodeMark')
|
||||
let code = text(marks[0].to, marks[marks.length - 1].from).replace(/\n/g, ' ')
|
||||
@@ -439,9 +443,13 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
||||
}
|
||||
case 'FootnoteDefinition': {
|
||||
const label = node.getChild('FootnoteLabel')!
|
||||
return `<div class="footnote"><sup>${escapeHtml(text(label.from + 2, label.to - 2))}</sup> ${inline(node, label.to, node.to).trim()}</div>\n`
|
||||
const name = text(label.from + 2, label.to - 2)
|
||||
return `<div class="footnote" id="${escapeAttr(footnoteId(name))}"><sup>${escapeHtml(name)}</sup> ${inline(node, label.to, node.to).trim()}</div>\n`
|
||||
}
|
||||
// front matter 是元数据,不输出;[TOC] 在只读分享页面(M4)中生成
|
||||
// 目录占位,由分享页根据标题填充
|
||||
case 'TableOfContents':
|
||||
return '<nav class="toc"></nav>\n'
|
||||
// front matter 是元数据,不输出
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// 只读分享页(/s/{token}):取回笔记内容,用与编辑器同一套解析器渲染为 HTML。不加载编辑器
|
||||
import { requestTokens } from '../highlight/client'
|
||||
import { renderMarkdown } from '../markdown/html'
|
||||
import { hydrate, renderMermaid } from '../markdown/render'
|
||||
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' })
|
||||
} catch {
|
||||
message('网络错误,请检查连接后刷新')
|
||||
return
|
||||
}
|
||||
if (res.ok) render((await res.json()) as SharedNote)
|
||||
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)
|
||||
}
|
||||
|
||||
// 相对地址的图片(笔记的 .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'
|
||||
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)
|
||||
}
|
||||
|
||||
void load()
|
||||
@@ -0,0 +1,112 @@
|
||||
/* 只读分享页:排版与编辑器渲染效果保持一致(同一套 --rm-* 变量) */
|
||||
.rm-share {
|
||||
max-width: var(--rm-content-width);
|
||||
margin: 0 auto;
|
||||
padding: 24px 24px 80px;
|
||||
font-size: var(--rm-font-size);
|
||||
line-height: var(--rm-line-height);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.rm-share-message { margin-top: 30vh; text-align: center; color: var(--rm-text-muted); }
|
||||
|
||||
.rm-share-password { display: flex; flex-direction: column; gap: 12px; max-width: 300px; margin: 25vh auto 0; }
|
||||
.rm-share-password p { margin: 0; }
|
||||
.rm-share-password input {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--rm-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--rm-bg);
|
||||
color: var(--rm-text);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
}
|
||||
.rm-share-password input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); }
|
||||
.rm-share-password button {
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--rm-accent);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rm-share-password button:disabled { opacity: 0.6; }
|
||||
.rm-share-error { min-height: 1.2em; color: #e5484d; font-size: 13px; }
|
||||
|
||||
.rm-share-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--rm-divider);
|
||||
color: var(--rm-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.rm-share-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rm-share-time { flex: none; }
|
||||
|
||||
.rm-share-content h1 { font-size: 2em; line-height: 1.3; margin: 0.9em 0 0.4em; }
|
||||
.rm-share-content h2 { font-size: 1.6em; line-height: 1.35; margin: 0.9em 0 0.4em; }
|
||||
.rm-share-content h3 { font-size: 1.3em; line-height: 1.4; margin: 0.8em 0 0.4em; }
|
||||
.rm-share-content h4 { font-size: 1.15em; margin: 0.8em 0 0.4em; }
|
||||
.rm-share-content h5,
|
||||
.rm-share-content h6 { font-size: 1em; margin: 0.8em 0 0.4em; }
|
||||
.rm-share-content h6 { color: var(--rm-text-muted); }
|
||||
.rm-share-content p { margin: 0.6em 0; }
|
||||
.rm-share-content a { color: var(--rm-accent-strong); text-decoration: none; }
|
||||
.rm-share-content a:hover { text-decoration: underline; }
|
||||
.rm-share-content img { max-width: 100%; border-radius: 4px; }
|
||||
.rm-share-content mark { background: var(--rm-highlight-bg); color: inherit; padding: 0 2px; border-radius: 2px; }
|
||||
.rm-share-content hr { border: 0; border-top: 1px solid var(--rm-divider); margin: 1.5em 0; }
|
||||
.rm-share-content blockquote { margin: 0.8em 0; padding-left: 16px; border-left: 3px solid var(--rm-divider); color: var(--rm-text-muted); }
|
||||
.rm-share-content ul,
|
||||
.rm-share-content ol { padding-left: 1.6em; }
|
||||
.rm-share-content li > input[type='checkbox'] { margin: 0 0.4em 0 -1.4em; accent-color: var(--rm-accent); vertical-align: -0.1em; }
|
||||
.rm-share-content li:has(> input[type='checkbox']) { list-style: none; }
|
||||
|
||||
.rm-share-content code {
|
||||
font-family: var(--rm-font-mono);
|
||||
font-size: 0.875em;
|
||||
background: var(--rm-inline-code-bg);
|
||||
color: var(--rm-inline-code-text);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.rm-share-content pre {
|
||||
margin: 0.8em 0;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
background: var(--rm-code-bg);
|
||||
overflow-x: auto;
|
||||
line-height: 1.5;
|
||||
tab-size: 4;
|
||||
}
|
||||
.rm-share-content pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: var(--rm-code-text);
|
||||
font-size: var(--rm-code-font-size);
|
||||
}
|
||||
|
||||
.rm-share-content table { border-collapse: collapse; display: block; max-width: 100%; width: fit-content; overflow-x: auto; margin: 0.8em 0; font-size: 0.95em; line-height: 1.5; }
|
||||
.rm-share-content th,
|
||||
.rm-share-content td { border: 1px solid var(--rm-divider); padding: 6px 12px; }
|
||||
.rm-share-content th { font-weight: 600; background: var(--rm-inline-code-bg); }
|
||||
|
||||
.rm-share-content div[data-math] { margin: 0.8em 0; overflow-x: auto; }
|
||||
.rm-share-mermaid { margin: 0.8em 0; text-align: center; overflow-x: auto; }
|
||||
|
||||
.rm-share-content nav.toc { margin: 0.8em 0; padding: 8px 16px; border-left: 3px solid var(--rm-divider); }
|
||||
.rm-share-content nav.toc ul { list-style: none; margin: 0; padding: 0; }
|
||||
.rm-share-content nav.toc li { margin: 2px 0; }
|
||||
|
||||
.rm-share-content .footnote-ref a { text-decoration: none; }
|
||||
.rm-share-content .footnote { margin-top: 0.4em; font-size: 0.9em; color: var(--rm-text-muted); }
|
||||
.rm-share-content .footnote:first-of-type { margin-top: 2em; padding-top: 1em; border-top: 1px solid var(--rm-divider); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.rm-share { padding: 16px 16px 60px; }
|
||||
.rm-share-time { display: none; }
|
||||
}
|
||||
+15
-3
@@ -2,11 +2,23 @@ import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
// 开发服务器中把分享地址 /s/{token} 交给分享页入口(生产环境由 Go 服务处理)
|
||||
{
|
||||
name: 'rawmark-share-route',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
if (req.url?.startsWith('/s/')) req.url = '/share.html'
|
||||
next()
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
// demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用
|
||||
input: { index: 'index.html', demo: 'demo.html' },
|
||||
// demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用;share.html 为只读分享页(/s/{token})
|
||||
input: { index: 'index.html', demo: 'demo.html', share: 'share.html' },
|
||||
},
|
||||
},
|
||||
worker: { format: 'es' },
|
||||
|
||||
Reference in new issue
Block a user