dsh-tiddlywiki 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +12 -6
- package/cordis.patch.yml +13 -13
- package/lib/client.bundle.js +2173 -1652
- package/lib/client.js +2183 -1662
- package/lib/index.js +2424 -2263
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/editor-popup.ts +121 -121
- package/src/client/index.ts +2 -0
- package/src/client/markdown.ts +137 -0
- package/src/client/note-widget.ts +515 -373
- package/src/client/panel.ts +51 -25
- package/src/client/settings-page.ts +32 -2
- package/src/client/sidebar-entry.ts +148 -148
- package/src/client/state.ts +39 -39
- package/src/client/styles.ts +111 -28
- package/src/client/sync-button.ts +188 -0
- package/src/client/toast.ts +22 -22
- package/src/host/admin.ts +408 -408
- package/src/host/config.ts +4 -2
- package/src/host/git.ts +13 -3
- package/src/host/routes.ts +151 -1
- package/src/host/seed-notes.ts +79 -122
- package/src/host/tools.ts +28 -1
- package/src/host/tw-api.ts +157 -157
- package/src/host/wiki.ts +287 -287
- package/src/index.ts +13 -12
- package/src/sdk.ts +198 -198
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tiddlywiki",
|
|
3
3
|
"description": "TiddlyWiki 5 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search/get/put/delete + git sync), a full TiddlyWiki editor embedded in the GUI center column, a floating quick-note widget in the chat area, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Floating popup iframe that loads TiddlyWiki's NATIVE editor for a draft
|
|
3
|
-
* (quick-note "✏️ 在 TW 中编辑"). A small draggable + resizable overlay with its
|
|
4
|
-
* own iframe pointed at `twUrl#<draftTitle>`: the fragment-only navigation
|
|
5
|
-
* triggers TW's hashchange, which opens the draft in the story, and because the
|
|
6
|
-
* draft tiddler carries `draft.of` the story renders the native EditTemplate.
|
|
7
|
-
*
|
|
8
|
-
* Independent of the center panel — a separate floating window so the user can
|
|
9
|
-
* edit a note without leaving the chat context.
|
|
10
|
-
*
|
|
11
|
-
* @module dsh-tiddlywiki/client/editor-popup
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
let root: HTMLDivElement | undefined
|
|
15
|
-
let frame: HTMLIFrameElement | undefined
|
|
16
|
-
let titleEl: HTMLSpanElement | undefined
|
|
17
|
-
|
|
18
|
-
/** Open (create on first use) the popup and load `url` (twUrl#draftTitle). */
|
|
19
|
-
export function openEditorPopup(url: string, label: string): void {
|
|
20
|
-
ensurePopup()
|
|
21
|
-
if (root === undefined || frame === undefined) return
|
|
22
|
-
if (titleEl !== undefined) titleEl.textContent = `TiddlyWiki 编辑器 · ${label}`
|
|
23
|
-
root.style.display = ''
|
|
24
|
-
// Same-base fragment navigation reloads nothing; a changed base (restart on a
|
|
25
|
-
// new port) reloads the app and TW still opens the draft from the hash.
|
|
26
|
-
frame.src = url
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Remove the popup DOM entirely (plugin dispose). */
|
|
30
|
-
export function disposeEditorPopup(): void {
|
|
31
|
-
root?.remove()
|
|
32
|
-
root = undefined
|
|
33
|
-
frame = undefined
|
|
34
|
-
titleEl = undefined
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function ensurePopup(): void {
|
|
38
|
-
if (root !== undefined && frame !== undefined) return
|
|
39
|
-
|
|
40
|
-
root = document.createElement('div')
|
|
41
|
-
root.className = 'dsh-tw-editor-popup'
|
|
42
|
-
root.style.display = 'none'
|
|
43
|
-
|
|
44
|
-
const bar = document.createElement('div')
|
|
45
|
-
bar.className = 'dsh-tw-editor-bar'
|
|
46
|
-
titleEl = document.createElement('span')
|
|
47
|
-
titleEl.className = 'dsh-tw-editor-title'
|
|
48
|
-
titleEl.textContent = 'TiddlyWiki 编辑器'
|
|
49
|
-
const close = document.createElement('button')
|
|
50
|
-
close.type = 'button'
|
|
51
|
-
close.className = 'dsh-tw-editor-close'
|
|
52
|
-
close.textContent = '✕'
|
|
53
|
-
close.title = '关闭'
|
|
54
|
-
bar.append(titleEl, close)
|
|
55
|
-
|
|
56
|
-
frame = document.createElement('iframe')
|
|
57
|
-
frame.className = 'dsh-tw-editor-frame'
|
|
58
|
-
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups')
|
|
59
|
-
frame.title = 'TiddlyWiki 编辑器'
|
|
60
|
-
|
|
61
|
-
const resize = document.createElement('div')
|
|
62
|
-
resize.className = 'dsh-tw-editor-resize'
|
|
63
|
-
resize.title = '拖拽调整大小'
|
|
64
|
-
|
|
65
|
-
root.append(bar, frame, resize)
|
|
66
|
-
document.body.append(root)
|
|
67
|
-
|
|
68
|
-
close.addEventListener('click', () => {
|
|
69
|
-
if (root !== undefined) root.style.display = 'none'
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
// Drag by the title bar (un-center by setting explicit left/top + margin 0).
|
|
73
|
-
bar.addEventListener('mousedown', (event) => {
|
|
74
|
-
if (event.button !== 0 || root === undefined) return
|
|
75
|
-
event.preventDefault()
|
|
76
|
-
const rect = root.getBoundingClientRect()
|
|
77
|
-
const startX = event.clientX
|
|
78
|
-
const startY = event.clientY
|
|
79
|
-
const baseLeft = rect.left
|
|
80
|
-
const baseTop = rect.top
|
|
81
|
-
const onMove = (ev: MouseEvent): void => {
|
|
82
|
-
if (root === undefined) return
|
|
83
|
-
root.style.left = `${baseLeft + ev.clientX - startX}px`
|
|
84
|
-
root.style.top = `${baseTop + ev.clientY - startY}px`
|
|
85
|
-
root.style.margin = '0'
|
|
86
|
-
root.style.right = 'auto'
|
|
87
|
-
root.style.bottom = 'auto'
|
|
88
|
-
}
|
|
89
|
-
const onUp = (): void => {
|
|
90
|
-
window.removeEventListener('mousemove', onMove)
|
|
91
|
-
window.removeEventListener('mouseup', onUp)
|
|
92
|
-
}
|
|
93
|
-
window.addEventListener('mousemove', onMove)
|
|
94
|
-
window.addEventListener('mouseup', onUp)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
// Resize from the bottom-right corner.
|
|
98
|
-
resize.addEventListener('mousedown', (event) => {
|
|
99
|
-
if (event.button !== 0 || root === undefined) return
|
|
100
|
-
event.preventDefault()
|
|
101
|
-
event.stopPropagation()
|
|
102
|
-
const rect = root.getBoundingClientRect()
|
|
103
|
-
const startX = event.clientX
|
|
104
|
-
const startY = event.clientY
|
|
105
|
-
const baseW = rect.width
|
|
106
|
-
const baseH = rect.height
|
|
107
|
-
const onMove = (ev: MouseEvent): void => {
|
|
108
|
-
if (root === undefined) return
|
|
109
|
-
root.style.width = `${Math.max(360, baseW + ev.clientX - startX)}px`
|
|
110
|
-
root.style.height = `${Math.max(260, baseH + ev.clientY - startY)}px`
|
|
111
|
-
root.style.right = 'auto'
|
|
112
|
-
root.style.bottom = 'auto'
|
|
113
|
-
}
|
|
114
|
-
const onUp = (): void => {
|
|
115
|
-
window.removeEventListener('mousemove', onMove)
|
|
116
|
-
window.removeEventListener('mouseup', onUp)
|
|
117
|
-
}
|
|
118
|
-
window.addEventListener('mousemove', onMove)
|
|
119
|
-
window.addEventListener('mouseup', onUp)
|
|
120
|
-
})
|
|
121
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Floating popup iframe that loads TiddlyWiki's NATIVE editor for a draft
|
|
3
|
+
* (quick-note "✏️ 在 TW 中编辑"). A small draggable + resizable overlay with its
|
|
4
|
+
* own iframe pointed at `twUrl#<draftTitle>`: the fragment-only navigation
|
|
5
|
+
* triggers TW's hashchange, which opens the draft in the story, and because the
|
|
6
|
+
* draft tiddler carries `draft.of` the story renders the native EditTemplate.
|
|
7
|
+
*
|
|
8
|
+
* Independent of the center panel — a separate floating window so the user can
|
|
9
|
+
* edit a note without leaving the chat context.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-tiddlywiki/client/editor-popup
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let root: HTMLDivElement | undefined
|
|
15
|
+
let frame: HTMLIFrameElement | undefined
|
|
16
|
+
let titleEl: HTMLSpanElement | undefined
|
|
17
|
+
|
|
18
|
+
/** Open (create on first use) the popup and load `url` (twUrl#draftTitle). */
|
|
19
|
+
export function openEditorPopup(url: string, label: string): void {
|
|
20
|
+
ensurePopup()
|
|
21
|
+
if (root === undefined || frame === undefined) return
|
|
22
|
+
if (titleEl !== undefined) titleEl.textContent = `TiddlyWiki 编辑器 · ${label}`
|
|
23
|
+
root.style.display = ''
|
|
24
|
+
// Same-base fragment navigation reloads nothing; a changed base (restart on a
|
|
25
|
+
// new port) reloads the app and TW still opens the draft from the hash.
|
|
26
|
+
frame.src = url
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Remove the popup DOM entirely (plugin dispose). */
|
|
30
|
+
export function disposeEditorPopup(): void {
|
|
31
|
+
root?.remove()
|
|
32
|
+
root = undefined
|
|
33
|
+
frame = undefined
|
|
34
|
+
titleEl = undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ensurePopup(): void {
|
|
38
|
+
if (root !== undefined && frame !== undefined) return
|
|
39
|
+
|
|
40
|
+
root = document.createElement('div')
|
|
41
|
+
root.className = 'dsh-tw-editor-popup'
|
|
42
|
+
root.style.display = 'none'
|
|
43
|
+
|
|
44
|
+
const bar = document.createElement('div')
|
|
45
|
+
bar.className = 'dsh-tw-editor-bar'
|
|
46
|
+
titleEl = document.createElement('span')
|
|
47
|
+
titleEl.className = 'dsh-tw-editor-title'
|
|
48
|
+
titleEl.textContent = 'TiddlyWiki 编辑器'
|
|
49
|
+
const close = document.createElement('button')
|
|
50
|
+
close.type = 'button'
|
|
51
|
+
close.className = 'dsh-tw-editor-close'
|
|
52
|
+
close.textContent = '✕'
|
|
53
|
+
close.title = '关闭'
|
|
54
|
+
bar.append(titleEl, close)
|
|
55
|
+
|
|
56
|
+
frame = document.createElement('iframe')
|
|
57
|
+
frame.className = 'dsh-tw-editor-frame'
|
|
58
|
+
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups')
|
|
59
|
+
frame.title = 'TiddlyWiki 编辑器'
|
|
60
|
+
|
|
61
|
+
const resize = document.createElement('div')
|
|
62
|
+
resize.className = 'dsh-tw-editor-resize'
|
|
63
|
+
resize.title = '拖拽调整大小'
|
|
64
|
+
|
|
65
|
+
root.append(bar, frame, resize)
|
|
66
|
+
document.body.append(root)
|
|
67
|
+
|
|
68
|
+
close.addEventListener('click', () => {
|
|
69
|
+
if (root !== undefined) root.style.display = 'none'
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Drag by the title bar (un-center by setting explicit left/top + margin 0).
|
|
73
|
+
bar.addEventListener('mousedown', (event) => {
|
|
74
|
+
if (event.button !== 0 || root === undefined) return
|
|
75
|
+
event.preventDefault()
|
|
76
|
+
const rect = root.getBoundingClientRect()
|
|
77
|
+
const startX = event.clientX
|
|
78
|
+
const startY = event.clientY
|
|
79
|
+
const baseLeft = rect.left
|
|
80
|
+
const baseTop = rect.top
|
|
81
|
+
const onMove = (ev: MouseEvent): void => {
|
|
82
|
+
if (root === undefined) return
|
|
83
|
+
root.style.left = `${baseLeft + ev.clientX - startX}px`
|
|
84
|
+
root.style.top = `${baseTop + ev.clientY - startY}px`
|
|
85
|
+
root.style.margin = '0'
|
|
86
|
+
root.style.right = 'auto'
|
|
87
|
+
root.style.bottom = 'auto'
|
|
88
|
+
}
|
|
89
|
+
const onUp = (): void => {
|
|
90
|
+
window.removeEventListener('mousemove', onMove)
|
|
91
|
+
window.removeEventListener('mouseup', onUp)
|
|
92
|
+
}
|
|
93
|
+
window.addEventListener('mousemove', onMove)
|
|
94
|
+
window.addEventListener('mouseup', onUp)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Resize from the bottom-right corner.
|
|
98
|
+
resize.addEventListener('mousedown', (event) => {
|
|
99
|
+
if (event.button !== 0 || root === undefined) return
|
|
100
|
+
event.preventDefault()
|
|
101
|
+
event.stopPropagation()
|
|
102
|
+
const rect = root.getBoundingClientRect()
|
|
103
|
+
const startX = event.clientX
|
|
104
|
+
const startY = event.clientY
|
|
105
|
+
const baseW = rect.width
|
|
106
|
+
const baseH = rect.height
|
|
107
|
+
const onMove = (ev: MouseEvent): void => {
|
|
108
|
+
if (root === undefined) return
|
|
109
|
+
root.style.width = `${Math.max(360, baseW + ev.clientX - startX)}px`
|
|
110
|
+
root.style.height = `${Math.max(260, baseH + ev.clientY - startY)}px`
|
|
111
|
+
root.style.right = 'auto'
|
|
112
|
+
root.style.bottom = 'auto'
|
|
113
|
+
}
|
|
114
|
+
const onUp = (): void => {
|
|
115
|
+
window.removeEventListener('mousemove', onMove)
|
|
116
|
+
window.removeEventListener('mouseup', onUp)
|
|
117
|
+
}
|
|
118
|
+
window.addEventListener('mousemove', onMove)
|
|
119
|
+
window.addEventListener('mouseup', onUp)
|
|
120
|
+
})
|
|
121
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { PanelState } from './state.ts'
|
|
|
16
16
|
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
17
17
|
import { mountPanel } from './panel.ts'
|
|
18
18
|
import { mountNoteWidget } from './note-widget.ts'
|
|
19
|
+
import { mountSyncButton } from './sync-button.ts'
|
|
19
20
|
import { disposeEditorPopup } from './editor-popup.ts'
|
|
20
21
|
import { SettingsSection } from './settings-page.ts'
|
|
21
22
|
|
|
@@ -50,6 +51,7 @@ export function apply(ctx: ClientContextFace): void {
|
|
|
50
51
|
disposers.push(mountSidebarEntry(state))
|
|
51
52
|
disposers.push(mountPanel(state))
|
|
52
53
|
disposers.push(mountNoteWidget())
|
|
54
|
+
disposers.push(mountSyncButton())
|
|
53
55
|
disposers.push(disposeEditorPopup)
|
|
54
56
|
} catch (error) {
|
|
55
57
|
// DOM failures degrade the plugin, never the GUI.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Markdown syntax highlighter for the quick-note editor.
|
|
3
|
+
*
|
|
4
|
+
* The editor is the classic "highlighted textarea": a `<pre>` rendered BEHIND
|
|
5
|
+
* a transparent-text `<textarea>` with identical metrics. To keep the two
|
|
6
|
+
* layers perfectly aligned we NEVER change the user's text — every token is
|
|
7
|
+
* only wrapped in a colored `<span class="md-*">`, so each line keeps its
|
|
8
|
+
* exact characters and column widths. Leading whitespace (indentation) is
|
|
9
|
+
* preserved as-is so list/heading/quote alignment survives.
|
|
10
|
+
*
|
|
11
|
+
* Coverage is deliberately pragmatic (a scratch-note editor, not CommonMark):
|
|
12
|
+
* ATX headings, fenced code blocks, blockquotes, bullet + ordered + task
|
|
13
|
+
* lists, horizontal rules, inline code, bold, italic, strikethrough, links
|
|
14
|
+
* and images. HTML is escaped first, so the output is always safe to set as
|
|
15
|
+
* `innerHTML` (no raw HTML from the user is ever executed).
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-tiddlywiki/client/markdown
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Escape a raw source fragment for safe insertion into the overlay HTML. */
|
|
21
|
+
export function escapeHtml(value: string): string {
|
|
22
|
+
return value
|
|
23
|
+
.replace(/&/g, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Inline token highlighting on ALREADY-ESCAPED text. Inline code spans are
|
|
30
|
+
* first pulled out into placeholders so later passes (links/bold/italic) can
|
|
31
|
+
* never reach inside them, then restored.
|
|
32
|
+
*/
|
|
33
|
+
function highlightInline(escaped: string): string {
|
|
34
|
+
// Inline code spans are pulled out first so later passes never reach inside.
|
|
35
|
+
const codeTokens: string[] = []
|
|
36
|
+
let s = escaped.replace(/(`+)([^`]+?)\1/g, (_all, t: string, code: string) => {
|
|
37
|
+
codeTokens.push(`<span class="md-code">${t}${code}${t}</span>`)
|
|
38
|
+
return `\u0000${codeTokens.length - 1}\u0000`
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Images and links: keep the literal markdown text, color the pieces.
|
|
42
|
+
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_a, alt: string, url: string) =>
|
|
43
|
+
`<span class="md-image"></span>`)
|
|
44
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_a, text: string, url: string) =>
|
|
45
|
+
`<span class="md-link">[${text}](</span><span class="md-url">${url}</span><span class="md-link">)</span>`)
|
|
46
|
+
|
|
47
|
+
// Bold spans are also protected (placeholders) so the later italic pass
|
|
48
|
+
// cannot chew into their `**` delimiters (which are also single `*`).
|
|
49
|
+
const boldTokens: string[] = []
|
|
50
|
+
s = s.replace(/(\*\*|__)([^*_]+?)\1/g, (_a, d: string, text: string) => {
|
|
51
|
+
boldTokens.push(`<span class="md-bold">${d}${text}${d}</span>`)
|
|
52
|
+
return `\u0001${boldTokens.length - 1}\u0001`
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
s = s.replace(/(\*|_)([^*_]+?)\1/g, '<span class="md-italic">$1$2$1</span>')
|
|
56
|
+
s = s.replace(/~~([^~]+?)~~/g, '<span class="md-strike">~~$1~~</span>')
|
|
57
|
+
|
|
58
|
+
return s
|
|
59
|
+
.replace(/\u0001(\d+)\u0001/g, (_a, i: string) => boldTokens[Number(i)] ?? '')
|
|
60
|
+
.replace(/\u0000(\d+)\u0000/g, (_a, i: string) => codeTokens[Number(i)] ?? '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Match and return groups as a plain string array ('' for absent groups), or
|
|
65
|
+
* null when the pattern does not match. Avoids noUncheckedIndexedAccess
|
|
66
|
+
* friction on RegExpMatchArray indexing.
|
|
67
|
+
*/
|
|
68
|
+
function groups(re: RegExp, value: string): string[] | null {
|
|
69
|
+
const m = value.match(re)
|
|
70
|
+
if (m === null) return null
|
|
71
|
+
return Array.from(m, (g) => g ?? '')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Highlight one non-fence line (leading whitespace preserved verbatim). */
|
|
75
|
+
function highlightLine(raw: string): string {
|
|
76
|
+
const leadMatch = groups(/^(\s*)(.*)$/, raw)
|
|
77
|
+
const lead = leadMatch === null ? '' : leadMatch[1]!
|
|
78
|
+
const body = leadMatch === null ? raw : leadMatch[2]!
|
|
79
|
+
if (body.length === 0) return raw
|
|
80
|
+
|
|
81
|
+
// ATX heading: ## 标题
|
|
82
|
+
const heading = groups(/^(#{1,6})(\s+)(.*)$/, body)
|
|
83
|
+
if (heading !== null) {
|
|
84
|
+
return `${lead}<span class="md-heading">${escapeHtml(heading[1]!)}${escapeHtml(heading[2]!)}</span>${highlightInline(escapeHtml(heading[3]!))}`
|
|
85
|
+
}
|
|
86
|
+
// Horizontal rule
|
|
87
|
+
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(body)) {
|
|
88
|
+
return `${lead}<span class="md-hr">${escapeHtml(body)}</span>`
|
|
89
|
+
}
|
|
90
|
+
// Blockquote: > 内容
|
|
91
|
+
const quote = groups(/^(>)( ?)(.*)$/, body)
|
|
92
|
+
if (quote !== null) {
|
|
93
|
+
return `${lead}<span class="md-quote">${escapeHtml(quote[1]!)}</span>${escapeHtml(quote[2]!)}${highlightInline(escapeHtml(quote[3]!))}`
|
|
94
|
+
}
|
|
95
|
+
// Task list item: - [x] 内容 / - [ ] 内容
|
|
96
|
+
const task = groups(/^([-*+])(\s+)(\[[ xX]\])(\s+)(.*)$/, body)
|
|
97
|
+
if (task !== null) {
|
|
98
|
+
const checked = /\[[xX]\]/.test(task[3]!)
|
|
99
|
+
return `${lead}<span class="md-bullet">${escapeHtml(task[1]!)}</span>${escapeHtml(task[2]!)}<span class="md-task${checked ? ' md-task-checked' : ''}">${escapeHtml(task[3]!)}</span>${escapeHtml(task[4]!)}${highlightInline(escapeHtml(task[5]!))}`
|
|
100
|
+
}
|
|
101
|
+
// Bullet list item
|
|
102
|
+
const bullet = groups(/^([-*+])(\s+)(.*)$/, body)
|
|
103
|
+
if (bullet !== null) {
|
|
104
|
+
return `${lead}<span class="md-bullet">${escapeHtml(bullet[1]!)}</span>${escapeHtml(bullet[2]!)}${highlightInline(escapeHtml(bullet[3]!))}`
|
|
105
|
+
}
|
|
106
|
+
// Ordered list item: 1. 内容
|
|
107
|
+
const ordered = groups(/^(\d+)([.)])(\s+)(.*)$/, body)
|
|
108
|
+
if (ordered !== null) {
|
|
109
|
+
return `${lead}<span class="md-number">${escapeHtml(ordered[1]!)}${escapeHtml(ordered[2]!)}</span>${escapeHtml(ordered[3]!)}${highlightInline(escapeHtml(ordered[4]!))}`
|
|
110
|
+
}
|
|
111
|
+
// Plain inline line
|
|
112
|
+
return `${lead}${highlightInline(escapeHtml(body))}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Highlight full Markdown source into safe `<span>`-wrapped HTML. Line count
|
|
117
|
+
* and per-line text (including whitespace) are preserved exactly.
|
|
118
|
+
*/
|
|
119
|
+
export function highlightMarkdown(src: string): string {
|
|
120
|
+
const lines = src.split('\n')
|
|
121
|
+
const out: string[] = []
|
|
122
|
+
let inFence = false
|
|
123
|
+
for (const rawLine of lines) {
|
|
124
|
+
const fence = rawLine.match(/^\s*(```|~~~)\s*([\w-]*)\s*$/)
|
|
125
|
+
if (fence !== null) {
|
|
126
|
+
inFence = !inFence
|
|
127
|
+
out.push(`<span class="md-fence">${escapeHtml(rawLine)}</span>`)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (inFence) {
|
|
131
|
+
out.push(`<span class="md-code-block">${escapeHtml(rawLine)}</span>`)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
out.push(highlightLine(rawLine))
|
|
135
|
+
}
|
|
136
|
+
return out.join('\n')
|
|
137
|
+
}
|