Mayx's Home Page
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

866 lines
34 KiB

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