artalk 2.1.6 → 2.1.9

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/index.html CHANGED
@@ -31,7 +31,7 @@
31
31
  </style>
32
32
  </head>
33
33
  <body>
34
- <div class="card billboard artalk-fade-in">
34
+ <div class="card billboard atk-fade-in">
35
35
  <div class="title"><a href="https://github.com/ArtalkJS/Artalk" target="_blank"><span>『 </span>Artalk<span> 』</span></a></div>
36
36
  <div class="desc">一款简洁的自托管评论系统 <span id="ArtalkPV"></span></div>
37
37
  <div class="social">
@@ -42,11 +42,11 @@
42
42
  <a href="https://github.com/ArtalkJS/Artalk" target="_blank"><img src="https://img.shields.io/github/stars/ArtalkJS/Artalk?style=flat-square"></a>
43
43
  </div>
44
44
  </div>
45
- <div class="card artalk-fade-in">
45
+ <div class="card atk-fade-in">
46
46
  <div id="comments"></div>
47
47
  </div>
48
- <div class="float-btn to-night artalk-fade-in">Blink</div>
49
- <div class="float-btn to-top artalk-fade-in" style="display: none;">TOP</div>
48
+ <div class="float-btn to-night atk-fade-in">Blink</div>
49
+ <div class="float-btn to-top atk-fade-in" style="display: none;">TOP</div>
50
50
 
51
51
  <script type="module">
52
52
  import Artalk from './src/main'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artalk",
3
- "version": "2.1.6",
3
+ "version": "2.1.9",
4
4
  "license": "GPL-3.0",
5
5
  "description": "A Selfhosted Comment System",
6
6
  "keywords": [
@@ -22,16 +22,6 @@
22
22
  "jsdelivr": "./dist/Artalk.iife.js",
23
23
  "module": "./dist/Artalk.es.js",
24
24
  "types": "./types/index.d.ts",
25
- "scripts": {
26
- "dev": "vite",
27
- "build": "yarn lint && vite build",
28
- "build:demo": "vite build --config vite-demo.config.ts",
29
- "serve": "vite preview",
30
- "deploy": "gh-pages -d deploy",
31
- "predeploy": "yarn run build && yarn run build:demo && yarn run copy:deploy",
32
- "copy:deploy": "cp -r dist deploy && cp CNAME deploy",
33
- "lint": "eslint --ext .js,.ts src"
34
- },
35
25
  "devDependencies": {
36
26
  "@types/marked": "^4.0.3"
37
27
  },
@@ -39,5 +29,15 @@
39
29
  "hanabi": "^0.4.0",
40
30
  "insane": "^2.6.2",
41
31
  "marked": "^4.0.14"
32
+ },
33
+ "scripts": {
34
+ "dev": "vite",
35
+ "build": "pnpm lint && vite build",
36
+ "build:demo": "vite build --config vite-demo.config.ts",
37
+ "serve": "vite preview",
38
+ "deploy": "gh-pages -d deploy",
39
+ "predeploy": "pnpm build && pnpm build:demo && pnpm copy:deploy",
40
+ "copy:deploy": "cp -r dist deploy && cp CNAME deploy",
41
+ "lint": "eslint --ext .js,.ts src"
42
42
  }
43
- }
43
+ }
package/src/api/index.ts CHANGED
@@ -264,6 +264,29 @@ export default class Api {
264
264
  return p.pv as number
265
265
  }
266
266
 
267
+ /** 图片上传 */
268
+ public async imgUpload(file: File) {
269
+ const params: any = {
270
+ name: this.ctx.user.data.nick,
271
+ email: this.ctx.user.data.email,
272
+ page_key: this.ctx.conf.pageKey,
273
+ }
274
+
275
+ if (this.ctx.conf.site) params.site_name = this.ctx.conf.site
276
+
277
+ const form = ToFormData(params)
278
+ form.set('file', file)
279
+
280
+ const init: RequestInit = {
281
+ method: 'POST',
282
+ body: form
283
+ }
284
+
285
+ const json = await Fetch(this.ctx, `${this.baseURL}/img-upload`, init)
286
+ return ((json.data || {}) as any) as { img_file: string, img_url: string }
287
+
288
+ }
289
+
267
290
  // ============================
268
291
  // 验证码
269
292
  // ============================
package/src/artalk.ts CHANGED
@@ -12,6 +12,7 @@ import SidebarLayer from './components/sidebar-layer'
12
12
  import { GetLayerWrap } from './components/layer'
13
13
  import { EventPayloadMap, Handler } from '~/types/event'
14
14
  import Api from './api'
15
+ import * as Utils from './lib/utils'
15
16
 
16
17
  /**
17
18
  * Artalk
@@ -59,6 +60,7 @@ export default class Artalk {
59
60
 
60
61
  // 组件初始化
61
62
  this.checkerLauncher = new CheckerLauncher(this.ctx)
63
+ Utils.initMarked(this.ctx) // 初始化 marked
62
64
 
63
65
  // 编辑器
64
66
  this.editor = new Editor(this.ctx)
@@ -80,6 +82,13 @@ export default class Artalk {
80
82
 
81
83
  // 其他
82
84
  this.initPV()
85
+
86
+ // 插件初始化
87
+ Artalk.Plugins.forEach(plugin => {
88
+ if (typeof plugin === 'function') {
89
+ plugin(this.ctx)
90
+ }
91
+ })
83
92
  }
84
93
 
85
94
  /** 事件绑定 · 初始化 */
@@ -148,6 +157,7 @@ export default class Artalk {
148
157
  /** 暗黑模式 · 设定 */
149
158
  public setDarkMode(darkMode: boolean) {
150
159
  this.ctx.conf.darkMode = darkMode
160
+ this.ctx.trigger('conf-updated')
151
161
  this.initDarkMode()
152
162
  }
153
163
 
@@ -179,4 +189,12 @@ export default class Artalk {
179
189
  public trigger<K extends keyof EventPayloadMap>(name: K, payload?: EventPayloadMap[K]) {
180
190
  this.ctx.trigger(name, payload, 'external')
181
191
  }
192
+
193
+ /** Plugins */
194
+ protected static Plugins: ((ctx: Context) => void)[] = []
195
+
196
+ /** Enable Plugin */
197
+ public static Use(plugin: ((ctx: Context) => void)) {
198
+ this.Plugins.push(plugin)
199
+ }
182
200
  }
@@ -136,6 +136,11 @@ export default class Comment extends Component {
136
136
  $badge.remove()
137
137
  }
138
138
 
139
+ if (this.data.is_pinned) {
140
+ const $pinnedBadge = Utils.createElement(`<span class="atk-item atk-pinned-badge">置顶</span>`) // 置顶徽章
141
+ $nick.insertAdjacentElement('afterend', $pinnedBadge)
142
+ }
143
+
139
144
  const $date = this.$el.querySelector<HTMLElement>('.atk-date')!
140
145
  $date.innerText = this.getDateFormatted()
141
146
  $date.setAttribute('data-atk-comment-date', String(+new Date(this.data.date)))
@@ -272,6 +277,16 @@ export default class Comment extends Component {
272
277
  delBtn.setClick(() => {
273
278
  this.adminDelete(delBtn)
274
279
  })
280
+
281
+ // 绑定置顶按钮事件
282
+ const pinnedBtn = new ActionBtn({
283
+ text: () => (this.data.is_pinned ? '取消置顶' : '置顶'),
284
+ adminOnly: true
285
+ })
286
+ pinnedBtn.appendTo(this.$actions)
287
+ pinnedBtn.setClick(() => {
288
+ this.adminEdit('pinned', pendingBtn)
289
+ })
275
290
  }
276
291
  //#endregion
277
292
 
@@ -404,9 +419,9 @@ export default class Comment extends Component {
404
419
  })
405
420
  }
406
421
 
407
- /** 管理员 - 评论折叠 */
408
- adminEdit(type: 'collapsed'|'pending', btnElem: ActionBtn) {
409
- if (btnElem.isLoading) return // 若正在折叠中
422
+ /** 管理员 - 评论状态修改 */
423
+ adminEdit(type: 'collapsed'|'pending'|'pinned', btnElem: ActionBtn) {
424
+ if (btnElem.isLoading) return // 若正在修改中
410
425
 
411
426
  btnElem.setLoading(true, '修改中...')
412
427
 
@@ -416,6 +431,8 @@ export default class Comment extends Component {
416
431
  modify.is_collapsed = !modify.is_collapsed
417
432
  } else if (type === 'pending') {
418
433
  modify.is_pending = !modify.is_pending
434
+ } else if (type === 'pinned') {
435
+ modify.is_pinned = !modify.is_pinned
419
436
  }
420
437
 
421
438
  new Api(this.ctx).commentEdit(modify).then((comment) => {
@@ -21,6 +21,7 @@ export default class Editor extends Component {
21
21
  public $plugWrap: HTMLElement
22
22
  public $bottom: HTMLElement
23
23
  public $plugBtnWrap: HTMLElement
24
+ public $imgUploadBtn?: HTMLElement
24
25
  public $submitBtn: HTMLButtonElement
25
26
  public $notifyWrap: HTMLElement
26
27
 
@@ -63,6 +64,7 @@ export default class Editor extends Component {
63
64
  this.ctx.on('editor-notify', (f) => (this.showNotify(f.msg, f.type)))
64
65
  this.ctx.on('editor-travel', ($el) => (this.travel($el)))
65
66
  this.ctx.on('editor-travel-back', () => (this.travelBack()))
67
+ this.ctx.on('conf-updated', () => (this.refreshUploadBtn()))
66
68
  }
67
69
 
68
70
  initLocalStorage () {
@@ -200,7 +202,7 @@ export default class Editor extends Component {
200
202
  // 依次实例化 plug
201
203
  this.LOADABLE_PLUG_LIST.forEach((PlugObj) => {
202
204
  // 切换按钮
203
- const btnElem = Utils.createElement(`<span class="atk-plug-btn">${PlugObj.BtnHTML}</span>`)
205
+ const btnElem = Utils.createElement(`<span class="atk-plug-btn" data-plug-name="${PlugObj.Name}">${PlugObj.BtnHTML}</span>`)
204
206
  this.$plugBtnWrap.appendChild(btnElem)
205
207
  btnElem.addEventListener('click', () => {
206
208
  let plug = this.plugList[PlugObj.Name]
@@ -244,6 +246,8 @@ export default class Editor extends Component {
244
246
  btnElem.classList.add('active')
245
247
  })
246
248
  })
249
+
250
+ this.initImgUpload()
247
251
  }
248
252
 
249
253
  /** 关闭编辑器插件 */
@@ -253,6 +257,116 @@ export default class Editor extends Component {
253
257
  this.openedPlugName = null
254
258
  }
255
259
 
260
+ /** 允许的图片格式 */
261
+ allowImgExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp']
262
+
263
+ /** 初始化图片上传功能 */
264
+ initImgUpload() {
265
+ this.$imgUploadBtn = Utils.createElement(`<span class="atk-plug-btn">图片</span>`)
266
+ this.$plugBtnWrap.querySelector('[data-plug-name="preview"]')!.before(this.$imgUploadBtn) // 显示在预览图标之前
267
+
268
+ // 按钮点击
269
+ this.$imgUploadBtn.onclick = () => {
270
+ // 选择图片
271
+ const $input = document.createElement('input')
272
+ $input.type = 'file'
273
+ $input.accept = this.allowImgExts.map(o => `.${o}`).join(',')
274
+ $input.onchange = () => {
275
+ (async () => { // 解决阻塞 UI 问题
276
+ if (!$input.files || $input.files.length === 0) return
277
+ const file = $input.files[0]
278
+ this.uploadImg(file)
279
+ })()
280
+ }
281
+ $input.click() // 显示选择图片对话框
282
+ }
283
+
284
+ // 统一从 FileList 获取文件并上传图片方法
285
+ const uploadFromFileList = (files?: FileList) => {
286
+ if (!files) return
287
+
288
+ for (let i = 0; i < files.length; i++) {
289
+ const file = files[i]
290
+ this.uploadImg(file)
291
+ }
292
+ }
293
+
294
+ // 拖拽图片
295
+ // @link https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API/File_drag_and_drop
296
+ // 阻止浏览器的默认释放行为
297
+ this.$textarea.addEventListener('dragover', (evt) => {
298
+ evt.stopPropagation()
299
+ evt.preventDefault()
300
+ })
301
+
302
+ this.$textarea.addEventListener('drop', (evt) => {
303
+ const files = evt.dataTransfer?.files
304
+ if (files?.length) {
305
+ evt.preventDefault()
306
+ uploadFromFileList(files)
307
+ }
308
+ })
309
+
310
+ // 粘贴图片
311
+ this.$textarea.addEventListener('paste', (evt) => {
312
+ const files = evt.clipboardData?.files
313
+ if (files?.length) {
314
+ evt.preventDefault()
315
+ uploadFromFileList(files)
316
+ }
317
+ })
318
+ }
319
+
320
+ /** 刷新图片上传按钮 */
321
+ refreshUploadBtn() {
322
+ if (!this.$imgUploadBtn) return
323
+
324
+ if (!this.ctx.conf.imgUpload) {
325
+ this.$imgUploadBtn.setAttribute('atk-only-admin-show', '')
326
+ this.ctx.trigger('check-admin-show-el')
327
+ }
328
+ }
329
+
330
+ async uploadImg(file: File) {
331
+ const fileExt = /[^.]+$/.exec(file.name)
332
+ if (!fileExt || !this.allowImgExts.includes(fileExt[0])) return
333
+
334
+ // 未登录提示
335
+ if (!this.ctx.user.checkHasBasicUserInfo()) {
336
+ this.showNotify('填入你的名字邮箱才能上传哦', 'w')
337
+ return
338
+ }
339
+
340
+ // 插入图片前换一行
341
+ let insertPrefix = '\n'
342
+ if (this.$textarea.value.trim() === '') insertPrefix = ''
343
+
344
+ // 插入占位加载文字
345
+ const uploadPlaceholderTxt = `${insertPrefix}![](Uploading ${file.name}...)`
346
+ this.insertContent(uploadPlaceholderTxt)
347
+
348
+ // 上传图片
349
+ let resp: any
350
+ try {
351
+ resp = await new Api(this.ctx).imgUpload(file)
352
+ } catch (err: any) {
353
+ console.error(err)
354
+ this.showNotify(`图片上传失败,${err.msg}`, 'e')
355
+ }
356
+ if (!!resp && resp.img_url) {
357
+ let imgURL = resp.img_url as string
358
+
359
+ // 若为相对路径,加上 artalk server
360
+ if (!Utils.isValidURL(imgURL)) imgURL = Utils.getURLBasedOnApi(this.ctx, imgURL)
361
+
362
+ // 上传成功插入图片
363
+ this.setContent(this.$textarea.value.replace(uploadPlaceholderTxt, `${insertPrefix}![](${imgURL})`))
364
+ } else {
365
+ // 上传失败删除加载文字
366
+ this.setContent(this.$textarea.value.replace(uploadPlaceholderTxt, ''))
367
+ }
368
+ }
369
+
256
370
  insertContent (val: string) {
257
371
  if ((document as any).selection) {
258
372
  this.$textarea.focus();
@@ -287,7 +401,8 @@ export default class Editor extends Component {
287
401
  this.cancelReply()
288
402
  }
289
403
 
290
- getContent () {
404
+ /** 获取最终用于 submit 的数据 */
405
+ getFinalContent () {
291
406
  let content = this.getContentOriginal()
292
407
 
293
408
  // 表情包处理
@@ -304,7 +419,7 @@ export default class Editor extends Component {
304
419
  }
305
420
 
306
421
  getContentMarked () {
307
- return Utils.marked(this.ctx, this.getContent())
422
+ return Utils.marked(this.ctx, this.getFinalContent())
308
423
  }
309
424
 
310
425
  initBottomPart () {
@@ -363,7 +478,7 @@ export default class Editor extends Component {
363
478
  }
364
479
 
365
480
  async submit () {
366
- if (this.getContent().trim() === '') {
481
+ if (this.getFinalContent().trim() === '') {
367
482
  this.$textarea.focus()
368
483
  return
369
484
  }
@@ -374,7 +489,7 @@ export default class Editor extends Component {
374
489
 
375
490
  try {
376
491
  const nComment = await new Api(this.ctx).add({
377
- content: this.getContent(),
492
+ content: this.getFinalContent(),
378
493
  nick: this.user.data.nick,
379
494
  email: this.user.data.email,
380
495
  link: this.user.data.link,
@@ -1,8 +1,10 @@
1
1
  <div class="atk-list">
2
2
  <div class="atk-list-header">
3
3
  <div class="atk-comment-count">
4
- <span class="atk-comment-count-num">0</span>
5
- 条评论
4
+ <div class="atk-text">
5
+ <span class="atk-comment-count-num">0</span>
6
+ 条评论
7
+ </div>
6
8
  </div>
7
9
  <div class="atk-right-action">
8
10
  <span data-action="admin-close-comment" class="atk-hide" atk-only-admin-show>关闭评论</span>
@@ -119,6 +119,11 @@ export default class ListLite extends Component {
119
119
  // 版本检测
120
120
  if (this.ctx.conf.versionCheck && this.versionCheck(data.api_version)) return
121
121
 
122
+ // 图片上传功能
123
+ if (data.conf && typeof data.conf.img_upload === "boolean") {
124
+ this.ctx.conf.imgUpload = data.conf.img_upload
125
+ }
126
+
122
127
  // 导入数据
123
128
  this.importComments(data.comments)
124
129
 
@@ -130,6 +135,7 @@ export default class ListLite extends Component {
130
135
 
131
136
  this.ctx.trigger('unread-update', { notifies: data.unread || [] })
132
137
  this.ctx.trigger('comments-loaded')
138
+ this.ctx.trigger('conf-updated')
133
139
 
134
140
  if (this.onAfterLoad) this.onAfterLoad(data)
135
141
  }
@@ -481,6 +487,7 @@ export default class ListLite extends Component {
481
487
  ignoreBtn.onclick = () => {
482
488
  Ui.setError(this.ctx, null)
483
489
  this.ctx.conf.versionCheck = false
490
+ this.ctx.trigger('conf-updated')
484
491
  this.fetchComments(0)
485
492
  }
486
493
  errEl.append(ignoreBtn)
@@ -12,6 +12,8 @@ export default class List extends ListLite {
12
12
  private $closeCommentBtn!: HTMLElement
13
13
  private $openSidebarBtn!: HTMLElement
14
14
  private $unreadBadge!: HTMLElement
15
+ private $commentCount!: HTMLElement
16
+ private $dropdownWrap?: HTMLElement
15
17
 
16
18
  constructor (ctx: Context) {
17
19
  const el = Utils.createElement(ListHTML)
@@ -40,6 +42,13 @@ export default class List extends ListLite {
40
42
  // 操作按钮
41
43
  this.initListActionBtn()
42
44
 
45
+ this.$commentCount = this.$el.querySelector('.atk-comment-count')!
46
+
47
+ // 评论列表排序 Dropdown 下拉选择层
48
+ if (this.ctx.conf.listSort) {
49
+ this.initDropdown()
50
+ }
51
+
43
52
  // copyright
44
53
  this.$el.querySelector<HTMLElement>('.atk-copyright')!.innerHTML = `Powered By <a href="https://artalk.js.org" target="_blank" title="Artalk v${ARTALK_VERSION}">Artalk</a>`
45
54
 
@@ -171,4 +180,58 @@ export default class List extends ListLite {
171
180
  this.$unreadBadge.style.display = 'none'
172
181
  }
173
182
  }
183
+
184
+ /** 初始化选择下拉层 */
185
+ protected initDropdown() {
186
+ this.$dropdownWrap = this.$commentCount
187
+ this.$commentCount.classList.add('atk-dropdown-wrap')
188
+
189
+ // 插入图标
190
+ this.$dropdownWrap.append(Utils.createElement(`<span class="atk-arrow-down-icon"></span>`))
191
+
192
+ const reloadUseParamsEditor = (func: (p: any) => void) => {
193
+ this.paramsEditor = (p) => { func(p) }
194
+ this.fetchComments(0)
195
+ }
196
+
197
+ // 下拉列表
198
+ const dropdownList = [
199
+ ['最新', () => { reloadUseParamsEditor(p => { p.sort_by = 'date_desc' }) }],
200
+ ['最热', () => { reloadUseParamsEditor(p => { p.sort_by = 'vote' }) }],
201
+ ['最早', () => { reloadUseParamsEditor(p => { p.sort_by = 'date_asc' }) }],
202
+ ['作者', () => { reloadUseParamsEditor(p => { p.view_only_admin = true }) }],
203
+ ]
204
+
205
+ // 列表项点击事件
206
+ let curtActive = 0 // 当前选中
207
+ const onItemClick = (i: number, $item: HTMLElement, name: string, action: Function) => {
208
+ action()
209
+
210
+ // set active
211
+ curtActive = i
212
+ $dropdown.querySelectorAll('.active').forEach((e) => { e.classList.remove('active') })
213
+ $item.classList.add('active')
214
+
215
+ // 关闭层 (临时消失,取消 :hover)
216
+ $dropdown.style.display = 'none'
217
+ setTimeout(() => { $dropdown.style.display = '' }, 80)
218
+ }
219
+
220
+ // 生成列表元素
221
+ const $dropdown = Utils.createElement(`<ul class="atk-dropdown atk-fade-in"></ul>`)
222
+ dropdownList.forEach((item, i) => {
223
+ const name = item[0] as string
224
+ const action = item[1] as Function
225
+
226
+ const $item = Utils.createElement(`<li class="atk-dropdown-item"><span></span></li>`)
227
+ const $link = $item.querySelector<HTMLElement>('span')!
228
+ $link.innerText = name
229
+ $link.onclick = () => { onItemClick(i, $item, name, action) }
230
+ $dropdown.append($item)
231
+
232
+ if (i === curtActive) $item.classList.add('active') // 默认选中项
233
+ })
234
+
235
+ this.$dropdownWrap.append($dropdown)
236
+ }
174
237
  }
@@ -64,14 +64,15 @@ export default class SidebarLayer extends Component {
64
64
  this.$iframe = Utils.createElement<HTMLIFrameElement>('<iframe></iframe>')
65
65
 
66
66
  const baseURL = (import.meta.env.MODE === 'development') ? 'http://localhost:23367/'
67
- : `${this.conf.server.replace(/\/$/, '')}/../sidebar/`
67
+ : Utils.getURLBasedOnApi(this.ctx, 'sidebar/')
68
68
  const userData = encodeURIComponent(JSON.stringify(this.ctx.user.data))
69
69
 
70
- // 测试是否能访问
70
+ const location = window.location
71
71
  this.iframeLoad(`${baseURL}`
72
72
  + `?pageKey=${encodeURIComponent(this.conf.pageKey)}`
73
73
  + `&site=${encodeURIComponent(this.conf.site || '')}`
74
74
  + `&user=${userData}`
75
+ + `&referer=${encodeURIComponent(`${location.protocol}//${location.host}${location.pathname}`)}`
75
76
  + `${((this.conf.darkMode) ? `&darkMode=1` : ``)}`)
76
77
 
77
78
  this.$iframeWrap.append(this.$iframe)
package/src/context.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { marked as libMarked } from 'marked'
1
2
  import User from './lib/user'
2
3
  import ArtalkConfig from '~/types/artalk-config'
3
4
  import { EventPayloadMap, Event, EventScopeType, Handler } from '~/types/event'
@@ -39,4 +40,6 @@ export default class Context {
39
40
  .map((evt) => evt.handler)
40
41
  .forEach((handler) => handler(payload as any))
41
42
  }
43
+
44
+ public markedInstance!: typeof libMarked
42
45
  }
package/src/defaults.ts CHANGED
@@ -17,6 +17,7 @@ const defaults: ArtalkConfig = {
17
17
  vote: true,
18
18
  voteDown: false,
19
19
  uaBadge: true,
20
+ listSort: true,
20
21
  pvEl: '#ArtalkPV',
21
22
  flatMode: 'auto',
22
23
  maxNesting: 3,
@@ -36,6 +37,7 @@ const defaults: ArtalkConfig = {
36
37
  children: 400,
37
38
  },
38
39
 
40
+ imgUpload: true,
39
41
  reqTimeout: 15000,
40
42
  versionCheck: true,
41
43
  }
package/src/lib/utils.ts CHANGED
@@ -140,43 +140,44 @@ export function versionCompare(a: string, b: string) {
140
140
  return 0
141
141
  }
142
142
 
143
- let markedInstance: typeof libMarked
144
- export function marked(ctx: Context, src: string): string {
145
- if (!markedInstance) {
146
- const renderer = new libMarked.Renderer()
147
- const linkRenderer = renderer.link
148
- renderer.link = (href, title, text) => {
149
- const localLink = href?.startsWith(`${window.location.protocol}//${window.location.hostname}`);
150
- const html = linkRenderer.call(renderer as any, href, title, text);
151
- return html.replace(/^<a /, `<a target="_blank" ${!localLink ? `rel="noreferrer noopener nofollow"` : ''} `);
152
- }
153
-
154
- // @see https://github.com/markedjs/marked/blob/4afb228d956a415624c4e5554bb8f25d047676fe/src/Tokenizer.js#L329
155
- const nMarked = libMarked
156
- libMarked.setOptions({
157
- renderer,
158
- highlight: (code) => hanabi(code),
159
- pedantic: false,
160
- gfm: true,
161
- breaks: true,
162
- smartLists: true,
163
- smartypants: true,
164
- xhtml: false,
165
- sanitize: true,
166
- sanitizer: (html) => insane(html, {
167
- ...insane.defaults,
168
- allowedAttributes: {
169
- ...insane.defaults.allowedAttributes,
170
- img: ['src', 'atk-emoticon']
171
- },
172
- }),
173
- silent: true,
174
- })
175
-
176
- markedInstance = nMarked
143
+ /** 初始化 marked */
144
+ export function initMarked(ctx: Context) {
145
+ const renderer = new libMarked.Renderer()
146
+ const linkRenderer = renderer.link
147
+ renderer.link = (href, title, text) => {
148
+ const localLink = href?.startsWith(`${window.location.protocol}//${window.location.hostname}`);
149
+ const html = linkRenderer.call(renderer as any, href, title, text);
150
+ return html.replace(/^<a /, `<a target="_blank" ${!localLink ? `rel="noreferrer noopener nofollow"` : ''} `);
177
151
  }
178
152
 
179
- return markedInstance.parse(src)
153
+ // @see https://github.com/markedjs/marked/blob/4afb228d956a415624c4e5554bb8f25d047676fe/src/Tokenizer.js#L329
154
+ const nMarked = libMarked
155
+ libMarked.setOptions({
156
+ renderer,
157
+ highlight: (code) => hanabi(code),
158
+ pedantic: false,
159
+ gfm: true,
160
+ breaks: true,
161
+ smartLists: true,
162
+ smartypants: true,
163
+ xhtml: false,
164
+ sanitize: true,
165
+ sanitizer: (html) => insane(html, {
166
+ ...insane.defaults,
167
+ allowedAttributes: {
168
+ ...insane.defaults.allowedAttributes,
169
+ img: ['src', 'atk-emoticon']
170
+ },
171
+ }),
172
+ silent: true,
173
+ })
174
+
175
+ ctx.markedInstance = nMarked
176
+ }
177
+
178
+ /** 解析 markdown */
179
+ export function marked(ctx: Context, src: string): string {
180
+ return ctx.markedInstance.parse(src)
180
181
  }
181
182
 
182
183
  /** 获取修正后的 UserAgent */
@@ -203,3 +204,23 @@ export async function getCorrectUserAgent() {
203
204
 
204
205
  return uaRaw
205
206
  }
207
+
208
+ /** 是否为完整的 URL */
209
+ export function isValidURL(urlRaw: string) {
210
+ // @link https://www.rfc-editor.org/rfc/rfc3986
211
+ let url: URL
212
+ try {
213
+ url = new URL(urlRaw)
214
+ } catch (_) { return false }
215
+ return url.protocol === "http:" || url.protocol === "https:"
216
+ }
217
+
218
+ /** 获取基于 conf.server 的 URL */
219
+ export function getURLBasedOnApi(ctx: Context, path: string) {
220
+ return `${ctx.conf.server.replace(/\/api\/?$/, '')}/${path.replace(/^\//, '')}`
221
+ }
222
+
223
+ /** 获取基于某个 baseURL 的 URL */
224
+ export function getURLBasedOn(baseURL: string, path: string) {
225
+ return `${baseURL.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
226
+ }