dsh-tiddlywiki 0.3.0 → 0.4.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.
@@ -95,9 +95,12 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
95
95
 
96
96
  /* ── floating quick-note widget ────────────────────────────────
97
97
  Positioned ABOVE the shutdown launcher FAB (fixed right:24 bottom:24,
98
- z-index 900, 46px) and BELOW its confirm overlay (z-index 1000). */
98
+ z-index 900, 46px) and BELOW its confirm overlay (z-index 1000).
99
+ Interactive overlays (the open card, the native-editor popup) sit ABOVE the
100
+ passive FAB column (sync/status, z-index 950) so those FABs never cover the
101
+ card's own bottom-right buttons. */
99
102
  .dsh-tw-note {
100
- position: fixed; right: 24px; bottom: 88px; z-index: 950;
103
+ position: fixed; right: 24px; bottom: 88px; z-index: 980;
101
104
  display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
102
105
  font-family: inherit;
103
106
  }
@@ -108,11 +111,18 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
108
111
  border-radius: 14px; box-shadow: var(--dsw-shadow-lv3, 0 8px 30px rgba(0,0,0,.22));
109
112
  padding: 12px; display: flex; flex-direction: column; gap: 10px;
110
113
  font-size: 13px;
114
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
115
+ animation: dsh-tw-note-in 180ms ease;
111
116
  }
112
117
  .dsh-tw-note-card[hidden] { display: none; }
118
+ @keyframes dsh-tw-note-in {
119
+ from { opacity: 0; transform: translateY(8px) scale(.98); }
120
+ to { opacity: 1; transform: translateY(0) scale(1); }
121
+ }
113
122
  .dsh-tw-note-head {
114
123
  display: flex; align-items: center; gap: 8px;
115
- font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary, #222);
124
+ font-size: 12px; font-weight: 600; letter-spacing: .2px;
125
+ color: var(--dsw-alias-label-primary, #222);
116
126
  }
117
127
  .dsh-tw-note-head .dsh-tw-note-label { margin-right: auto; display: flex; align-items: center; gap: 6px; }
118
128
  .dsh-tw-note-close {
@@ -120,75 +130,52 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
120
130
  font: inherit; font-size: 15px; line-height: 1; padding: 2px 6px; border-radius: 6px; cursor: pointer;
121
131
  }
122
132
  .dsh-tw-note-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, #222); }
123
- .dsh-tw-note-fields { display: flex; gap: 8px; }
133
+ /* 标题与标签上下分行:标题一行,标签 chips+输入框独占一行,
134
+ 避免标签增多时把并排的标题栏一起撑高。 */
135
+ .dsh-tw-note-fields { display: flex; flex-direction: column; gap: 8px; }
124
136
  .dsh-tw-note-fields input {
125
- flex: 1; min-width: 0; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
137
+ width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
126
138
  border-radius: 8px; padding: 6px 9px; font: inherit; font-size: 12px;
127
139
  background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, inherit);
128
140
  transition: border-color 120ms ease, box-shadow 120ms ease;
129
141
  }
130
- .dsh-tw-note-fields input:focus, .dsh-tw-note-text:focus {
142
+ .dsh-tw-note-fields input:focus, .dsh-tw-note-editor .cm-editor.cm-focused {
131
143
  outline: none; border-color: var(--dsw-alias-brand-primary, #3e63dd);
132
144
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
133
145
  }
134
- .dsh-tw-note-text::placeholder { color: var(--dsw-alias-label-dimmed, #999); }
135
- /* ── Markdown editor (highlighted-textarea overlay) ────────────────
136
- The <pre> highlight layer and the transparent <textarea> share identical
137
- metrics (border/padding/font/line-height/white-space) so lines align 1:1. */
146
+ /* ── Markdown editor (CodeMirror 6, see markdown-editor.ts) ──────────
147
+ The .cm-editor box carries the border/radius/min-height the old textarea
148
+ had; font metrics live on the scroller so lines/selection stay aligned. */
138
149
  .dsh-tw-note-editor { position: relative; min-width: 0; }
139
- .dsh-tw-note-editor pre,
140
- .dsh-tw-note-editor textarea {
141
- margin: 0; box-sizing: border-box; width: 100%;
150
+ .dsh-tw-note-editor .cm-editor {
142
151
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18)); border-radius: 8px;
143
- padding: 8px 9px; font: inherit; font-size: 13px; line-height: 1.5; min-height: 120px;
144
- white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; tab-size: 4;
152
+ background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #222);
153
+ font-size: 13px; min-height: 120px;
145
154
  transition: border-color 120ms ease, box-shadow 120ms ease;
146
155
  }
147
- .dsh-tw-note-editor .dsh-tw-note-hl {
148
- position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 0;
149
- background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #222);
150
- pointer-events: none; overflow: hidden;
156
+ .dsh-tw-note-editor .cm-scroller {
157
+ font-family: inherit; font-size: 13px; line-height: 1.5;
158
+ min-height: 120px; overflow: auto;
159
+ }
160
+ .dsh-tw-note-editor .cm-content {
161
+ caret-color: var(--dsw-alias-label-primary, #222);
162
+ padding: 8px 9px;
163
+ }
164
+ .dsh-tw-note-editor .cm-placeholder { color: var(--dsw-alias-label-dimmed, #999); }
165
+ .dsh-tw-note-editor .cm-line { padding: 0; }
166
+ /* Selection + active line follow the theme. */
167
+ .dsh-tw-note-editor .cm-editor .cm-selectionBackground,
168
+ .dsh-tw-note-editor .cm-editor.cm-focused .cm-selectionBackground {
169
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 22%, transparent) !important;
151
170
  }
152
- .dsh-tw-note-editor .dsh-tw-note-text {
153
- position: relative; z-index: 1; resize: vertical;
154
- background: transparent; color: transparent; caret-color: var(--dsw-alias-label-primary, #222);
155
- overflow: auto;
171
+ .dsh-tw-note-editor .cm-editor .cm-activeLine {
172
+ background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent);
156
173
  }
157
- .dsh-tw-note-editor .dsh-tw-note-text:focus { outline: none; }
158
- .dsh-tw-note-drop .dsh-tw-note-text,
159
- .dsh-tw-note-drop .dsh-tw-note-hl {
174
+ /* Drag-over highlight (file upload) targets the CodeMirror box now. */
175
+ .dsh-tw-note-drop .cm-editor {
160
176
  border-color: var(--dsw-alias-brand-primary, #3e63dd);
161
177
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
162
178
  }
163
- /* ── markdown highlight tokens (colors only — never size/height, so the
164
- overlay keeps line-for-line alignment with the invisible textarea) ── */
165
- .dsh-tw-note-hl .md-heading { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
166
- .dsh-tw-note-hl .md-hash { font-weight: 700; color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 55%, transparent); }
167
- .dsh-tw-note-hl .md-code,
168
- .dsh-tw-note-hl .md-code-block,
169
- .dsh-tw-note-hl .md-fence {
170
- font-family: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
171
- }
172
- .dsh-tw-note-hl .md-code {
173
- background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 14%, transparent);
174
- border-radius: 4px; padding: 0 3px;
175
- }
176
- .dsh-tw-note-hl .md-code-block,
177
- .dsh-tw-note-hl .md-fence {
178
- display: block; background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent);
179
- }
180
- .dsh-tw-note-hl .md-bold { font-weight: 700; }
181
- .dsh-tw-note-hl .md-italic { font-style: italic; }
182
- .dsh-tw-note-hl .md-strike { text-decoration: line-through; opacity: .75; }
183
- .dsh-tw-note-hl .md-link { color: var(--dsw-alias-brand-primary, #3e63dd); text-decoration: underline; }
184
- .dsh-tw-note-hl .md-url { color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 70%, #999); text-decoration: underline dotted; }
185
- .dsh-tw-note-hl .md-image { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
186
- .dsh-tw-note-hl .md-bullet { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
187
- .dsh-tw-note-hl .md-number { font-weight: 700; color: var(--dsw-alias-state-warning-primary, #d9822b); }
188
- .dsh-tw-note-hl .md-quote { font-style: italic; color: var(--dsw-alias-label-secondary, #888); }
189
- .dsh-tw-note-hl .md-hr { color: var(--dsw-alias-label-dimmed, #999); text-decoration: line-through; }
190
- .dsh-tw-note-hl .md-task { color: var(--dsw-alias-label-secondary, #888); }
191
- .dsh-tw-note-hl .md-task-checked { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
192
179
  .dsh-tw-note-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
193
180
  .dsh-tw-note-foot-left, .dsh-tw-note-foot-right { display: flex; align-items: center; gap: 8px; }
194
181
  .dsh-tw-note-hint { font-size: 11px; color: var(--dsw-alias-label-dimmed, #999); }
@@ -196,26 +183,29 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
196
183
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
197
184
  background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
198
185
  font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
199
- transition: filter 120ms ease;
186
+ transition: filter 120ms ease, transform 80ms ease;
200
187
  }
201
188
  .dsh-tw-note-upload:hover { filter: brightness(.96); }
189
+ .dsh-tw-note-upload:active { transform: scale(.96); }
202
190
  .dsh-tw-note-save {
203
191
  border: 1px solid transparent; background: var(--dsw-alias-brand-primary, #3e63dd); color: #fff;
204
192
  font: inherit; font-size: 12px; padding: 6px 16px; border-radius: 8px; cursor: pointer;
205
- transition: filter 120ms ease;
193
+ transition: filter 120ms ease, transform 80ms ease;
206
194
  }
207
195
  .dsh-tw-note-save:hover:not(:disabled) { filter: brightness(1.08); }
196
+ .dsh-tw-note-save:active:not(:disabled) { transform: scale(.96); }
208
197
  .dsh-tw-note-save:disabled { opacity: .55; cursor: default; }
209
198
  .dsh-tw-note-edit {
210
199
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
211
200
  background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
212
201
  font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
213
- transition: filter 120ms ease;
202
+ transition: filter 120ms ease, transform 80ms ease;
214
203
  }
215
204
  .dsh-tw-note-edit:hover:not(:disabled) { filter: brightness(.96); }
205
+ .dsh-tw-note-edit:active:not(:disabled) { transform: scale(.96); }
216
206
  .dsh-tw-note-edit:disabled { opacity: .55; cursor: default; }
217
207
  /* ── Multi-tag chip editor (quick-note tags) ─────────────────────────────── */
218
- .dsh-tw-note-tags { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
208
+ .dsh-tw-note-tags { position: relative; width: 100%; display: flex; flex-direction: column; gap: 4px; }
219
209
  .dsh-tw-note-chips { display: flex; flex-wrap: wrap; gap: 4px; }
220
210
  .dsh-tw-note-tagchip {
221
211
  display: inline-flex; align-items: center; gap: 4px;
@@ -250,7 +240,7 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
250
240
  background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
251
241
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.2));
252
242
  border-radius: 12px; box-shadow: 0 14px 44px rgba(0,0,0,.3);
253
- z-index: 980; font-family: inherit; font-size: 13px;
243
+ z-index: 990; font-family: inherit; font-size: 13px;
254
244
  }
255
245
  .dsh-tw-editor-bar {
256
246
  display: flex; align-items: center; gap: 8px; flex: 0 0 auto;
@@ -33,6 +33,11 @@ const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024
33
33
  /** Max uploaded file body. */
34
34
  const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
35
35
 
36
+ /** Tiddler type for quick-notes: Markdown, so the uploaded images/links and
37
+ * any Markdown in the note actually render in TW (a type-less tiddler is
38
+ * treated as plain wiki text and shows raw `![..]`/`[..]` instead). */
39
+ const NOTE_TYPE = 'text/markdown'
40
+
36
41
  /** Structural webserver face (a subset of dsh-host-webserver). */
37
42
  export interface WebServerFace {
38
43
  register(route: { kind: 'exact' | 'prefix'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }): () => void
@@ -125,10 +130,13 @@ function timestampTitle(date = new Date()): string {
125
130
 
126
131
  /**
127
132
  * Open a tiddler in TW's NATIVE editor: save the tiddler (when text is
128
- * non-empty), reuse or create a DRAFT tiddler carrying `draft.of`/`draft.title`
129
- * (TW's story view renders drafts with the EditTemplate — list.js:
130
- * `isDraft && editTemplate`), and return the draft title so the client can
131
- * navigate the panel iframe to `#<draftTitle>`.
133
+ * non-empty) as Markdown, reuse or create a DRAFT tiddler carrying
134
+ * `draft.of`/`draft.title` (TW's story view renders drafts with the
135
+ * EditTemplate — list.js: `isDraft && editTemplate`), and return the draft
136
+ * title so the client can navigate the panel iframe to `#<draftTitle>`.
137
+ * The draft carries the same `text/markdown` type as the note so saving it in
138
+ * TW keeps Markdown (a draft without a matching type would overwrite the
139
+ * note's type back to plain wiki text).
132
140
  */
133
141
  export async function openInTwEditor(
134
142
  client: TiddlyWebClient,
@@ -137,7 +145,7 @@ export async function openInTwEditor(
137
145
  tags: string[],
138
146
  ): Promise<{ title: string; draftTitle: string }> {
139
147
  if (text.trim().length > 0) {
140
- await client.put({ title, text, tags })
148
+ await client.put({ title, text, tags, type: NOTE_TYPE })
141
149
  }
142
150
  // Draft content: the provided text, else the existing tiddler's content.
143
151
  let draftText = text
@@ -159,7 +167,7 @@ export async function openInTwEditor(
159
167
  /* fall back to a fresh draft */
160
168
  }
161
169
  if (draftTitle === undefined) draftTitle = `Draft of "${title}" ${Date.now()}`
162
- await client.put({ title: draftTitle, text: draftText, 'draft.of': title, 'draft.title': title, type: 'text/vnd.tiddlywiki' })
170
+ await client.put({ title: draftTitle, text: draftText, 'draft.of': title, 'draft.title': title, type: NOTE_TYPE })
163
171
  return { title, draftTitle }
164
172
  }
165
173
 
@@ -206,9 +214,9 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
206
214
  }
207
215
  const title = typeof body.title === 'string' && body.title.trim().length > 0 ? body.title.trim() : timestampTitle()
208
216
  const tags = resolveTags(body, deps.noteDefaults().tag)
209
- await client.put({ title, text, tags })
217
+ await client.put({ title, text, tags, type: NOTE_TYPE })
210
218
  deps.autoCommit()
211
- json(res, { ok: true, title, tag: tags.join(' '), tags, text })
219
+ json(res, { ok: true, title, tag: tags.join(' '), tags, text, type: NOTE_TYPE })
212
220
  } catch (err) {
213
221
  json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
214
222
  }
package/src/index.ts CHANGED
@@ -152,7 +152,9 @@ const PROMPT_TEXT = `## TiddlyWiki 持久知识库
152
152
  2. 收工 commit + push:\`tiddlywiki_git_sync action=sync\`(pull → commit → push)。
153
153
  3. 插件会自动防抖 commit(默认 60s),手动同步用上面的工具。
154
154
 
155
- 把 wiki 当作长期记忆与知识沉淀的地方:会议纪要、决策记录、调研笔记、随手的想法都可存成独立 tiddler(tag 建议用 inbox/meeting/decision 等便于检索)。`
155
+ 把 wiki 当作长期记忆与知识沉淀的地方:会议纪要、决策记录、调研笔记、随手的想法都可存成独立 tiddler(tag 建议用 inbox/meeting/decision 等便于检索)。
156
+
157
+ 用本插件自动创建笔记时,除了业务性 tag 外,请把「当前工作区(项目)的名字」也作为标签之一加上去(例如 \`tiddlywiki_put\` 的 tags 里带上当前 workspace 名),这样笔记能按项目归集、检索。`
156
158
 
157
159
  /**
158
160
  * Mount the host half.
@@ -1,137 +0,0 @@
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, '&amp;')
24
- .replace(/</g, '&lt;')
25
- .replace(/>/g, '&gt;')
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">![${alt}](</span><span class="md-url">${url}</span><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
- }