1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
|
/*!
* blog-console.js — Mayx's Blog DevTools Console API
*/
(function (global) {
'use strict';
/* =====================================================================
* 0. 常量与配置
* ===================================================================== */
var config = {
/** list() 默认每页条数 */
pageSize: 10,
/** show() 单次最多渲染的行数,防止超长文章刷屏 */
maxShowLines: 600,
/** 摘要/预览截断长度 */
previewLength: 120
};
/* =====================================================================
* 1. 控制台样式(DevTools 用 %c + CSS)
* ===================================================================== */
var S = {
reset: '',
title: 'font-weight:bold;font-size:13px;color:#3fb950',
sub: 'color:#8b949e',
num: 'color:#d29922;font-weight:bold',
date: 'color:#58a6ff',
strong: 'font-weight:bold',
link: 'color:#58a6ff;text-decoration:underline',
ok: 'color:#3fb950;font-weight:bold',
warn: 'color:#d29922',
err: 'color:#f85149;font-weight:bold',
dim: 'color:#8b949e',
code: 'color:#e3b341;font-family:ui-monospace,Consolas,monospace;background:rgba(110,118,129,.18);padding:0 3px;border-radius:3px',
quote: 'color:#8b949e;font-style:italic',
hr: 'color:#6e7781',
tag: 'color:#a371f7',
h: [
'',
'font-weight:bold;font-size:16px;color:#f85149',
'font-weight:bold;font-size:15px;color:#3fb950',
'font-weight:bold;font-size:14px;color:#d29922',
'font-weight:bold;font-size:13px;color:#58a6ff',
'font-weight:bold;font-size:13px;color:#a371f7',
'font-weight:bold;font-size:13px;color:#39c5cf'
]
};
/**
* 分段样式打印器。收集 [文本, CSS] 片段,分批 flush 到 console.log,
* 既能保证多行样式连续,又避免单条消息过长被浏览器截断。
*/
function Printer(chunkSize) {
this.chunk = chunkSize || 160;
this.fmt = [];
this.css = [];
this.pending = 0;
}
/**
* 追加一个带样式的片段(不换行)。
* @param {string} text 文本内容
* @param {string} [css] CSS 样式串,省略则使用默认样式
*/
Printer.prototype.push = function (text, css) {
// 转义 %,防止与 console 的格式化占位符冲突
this.fmt.push('%c' + String(text).replace(/%/g, '%%'));
this.css.push(css || '');
this.pending++;
return this;
};
/** 追加一行(自动换行),并在片段数超过阈值时 flush。 */
Printer.prototype.line = function (text, css) {
this.push((text === undefined ? '' : text) + '\n', css);
if (this.pending >= this.chunk) this.flush();
return this;
};
/** 结束当前行。 */
Printer.prototype.br = function () {
this.push('\n', '');
return this;
};
/** 把已累积的片段输出到控制台。 */
Printer.prototype.flush = function () {
if (!this.fmt.length) return this;
var msg = this.fmt.join('');
// 去掉行尾多余换行,避免每次 flush 产生空行
msg = msg.replace(/\n$/, '');
console.log.apply(console, [msg].concat(this.css));
this.fmt = [];
this.css = [];
this.pending = 0;
return this;
};
/** 打印一条分隔标题横幅。 */
function banner(text) {
var p = new Printer();
p.line('── ' + text + ' ' + repeat('─', Math.max(2, 46 - strWidth(text))), S.title);
p.flush();
}
function repeat(ch, n) { return n > 0 ? new Array(n + 1).join(ch) : ''; }
/** 粗略估算显示宽度(中日韩字符按 2 计)。 */
function strWidth(s) {
var w = 0;
for (var i = 0; i < s.length; i++) {
w += /[\u2E80-\uFFFF]/.test(s[i]) ? 2 : 1;
}
return w;
}
/** 打印错误并返回 null,统一失败出口。 */
function fail(msg) {
console.log('%c⚠️ ' + msg, S.err);
return null;
}
/* =====================================================================
* 2. 宿主环境依赖层
* ===================================================================== */
/**
* GitHub Issues 访问用的 Basic 凭据。
* @returns {{headers:object, owner:string, repo:string}}
*/
function githubAuth() {
var g = GitalkConfig;
return {
headers: { Authorization: 'Basic ' + btoa(g.clientID + ':' + g.clientSecret) },
owner: g.owner,
repo: g.repo
};
}
/**
* 读取搜索索引 search.json,复用 main.js 的 getSearchJSON(带 localStorage 缓存)。
* @returns {Promise<Array>} search.json 原始数组
*/
function loadSearchJSON() {
return new Promise(function (resolve) {
getSearchJSON(resolve);
});
}
/**
* 懒加载站点自带的 SimpleJekyllSearch。
* 该库只在 /search.html 里被引入,其他页面需要时动态注入同一个文件,
* 从而保证控制台搜索与页面搜索使用完全一致的匹配/排序逻辑。
* @returns {Promise<Function>} SimpleJekyllSearch 工厂函数
* @throws {Error} 脚本加载失败时 reject
*/
function ensureSimpleJekyllSearch() {
if (typeof global.SimpleJekyllSearch === 'function') {
return Promise.resolve(global.SimpleJekyllSearch);
}
return new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = '/assets/js/simple-jekyll-search.min.js';
s.async = true;
s.onload = function () {
if (typeof global.SimpleJekyllSearch === 'function') resolve(global.SimpleJekyllSearch);
};
s.onerror = function () {
reject(new Error('无法加载 SimpleJekyllSearch'));
};
document.head.appendChild(s);
});
}
/* =====================================================================
* 3. 通用请求工具
* ===================================================================== */
/**
* 请求 JSON,失败返回 null(不抛异常,便于控制台链式使用)。
* @param {string} url
* @param {object} [options] fetch 选项
* @returns {Promise<any|null>}
*/
function fetchJSON(url, options) {
return fetch(url, options || {})
.then(function (r) { return r.ok ? r.json() : null; })
.catch(function () { return null; });
}
/**
* 请求纯文本,失败返回 null。
* @param {string} url
* @param {object} [options] fetch 选项
* @returns {Promise<string|null>}
*/
function fetchText(url, options) {
return fetch(url, options || {})
.then(function (r) { return r.ok ? r.text() : null; })
.catch(function () { return null; });
}
/** 解码 HTML 实体(search.json 的 title 经过 Liquid escape 过滤器处理)。 */
function unescapeHTML(str) {
if (!str || str.indexOf('&') === -1) return str || '';
var el = document.createElement('textarea');
el.innerHTML = str;
return el.value;
}
/* =====================================================================
* 4. 数据层
* ===================================================================== */
var _articles = null;
/**
* 规范化 search.json 的一条记录。
* @param {object} item search.json 原始项
* @param {number} index 从 0 开始的下标
* @returns {{num:number,title:string,url:string,date:string,category:string,tags:string[],content:string,excerpt:string,link:string}}
*/
function normalize(item, index) {
var content = item.content || '';
var tags = (item.tags || '')
.split(',')
.map(function (t) { return t.trim(); })
.filter(Boolean);
return {
num: index + 1,
title: unescapeHTML(item.title || ''),
url: item.url || '',
date: item.date || '',
category: item.category || '',
tags: tags,
content: content,
excerpt: content.slice(0, config.previewLength) +
(content.length > config.previewLength ? '……' : ''),
link: item.url || ''
};
}
/**
* 取得全部文章(含缓存)。
* 注意:search.json 由 Jekyll 生成时已排除 layout 为 encrypt 的加密文章,
* 因此这里的序号 num 是「非加密文章」的序号。
* @param {boolean} [force] 传 true 强制重新拉取
* @returns {Promise<Array>} 规范化后的文章数组,最新的在前
*/
function getArticles(force) {
if (_articles && !force) return Promise.resolve(_articles);
return loadSearchJSON().then(function (data) {
_articles = (data || []).map(normalize);
return _articles;
});
}
/**
* 把各种形式的标识解析为一篇文章。
* @param {number|string} id 序号(1 起) / 文章 URL / 标题(支持模糊包含匹配)
* @returns {Promise<object|null>} 命中的文章对象,未命中为 null
*/
function resolve(id) {
return getArticles().then(function (list) {
if (!list || !list.length) return null;
if (id === undefined || id === null || id === '') {
// 无参时默认取当前页面对应的文章
return matchByPath(list, global.location.pathname);
}
// 1) 纯数字序号
var n = parseInt(id, 10);
if (!isNaN(n) && String(n) === String(id).trim()) {
return (n >= 1 && n <= list.length) ? list[n - 1] : null;
}
var s = String(id).trim();
// 2) 精确 URL / 路径
var byUrl = matchByPath(list, s);
if (byUrl) return byUrl;
// 3) 标题包含匹配(大小写不敏感)
var low = s.toLowerCase();
var hit = list.filter(function (a) {
return a.title.toLowerCase().indexOf(low) !== -1;
});
return hit.length ? hit[0] : null;
});
}
/** 按路径匹配文章(容忍 URL 编码差异与站点前缀)。 */
function matchByPath(list, path) {
if (!path) return null;
var dec = path, p = path;
try { dec = decodeURIComponent(p); } catch (e) { }
for (var i = 0; i < list.length; i++) {
var u = list[i].url, ud = u;
try { ud = decodeURIComponent(u); } catch (e) { }
if (u === p || ud === dec || u === dec || ud === p) return list[i];
}
return null;
}
/**
* 由文章对象推导其原始 Markdown 文件的 raw 地址。
* @param {object} article
* @returns {string} raw.githubusercontent.com 上的 .md 地址
*/
function rawUrlOf(article) {
var dateDash = (article.date || '').replace(/\//g, '-');
var last = (article.url || '').split('/').pop();
try { last = decodeURIComponent(last); } catch (e) { }
var slug = last.replace(/\.html$/, '');
return 'https://raw.githubusercontent.com/Mabbs/mabbs.github.io/refs/heads/master/_posts/' + dateDash + '-' + slug + '.md';
}
/* =====================================================================
* 5. Markdown → 控制台样式渲染
* ===================================================================== */
/**
* 行内标记解析:`code`、**bold**、*italic*、~~del~~、[text](url)、
* @param {Printer} p
* @param {string} text
* @param {string} baseCss 该行的基础样式
*/
function inline(p, text, baseCss) {
var re = /(`[^`]+`)|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\n]+\*|_[^_\n]+_)|(~~[^~]+~~)|(!?\[[^\]]*\]\([^)]*\))/g;
var last = 0, m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) p.push(text.slice(last, m.index), baseCss);
var tok = m[0];
if (m[1]) {
p.push(tok.slice(1, -1), S.code);
} else if (m[2]) {
p.push(tok.slice(2, -2), baseCss + ';font-weight:bold');
} else if (m[3]) {
p.push(tok.slice(1, -1), baseCss + ';font-style:italic');
} else if (m[4]) {
p.push(tok.slice(2, -2), baseCss + ';text-decoration:line-through;opacity:.7');
} else if (m[5]) {
var mm = /^(!?)\[([^\]]*)\]\(([^)]*)\)$/.exec(tok);
if (mm) {
var label = mm[2] || (mm[1] ? '图片' : '链接');
p.push((mm[1] ? '🖼 ' : '') + label, S.link);
if (mm[3]) p.push(' (' + mm[3] + ')', S.dim);
} else {
p.push(tok, baseCss);
}
}
last = m.index + tok.length;
}
if (last < text.length) p.push(text.slice(last), baseCss);
p.push('\n', '');
}
/**
* 渲染整篇 Markdown 到控制台。
* @param {string} md Markdown 原文
* @param {object} [opts]
* @param {number} [opts.maxLines] 最大渲染行数
* @param {boolean} [opts.frontMatter=true] 是否显示 YAML front matter
*/
function renderMarkdown(md, opts) {
opts = opts || {};
var maxLines = opts.maxLines || config.maxShowLines;
var showFM = opts.frontMatter !== false;
var lines = md.split('\n');
var p = new Printer();
var inCode = false, inFM = false, rendered = 0;
for (var i = 0; i < lines.length && rendered < maxLines; i++) {
var raw = lines[i];
var t = raw.replace(/^\s+/, '');
// YAML front matter
if (i === 0 && /^---\s*$/.test(t)) { inFM = true; if (showFM) p.line(raw, S.dim); rendered++; continue; }
if (inFM) {
if (/^---\s*$/.test(t)) inFM = false;
if (showFM) { p.line(raw, S.dim); rendered++; }
continue;
}
// 围栏代码块
if (/^```/.test(t) || /^~~~/.test(t)) {
inCode = !inCode;
p.line(raw, S.dim);
rendered++;
continue;
}
if (inCode) { p.line(raw, S.code); rendered++; continue; }
if (t === '') { p.line(''); rendered++; continue; }
// 标题
var h = t.match(/^(#{1,6})\s+/);
if (h) {
p.line(raw, S.h[h[1].length] || S.h[6]);
rendered++;
continue;
}
// 水平分割线
var hr = t.replace(/\s/g, '');
if (/^-{3,}$/.test(hr) || /^\*{3,}$/.test(hr) || /^_{3,}$/.test(hr)) {
p.line(raw, S.hr); rendered++; continue;
}
// 引用
if (/^>/.test(t)) { p.line(raw, S.quote); rendered++; continue; }
// 无序列表
if (/^[-*+]\s/.test(t)) {
var ind = raw.match(/^(\s*)/)[1];
p.push(ind + t[0] + ' ', S.ok);
inline(p, t.replace(/^[-*+]\s+/, ''), '');
rendered++;
if (p.pending >= p.chunk) p.flush();
continue;
}
// 有序列表
if (/^\d+\.\s/.test(t)) {
var ind2 = raw.match(/^(\s*)/)[1];
var mk = t.match(/^\d+\./)[0];
p.push(ind2 + mk + ' ', S.num);
inline(p, t.replace(/^\d+\.\s+/, ''), '');
rendered++;
if (p.pending >= p.chunk) p.flush();
continue;
}
inline(p, raw, '');
rendered++;
if (p.pending >= p.chunk) p.flush();
}
p.flush();
if (lines.length > rendered) {
console.log('%c… 已省略 ' + (lines.length - rendered) + ' 行', S.warn);
}
}
/* =====================================================================
* 6. 对外 API
* ===================================================================== */
var Blog = {};
/** 运行期配置对象,可直接修改 @type {object} */
Blog.config = config;
// ---------------------------------------------------------------- 数据
/**
* 按序号 / URL / 标题定位一篇文章并打印其元信息。
* @param {number|string} [id] 序号(从 1 开始) / 文章 URL / 标题关键字;
* 省略时取当前页面对应的文章
* @returns {Promise<object|null>} 文章对象,未找到返回 null
* @example await Blog.get(1); await Blog.get('/2015/02/23/diary.html'); await Blog.get('日记')
*/
Blog.get = function (id) {
return resolve(id).then(function (a) {
if (!a) return fail('未找到文章: ' + id);
banner('文章 #' + a.num);
var p = new Printer();
p.line(a.title, S.title);
p.push('日期 ', S.dim).line(a.date, S.date);
p.push('链接 ', S.dim).line(a.link, S.link);
if (a.category) p.push('分类 ', S.dim).line(a.category, S.tag);
if (a.tags.length) p.push('标签 ', S.dim).line(a.tags.join(' #'), S.tag);
p.push('字数 ', S.dim).line(String(a.content.length), S.num);
p.line('');
p.line(a.excerpt, S.sub);
p.flush();
return a;
});
};
/**
* 分页列出文章(控制台以表格呈现)。
* @param {number} [page=1] 页码,从 1 开始
* @param {number} [pageSize] 每页条数,默认取 Blog.config.pageSize(10)
* @returns {Promise<Array|null>} 当前页的文章数组
* @example await Blog.list(2)
*/
Blog.list = function (page, pageSize) {
page = parseInt(page, 10) || 1;
pageSize = parseInt(pageSize, 10) || config.pageSize;
if (page < 1) return Promise.resolve(fail('页码必须是正整数'));
return getArticles().then(function (list) {
if (!list) return fail('无法获取文章列表');
var total = list.length;
var totalPages = Math.ceil(total / pageSize);
var start = (page - 1) * pageSize;
if (start >= total) return fail('第 ' + page + ' 页没有文章(共 ' + totalPages + ' 页)');
var slice = list.slice(start, Math.min(start + pageSize, total));
banner('博客文章列表 · 第 ' + page + '/' + totalPages + ' 页');
var obj = {};
slice.forEach(function (a) {
obj[a.num] = { '日期': a.date, '标题': a.title };
});
console.table(obj);
console.log('%c共 ' + total + ' 篇 · Blog.show(id) 读正文 · Blog.open(id) 跳转 · Blog.comment(id) 看评论', S.sub);
return slice;
});
};
/**
* 搜索文章。复用站点自带的 SimpleJekyllSearch,
* 与 /search.html 使用完全一致的匹配与排序规则。
* @param {string} keyword 关键词
* @param {object} [opts]
* @param {number} [opts.limit=10] 最大结果数
* @param {boolean} [opts.fuzzy=false] 是否启用模糊匹配
* @returns {Promise<Array|null>} 命中的文章数组
* @example await Blog.search('Jekyll')
*/
Blog.search = function (keyword, opts) {
opts = opts || {};
var limit = opts.limit || 10;
if (!keyword) return Promise.resolve(fail('请提供关键词,例如 Blog.search("Jekyll")'));
return getArticles().then(function (list) {
if (!list) return fail('无法获取文章列表');
return ensureSimpleJekyllSearch().then(function (SJS) {
var results = searchWithSJS(SJS, list, keyword, limit, !!opts.fuzzy);
render(results);
return results;
});
});
function render(results) {
banner('搜索「' + keyword + '」· ' + results.length + ' 条结果');
if (!results.length) {
console.log('%c没有匹配的文章。', S.warn);
return;
}
var obj = {};
results.forEach(function (a) {
obj[a.num] = { '日期': a.date, '标题': a.title, '摘要': a.excerpt.slice(0, 40) };
});
console.table(obj);
console.log('%c用 Blog.show(' + results[0].num + ') 查看第一条结果。', S.sub);
}
};
/**
* 借助 SimpleJekyllSearch 在游离 DOM 节点上取得搜索结果。
* 结果模板只输出 url,再用 url 反查完整文章对象。
* @param {Function} SJS SimpleJekyllSearch 工厂函数
* @param {Array} list 全部文章
* @param {string} keyword 关键词
* @param {number} limit 最大结果数
* @param {boolean} fuzzy 是否模糊匹配
* @returns {Array} 命中的文章数组
*/
function searchWithSJS(SJS, list, keyword, limit, fuzzy) {
// SimpleJekyllSearch 会对每个字段调用 String.prototype.trim,
// 因此必须喂给它与 search.json 一致的「全字符串」结构,
// 而不是规范化后的对象(num 为数字、tags 为数组会直接报错)。
var flat = list.map(function (a) {
return {
title: a.title,
category: a.category,
tags: a.tags.join(' '),
url: a.url,
date: a.date,
content: a.content
};
});
var box = document.createElement('div');
SJS({
searchInput: document.createElement('input'),
resultsContainer: box,
json: flat,
searchResultTemplate: '<i data-u="{url}"></i>',
noResultsText: '',
limit: limit,
fuzzy: fuzzy
}).search(keyword);
var byUrl = {};
list.forEach(function (a) { byUrl[a.url] = a; });
var out = [];
Array.prototype.forEach.call(box.querySelectorAll('i[data-u]'), function (el) {
var a = byUrl[el.getAttribute('data-u')];
if (a) out.push(a);
});
return out;
}
/**
* 用正则表达式检索全部文章正文,并打印命中的上下文片段。
* (search.json 已内联全文,因此无需额外网络请求)
* @param {string|RegExp} pattern 正则或字符串
* @param {object} [opts]
* @param {number} [opts.context=40] 命中处前后保留的字符数
* @param {number} [opts.limit=20] 最多显示的命中条目数
* @returns {Promise<Array<{article:object,matches:string[]}>>}
* @example await Blog.grep(/Cloudflare\s*Workers?/i)
*/
Blog.grep = function (pattern, opts) {
if (!pattern) return Promise.resolve(fail('请提供关键词,例如 Blog.grep("Jekyll")'));
opts = opts || {};
var ctx = opts.context || 40;
var limit = opts.limit || 20;
var re;
try {
re = pattern instanceof RegExp
? new RegExp(pattern.source, pattern.flags.indexOf('g') === -1 ? pattern.flags + 'g' : pattern.flags)
: new RegExp(String(pattern).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
} catch (e) {
return Promise.resolve(fail('无效的正则表达式: ' + e.message));
}
return getArticles().then(function (list) {
if (!list) return fail('无法获取文章列表');
var out = [];
for (var i = 0; i < list.length && out.length < limit; i++) {
var a = list[i], m, hits = [];
re.lastIndex = 0;
while ((m = re.exec(a.content)) !== null && hits.length < 3) {
var s = Math.max(0, m.index - ctx);
var e = Math.min(a.content.length, m.index + m[0].length + ctx);
hits.push((s > 0 ? '…' : '') + a.content.slice(s, e) + (e < a.content.length ? '…' : ''));
if (m[0] === '') re.lastIndex++;
}
if (hits.length) out.push({ article: a, matches: hits });
}
banner('grep ' + re + ' · ' + out.length + ' 篇命中');
if (!out.length) { console.log('%c无命中。', S.warn); return out; }
var p = new Printer();
out.forEach(function (r) {
p.push('#' + r.article.num + ' ', S.num).line(r.article.title, S.strong);
r.matches.forEach(function (t) { p.line(' ' + t, S.sub); });
});
p.flush();
return out;
});
};
// ---------------------------------------------------------------- 内容
/**
* 拉取文章的原始 Markdown 并在控制台做语法着色渲染。
* @param {number|string} id 序号 / URL / 标题
* @param {object} [opts]
* @param {number} [opts.maxLines] 最多渲染行数,默认 Blog.config.maxShowLines(600)
* @param {boolean} [opts.frontMatter=true] 是否显示 YAML front matter
* @param {boolean} [opts.raw=false] 为 true 时跳过渲染,直接返回原文
* @returns {Promise<string|null>} Markdown 原文
* @example await Blog.show(1); await Blog.show(1, { raw: true })
*/
Blog.show = function (id, opts) {
opts = opts || {};
return resolve(id).then(function (a) {
if (!a) return fail('未找到文章: ' + id);
var url = rawUrlOf(a);
return fetchText(url).then(function (md) {
if (md === null) return fail('无法获取原始 Markdown:' + url);
if (opts.raw) return md;
banner(a.title);
var p = new Printer();
p.push('日期 ', S.dim).push(a.date, S.date)
.push(' 来源 ', S.dim).line(url, S.link);
p.line('');
p.flush();
renderMarkdown(md, opts);
});
});
};
/**
* 获取文章的 Gitalk 评论(走 GitHub Issues API)。
* @param {number|string} id 序号 / URL / 标题
* @returns {Promise<Array|null>} 评论数组 [{author, date, body}]
* @example await Blog.comment(1)
*/
Blog.comment = function (id) {
return resolve(id).then(function (a) {
if (!a) return fail('未找到文章: ' + id);
var auth = githubAuth();
var label = a.url.replace(/\.html$/, '');
var api = 'https://api.github.com/repos/' + auth.owner + '/' + auth.repo +
'/issues?labels=' + encodeURIComponent('Gitalk,' + label);
return fetchJSON(api, { headers: auth.headers }).then(function (issues) {
if (!issues || !issues.length) {
console.log('%c📭 文章「' + a.title + '」暂无评论(未找到对应 Issue)', S.warn);
return [];
}
return fetchJSON(issues[0].comments_url, { headers: auth.headers }).then(function (cs) {
if (!cs || !cs.length) {
console.log('%c📭 文章「' + a.title + '」暂无评论', S.warn);
return [];
}
var out = cs.map(function (c) {
return {
author: (c.user && c.user.login) || 'unknown',
date: c.created_at || '',
body: c.body || ''
};
});
banner('评论 · ' + a.title + ' · ' + out.length + ' 条');
var p = new Printer();
out.forEach(function (c, i) {
p.push((i + 1) + '. ', S.num)
.push(c.author, S.ok)
.line(' ' + c.date, S.dim);
c.body.split('\n').forEach(function (l) { p.line(' ' + l, ''); });
p.line('');
});
p.flush();
console.log('%c原 Issue: ' + issues[0].html_url, S.sub);
return out;
});
});
});
};
// ---------------------------------------------------------------- 导航
/**
* 打开一篇文章。默认复用 pjax.js 的 window.go() 做站内无刷新跳转。
* @param {number|string} id 序号 / URL / 标题
* @param {object} [opts]
* @param {boolean} [opts.newTab=false] 为 true 时改用新标签页打开
* @returns {Promise<object|null>} 被打开的文章对象
* @example await Blog.open(1); await Blog.open(1, { newTab: true })
*/
Blog.open = function (id, opts) {
opts = opts || {};
return resolve(id).then(function (a) {
if (!a) return fail('未找到文章: ' + id);
if (opts.newTab) {
var w = global.open(a.link, '_blank', 'noopener,noreferrer');
if (!w) return fail('浏览器阻止了弹出窗口,请允许后重试');
console.log('%c✅ 已在新标签页打开:%c' + a.title, S.ok, S.strong);
} else {
go(a.url);
console.log('%c✅ 正在跳转:%c' + a.title, S.ok, S.strong);
}
console.log('%c' + a.link, S.link);
return a;
});
};
/**
* 随机打开一篇文章。等价于首页 "Random" 链接的逻辑
* (getSearchJSON + go 的组合),此处复用同一对函数。
* @param {object} [opts]
* @param {boolean} [opts.open=true] 为 false 时只返回文章不跳转
* @returns {Promise<object|null>} 随机选中的文章
* @example await Blog.random({ open: false })
*/
Blog.random = function (opts) {
opts = opts || {};
return getArticles().then(function (list) {
if (!list || !list.length) return fail('无法获取文章列表');
var a = list[Math.floor(Math.random() * list.length)];
console.log('%c🎲 随机文章 #' + a.num + ':%c' + a.title, S.ok, S.strong);
console.log('%c' + a.link, S.link);
if (opts.open !== false) go(a.url);
return a;
});
};
/**
* 返回当前页面对应的文章信息(若当前不是文章页则返回 null)。
* @returns {Promise<object|null>}
* @example await Blog.current()
*/
Blog.current = function () {
return getArticles().then(function (list) {
var a = list ? matchByPath(list, global.location.pathname) : null;
if (!a) {
console.log('%c当前页面不是文章页:' + global.location.pathname, S.warn);
return null;
}
return Blog.get(a.num);
});
};
// ---------------------------------------------------------------- 其他
/**
* 显示站点作者信息(读取 /humans.txt)。。
* @returns {Promise<string|null>} humans.txt 全文
* @example await Blog.about()
*/
Blog.about = function () {
return fetchText('/humans.txt').then(function (t) {
if (t === null) return fail('无法获取 humans.txt');
banner('关于本站');
console.log('%c' + t, 'line-height:1.5');
return t;
});
};
/**
* 打印全部可用命令的帮助信息。
* @returns {void}
* @example Blog.help()
*/
Blog.help = function () {
var groups = [
['数据查询', [
['Blog.list(page, size)', '分页列出文章,表格输出,页码从 1 开始'],
['Blog.get(id)', '查看单篇文章元信息,id 支持 序号/URL/标题'],
['Blog.search(kw, opts)', '搜索文章'],
['Blog.grep(re, opts)', '用正则检索全文并显示上下文片段']
]],
['内容读取', [
['Blog.show(id, opts)', '阅读文章正文'],
['Blog.comment(id)', '获取 Gitalk 评论(GitHub Issues)'],
['Blog.current()', '当前页面对应的文章信息']
]],
['导航跳转', [
['Blog.open(id, {newTab})', '打开文章'],
['Blog.random({open})', '随机一篇文章']
]],
['其他', [
['Blog.about()', '站点与作者信息']
]]
];
var p = new Printer();
p.line('');
p.line(' Mayx 博客控制台 API', 'font-weight:bold;font-size:15px;color:#3fb950');
p.line('');
groups.forEach(function (g) {
p.line('▍' + g[0], S.h[3]);
g[1].forEach(function (row) {
var pad = repeat(' ', Math.max(1, 26 - strWidth(row[0])));
p.push(' ' + row[0], S.code).line(pad + row[1], S.sub);
});
p.line('');
});
p.line('示例:', S.strong);
p.line(' await Blog.list(1) 列出第 1 页文章', S.sub);
p.line(' await Blog.search("Jekyll") 搜索关键词', S.sub);
p.line(' await Blog.show(1) 阅读第 1 篇文章', S.sub);
p.line(' await Blog.open(1) 跳转到第 1 篇文章', S.sub);
p.line('');
p.flush();
};
/* =====================================================================
* 7. 挂载
* ===================================================================== */
global.Blog = Blog;
console.log(
'%c Mayx Blog %c 控制台 API 已就绪,输入 %cBlog.help()%c 查看全部命令 ',
'background:#3fb950;color:#fff;font-weight:bold;border-radius:3px 0 0 3px;padding:2px 6px',
'background:rgba(110,118,129,.2);padding:2px 6px',
'font-family:ui-monospace,Consolas,monospace;color:#e3b341;background:rgba(110,118,129,.2)',
'background:rgba(110,118,129,.2);padding:2px 6px;border-radius:0 3px 3px 0'
);
})(typeof window !== 'undefined' ? window : this);
|