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:
eezd committed 2026-10-17 15:21:47 +08:00
1 parent 7b076346a4
commit 5ac01894e6
26 files changed
+3147 -52

No files matched your search

+2 -2
View File
@@ -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&lt;b">a&lt;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')
})
})
+12 -4
View File
@@ -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 === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;'))
}
@@ -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 ''
}