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.
- package/README.md +258 -212
- package/lib/client.bundle.js +27490 -2173
- package/lib/client.js +27500 -2183
- package/lib/index.js +2436 -2424
- package/lib/index.js.map +1 -1
- package/package.json +85 -79
- package/src/client/markdown-editor.ts +123 -0
- package/src/client/note-widget.ts +36 -76
- package/src/client/styles.ts +50 -60
- package/src/host/routes.ts +16 -8
- package/src/index.ts +3 -1
- package/src/client/markdown.ts +0 -137
package/src/client/styles.ts
CHANGED
|
@@ -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:
|
|
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;
|
|
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
|
-
|
|
133
|
+
/* 标题与标签上下分行:标题一行,标签 chips+输入框独占一行,
|
|
134
|
+
避免标签增多时把并排的标题栏一起撑高。 */
|
|
135
|
+
.dsh-tw-note-fields { display: flex; flex-direction: column; gap: 8px; }
|
|
124
136
|
.dsh-tw-note-fields input {
|
|
125
|
-
|
|
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-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
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 .
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 .
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
.dsh-tw-note-drop .
|
|
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;
|
|
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:
|
|
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;
|
package/src/host/routes.ts
CHANGED
|
@@ -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
|
|
129
|
-
* (TW's story view renders drafts with the
|
|
130
|
-
* `isDraft && editTemplate`), and return the draft
|
|
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:
|
|
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.
|
package/src/client/markdown.ts
DELETED
|
@@ -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, '&')
|
|
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
|
-
}
|