hexo-text-pipeline 0.2.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/LICENSE +21 -0
- package/README.md +191 -0
- package/README.zh-CN.md +191 -0
- package/index.js +16 -0
- package/lib/core/api.js +102 -0
- package/lib/core/checker/runtime.js +71 -0
- package/lib/core/checker/static.js +168 -0
- package/lib/core/config.js +74 -0
- package/lib/core/console/pipeline.js +203 -0
- package/lib/core/engine.js +207 -0
- package/lib/core/loaders/command.js +56 -0
- package/lib/core/loaders/hooks.js +83 -0
- package/lib/core/loaders/plugin-dir.js +183 -0
- package/lib/core/loaders/preset.js +131 -0
- package/lib/core/loaders/script.js +50 -0
- package/lib/core/markdown-guard.js +89 -0
- package/lib/core/node-contract.js +133 -0
- package/lib/core/stages.js +53 -0
- package/lib/core/tap.js +73 -0
- package/lib/presets/obsidian/converters/_template/index.js +44 -0
- package/lib/presets/obsidian/converters/blockid/index.js +25 -0
- package/lib/presets/obsidian/converters/callout/index.js +84 -0
- package/lib/presets/obsidian/converters/callout/parse.js +60 -0
- package/lib/presets/obsidian/converters/callout/render.js +40 -0
- package/lib/presets/obsidian/converters/callout/styles.js +20 -0
- package/lib/presets/obsidian/converters/comment/index.js +106 -0
- package/lib/presets/obsidian/converters/embed/index.js +79 -0
- package/lib/presets/obsidian/converters/highlight/index.js +23 -0
- package/lib/presets/obsidian/converters/mdlink/index.js +63 -0
- package/lib/presets/obsidian/converters/mermaid/index.js +102 -0
- package/lib/presets/obsidian/converters/wikilink/index.js +65 -0
- package/lib/presets/obsidian/index.js +37 -0
- package/lib/presets/obsidian/post-index.js +158 -0
- package/package.json +41 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { replaceOutsideCode } = require('../../../../core/markdown-guard');
|
|
4
|
+
const { normalizeBase } = require('../../../../core/config');
|
|
5
|
+
const { getPostIndex, resolvePostByTarget, buildPostHref } = require('../../post-index');
|
|
6
|
+
|
|
7
|
+
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'avif'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Markdown 阶段处理 Obsidian 嵌入 ![[target|param]]:
|
|
11
|
+
* - 图片(按扩展名识别)→ 标准 markdown 图片;param 为数字时输出 <img width>(Obsidian 缩放语法)
|
|
12
|
+
* src = asset_prefix + target(attachment 目录布局是站点私事,交给配置)
|
|
13
|
+
* - 能解析为文章的笔记嵌入 → 降级为指向该文章的链接(博客无法内联整篇笔记)
|
|
14
|
+
* - 其他(pdf、音视频、解析不到的笔记)→ 原样保留
|
|
15
|
+
*
|
|
16
|
+
* 必须在 wikilink 之前运行:否则 ![[x]] 的 [[x]] 部分会被替换成链接,
|
|
17
|
+
* 残留的 ! 使其变成指向文章 URL 的图片。
|
|
18
|
+
*/
|
|
19
|
+
function parseEmbed(raw) {
|
|
20
|
+
const firstPipe = raw.indexOf('|');
|
|
21
|
+
const targetAndAnchor = firstPipe >= 0 ? raw.slice(0, firstPipe) : raw;
|
|
22
|
+
const param = firstPipe >= 0 ? raw.slice(firstPipe + 1).trim() : '';
|
|
23
|
+
|
|
24
|
+
const hashIndex = targetAndAnchor.indexOf('#');
|
|
25
|
+
const target = (hashIndex >= 0 ? targetAndAnchor.slice(0, hashIndex) : targetAndAnchor).trim();
|
|
26
|
+
|
|
27
|
+
return { target, param };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function imageExtension(target) {
|
|
31
|
+
const dot = target.lastIndexOf('.');
|
|
32
|
+
if (dot === -1) return '';
|
|
33
|
+
return target.slice(dot + 1).toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderImage(target, param, assetPrefix) {
|
|
37
|
+
const src = encodeURI((assetPrefix ? assetPrefix + '/' : '') + target.replace(/^\/+/, ''));
|
|
38
|
+
if (/^\d+$/.test(param)) {
|
|
39
|
+
return '<img src="' + src + '" width="' + param + '">';
|
|
40
|
+
}
|
|
41
|
+
const alt = param || target.split('/').pop();
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createEmbedReplacer(index, domainPrefix, assetPrefix) {
|
|
46
|
+
return function replaceEmbeds(segment) {
|
|
47
|
+
return segment.replace(/!\[\[([^\]]+)\]\]/g, (full, inner) => {
|
|
48
|
+
const { target, param } = parseEmbed(inner);
|
|
49
|
+
if (!target) return full;
|
|
50
|
+
|
|
51
|
+
if (IMAGE_EXTENSIONS.includes(imageExtension(target))) {
|
|
52
|
+
return renderImage(target, param, assetPrefix);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const post = resolvePostByTarget(index, target);
|
|
56
|
+
if (!post) return full;
|
|
57
|
+
|
|
58
|
+
const href = buildPostHref(post, domainPrefix);
|
|
59
|
+
if (!href) return full;
|
|
60
|
+
|
|
61
|
+
return '[' + (param || target) + '](' + href + ')';
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
name: 'embed',
|
|
68
|
+
stage: 'before_post_render',
|
|
69
|
+
test(content) {
|
|
70
|
+
return content.includes('![[');
|
|
71
|
+
},
|
|
72
|
+
convert(content, ctx) {
|
|
73
|
+
const index = getPostIndex(ctx.hexo);
|
|
74
|
+
const domainPrefix = normalizeBase(ctx.presetConfig.domain_prefix);
|
|
75
|
+
const assetPrefix = normalizeBase(ctx.config.asset_prefix);
|
|
76
|
+
return replaceOutsideCode(content, createEmbedReplacer(index, domainPrefix, assetPrefix));
|
|
77
|
+
},
|
|
78
|
+
_internal: { parseEmbed, createEmbedReplacer, renderImage }
|
|
79
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { replaceOutsideCode } = require('../../../../core/markdown-guard');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Markdown 阶段把 Obsidian 高亮 ==文本== 改写为 <mark>文本</mark>。
|
|
7
|
+
* 不跨行、内容非空且不含 =;代码内为字面量。
|
|
8
|
+
*/
|
|
9
|
+
function replaceHighlights(segment) {
|
|
10
|
+
return segment.replace(/==([^=\n]+)==/g, '<mark>$1</mark>');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
name: 'highlight',
|
|
15
|
+
stage: 'before_post_render',
|
|
16
|
+
test(content) {
|
|
17
|
+
return content.includes('==');
|
|
18
|
+
},
|
|
19
|
+
convert(content) {
|
|
20
|
+
return replaceOutsideCode(content, replaceHighlights);
|
|
21
|
+
},
|
|
22
|
+
_internal: { replaceHighlights }
|
|
23
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normalizeBase } = require('../../../../core/config');
|
|
4
|
+
const { getPostIndex, resolvePostByTarget, buildPostHref, safeDecodeURI } = require('../../post-index');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HTML 阶段兜底:把渲染后仍指向 .md 文件的链接(markdown 形式和 href 属性两种残留)
|
|
8
|
+
* 改写为 abbrlink 永久链接。处理 Obsidian 导出的相对路径和 URI 编码。
|
|
9
|
+
*/
|
|
10
|
+
function isExternalHref(href) {
|
|
11
|
+
return /^(https?:|mailto:|tel:|\/\/)/i.test(href);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveHref(index, domainPrefix, rawHref) {
|
|
15
|
+
const hashPos = rawHref.indexOf('#');
|
|
16
|
+
const hrefWithoutHash = hashPos >= 0 ? rawHref.slice(0, hashPos) : rawHref;
|
|
17
|
+
const hashRaw = hashPos >= 0 ? rawHref.slice(hashPos + 1) : '';
|
|
18
|
+
|
|
19
|
+
const post = resolvePostByTarget(index, safeDecodeURI(hrefWithoutHash));
|
|
20
|
+
if (!post) return null;
|
|
21
|
+
|
|
22
|
+
const href = buildPostHref(post, domainPrefix);
|
|
23
|
+
if (!href) return null;
|
|
24
|
+
|
|
25
|
+
const anchor = hashRaw ? '#' + encodeURIComponent(safeDecodeURI(hashRaw)) : '';
|
|
26
|
+
return href + anchor;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createMarkdownMdLinkReplacer(index, domainPrefix) {
|
|
30
|
+
return function replaceMarkdownMdLinks(content) {
|
|
31
|
+
return content.replace(/\[([^\]]+)\]\(([^\n]+?\.md(?:#[^\n]+)?)\)/gi, (full, text, rawHref) => {
|
|
32
|
+
if (isExternalHref(rawHref)) return full;
|
|
33
|
+
const href = resolveHref(index, domainPrefix, rawHref);
|
|
34
|
+
return href ? '[' + text + '](' + href + ')' : full;
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createHtmlMdHrefReplacer(index, domainPrefix) {
|
|
40
|
+
return function replaceMdHref(html) {
|
|
41
|
+
return html.replace(/(href\s*=\s*["'])([^"']+?\.md(?:#[^"']*)?)(["'])/gi, (full, prefix, rawHref, suffix) => {
|
|
42
|
+
if (isExternalHref(rawHref)) return full;
|
|
43
|
+
const href = resolveHref(index, domainPrefix, rawHref);
|
|
44
|
+
return href ? prefix + href + suffix : full;
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
name: 'mdlink',
|
|
51
|
+
stage: 'after_post_render',
|
|
52
|
+
test(content) {
|
|
53
|
+
return content.includes('.md');
|
|
54
|
+
},
|
|
55
|
+
convert(content, ctx) {
|
|
56
|
+
const index = getPostIndex(ctx.hexo);
|
|
57
|
+
const domainPrefix = normalizeBase(ctx.presetConfig.domain_prefix);
|
|
58
|
+
const replaceMarkdownMdLinks = createMarkdownMdLinkReplacer(index, domainPrefix);
|
|
59
|
+
const replaceMdHref = createHtmlMdHrefReplacer(index, domainPrefix);
|
|
60
|
+
return replaceMdHref(replaceMarkdownMdLinks(content));
|
|
61
|
+
},
|
|
62
|
+
_internal: { createMarkdownMdLinkReplacer, createHtmlMdHrefReplacer }
|
|
63
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SCRIPT_SRC = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
|
|
4
|
+
const DEFAULT_CLASS = 'mermaid';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Markdown 阶段把 ```mermaid 围栏块替换为 <pre class="mermaid">,
|
|
8
|
+
* 让图源码绕过语法高亮(highlight.js / prismjs 会把它渲染成代码而非图)。
|
|
9
|
+
* 内容做 HTML 转义,浏览器解析后 textContent 还原原文,mermaid.js 正常读取。
|
|
10
|
+
*
|
|
11
|
+
* 前端脚本默认按需注入(页面没有 .mermaid 元素时不加载 CDN):
|
|
12
|
+
* converters.mermaid 子配置:class / inject_script / script_src / theme。
|
|
13
|
+
*/
|
|
14
|
+
function escapeHtml(text) {
|
|
15
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function transformMermaidBlocks(content, className) {
|
|
19
|
+
const lines = content.split('\n');
|
|
20
|
+
const out = [];
|
|
21
|
+
|
|
22
|
+
let inFence = false;
|
|
23
|
+
let fenceChar = '';
|
|
24
|
+
let fenceLen = 0;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
27
|
+
const line = lines[i];
|
|
28
|
+
const match = line.match(/^ {0,3}([`~]{3,})(.*)$/);
|
|
29
|
+
|
|
30
|
+
if (!inFence && match && match[2].trim().toLowerCase() === 'mermaid') {
|
|
31
|
+
const token = match[1];
|
|
32
|
+
// 收集到配对的闭合围栏;未闭合则原样保留
|
|
33
|
+
const body = [];
|
|
34
|
+
let closed = false;
|
|
35
|
+
let j = i + 1;
|
|
36
|
+
for (; j < lines.length; j += 1) {
|
|
37
|
+
const closeMatch = lines[j].match(/^ {0,3}([`~]{3,})\s*$/);
|
|
38
|
+
if (closeMatch && closeMatch[1][0] === token[0] && closeMatch[1].length >= token.length) {
|
|
39
|
+
closed = true;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
body.push(lines[j]);
|
|
43
|
+
}
|
|
44
|
+
if (closed) {
|
|
45
|
+
out.push('<pre class="' + className + '">' + escapeHtml(body.join('\n')) + '</pre>');
|
|
46
|
+
i = j;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
out.push(line);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (match) {
|
|
54
|
+
const token = match[1];
|
|
55
|
+
if (!inFence) {
|
|
56
|
+
inFence = true;
|
|
57
|
+
fenceChar = token[0];
|
|
58
|
+
fenceLen = token.length;
|
|
59
|
+
} else if (token[0] === fenceChar && token.length >= fenceLen && !match[2].trim()) {
|
|
60
|
+
inFence = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
out.push(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return out.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildLoaderScript(config) {
|
|
71
|
+
if (config.inject_script === false) return '';
|
|
72
|
+
const src = typeof config.script_src === 'string' && config.script_src ? config.script_src : DEFAULT_SCRIPT_SRC;
|
|
73
|
+
const theme = typeof config.theme === 'string' && config.theme ? config.theme : 'default';
|
|
74
|
+
const className = typeof config.class === 'string' && config.class ? config.class : DEFAULT_CLASS;
|
|
75
|
+
return (
|
|
76
|
+
'(function(){' +
|
|
77
|
+
'if(!document.querySelector("pre.' + className + '"))return;' +
|
|
78
|
+
'var s=document.createElement("script");' +
|
|
79
|
+
's.src=' + JSON.stringify(src) + ';' +
|
|
80
|
+
's.onload=function(){window.mermaid.initialize({startOnLoad:false,theme:' + JSON.stringify(theme) + '});' +
|
|
81
|
+
'window.mermaid.run({querySelector:"pre.' + className + '"});};' +
|
|
82
|
+
'document.head.appendChild(s);' +
|
|
83
|
+
'})();'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
name: 'mermaid',
|
|
89
|
+
stage: 'before_post_render',
|
|
90
|
+
js: buildLoaderScript,
|
|
91
|
+
test(content) {
|
|
92
|
+
return /^ {0,3}[`~]{3,}\s*mermaid\s*$/im.test(content);
|
|
93
|
+
},
|
|
94
|
+
convert(content, ctx) {
|
|
95
|
+
const className =
|
|
96
|
+
ctx && ctx.config && typeof ctx.config.class === 'string' && ctx.config.class
|
|
97
|
+
? ctx.config.class
|
|
98
|
+
: DEFAULT_CLASS;
|
|
99
|
+
return transformMermaidBlocks(content, className);
|
|
100
|
+
},
|
|
101
|
+
_internal: { transformMermaidBlocks, buildLoaderScript }
|
|
102
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { replaceOutsideCode } = require('../../../../core/markdown-guard');
|
|
4
|
+
const { normalizeBase } = require('../../../../core/config');
|
|
5
|
+
const { getPostIndex, resolvePostByTarget, buildPostHref } = require('../../post-index');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Markdown 阶段把 Obsidian wiki link [[target#anchor|alias]] 改写为
|
|
9
|
+
* 指向 abbrlink 永久链接的标准 markdown 链接。无法解析的目标原样保留。
|
|
10
|
+
*
|
|
11
|
+
* - ![[...]] 是嵌入,归 embed node 管,这里用 lookbehind 跳过
|
|
12
|
+
* - #^block-id 块引用锚点在渲染后的 HTML 里不存在,丢弃锚点只留文章链接
|
|
13
|
+
* - [[#标题]](无 target 的同页链接)改写为页内锚点
|
|
14
|
+
*/
|
|
15
|
+
function parseWikiLink(raw) {
|
|
16
|
+
const firstPipe = raw.indexOf('|');
|
|
17
|
+
const targetAndAnchor = firstPipe >= 0 ? raw.slice(0, firstPipe) : raw;
|
|
18
|
+
const alias = firstPipe >= 0 ? raw.slice(firstPipe + 1).trim() : '';
|
|
19
|
+
|
|
20
|
+
const hashIndex = targetAndAnchor.indexOf('#');
|
|
21
|
+
const target = hashIndex >= 0 ? targetAndAnchor.slice(0, hashIndex).trim() : targetAndAnchor.trim();
|
|
22
|
+
const anchor = hashIndex >= 0 ? targetAndAnchor.slice(hashIndex + 1).trim() : '';
|
|
23
|
+
|
|
24
|
+
return { target, anchor, alias };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createWikiLinkReplacer(index, domainPrefix) {
|
|
28
|
+
return function replaceWikiLinks(segment) {
|
|
29
|
+
return segment.replace(/(?<!!)\[\[([^\]]+)\]\]/g, (full, inner) => {
|
|
30
|
+
const parsed = parseWikiLink(inner);
|
|
31
|
+
|
|
32
|
+
// 同页链接 [[#标题]]:没有 target,只有锚点
|
|
33
|
+
if (!parsed.target) {
|
|
34
|
+
if (!parsed.anchor || parsed.anchor.startsWith('^')) return full;
|
|
35
|
+
return '[' + (parsed.alias || parsed.anchor) + '](#' + encodeURIComponent(parsed.anchor) + ')';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const post = resolvePostByTarget(index, parsed.target);
|
|
39
|
+
if (!post) return full;
|
|
40
|
+
|
|
41
|
+
const href = buildPostHref(post, domainPrefix);
|
|
42
|
+
if (!href) return full;
|
|
43
|
+
|
|
44
|
+
// 块引用锚点(#^id)在 HTML 里没有对应元素,降级为文章链接
|
|
45
|
+
const anchor = parsed.anchor && !parsed.anchor.startsWith('^') ? '#' + encodeURIComponent(parsed.anchor) : '';
|
|
46
|
+
const text = parsed.alias || parsed.target;
|
|
47
|
+
|
|
48
|
+
return '[' + text + '](' + href + anchor + ')';
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
name: 'wikilink',
|
|
55
|
+
stage: 'before_post_render',
|
|
56
|
+
test(content) {
|
|
57
|
+
return content.includes('[[');
|
|
58
|
+
},
|
|
59
|
+
convert(content, ctx) {
|
|
60
|
+
const index = getPostIndex(ctx.hexo);
|
|
61
|
+
const replacer = createWikiLinkReplacer(index, normalizeBase(ctx.presetConfig.domain_prefix));
|
|
62
|
+
return replaceOutsideCode(content, replacer);
|
|
63
|
+
},
|
|
64
|
+
_internal: { parseWikiLink, createWikiLinkReplacer }
|
|
65
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { invalidatePostIndex } = require('./post-index');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Obsidian Flavored Markdown preset —— 本插件自带的"插件的插件"。
|
|
7
|
+
* nodes 数组顺序就是同 stage 同 priority 下的执行顺序:
|
|
8
|
+
* comment 必须最先(被注释掉的语法要在其他 node 看到之前消失)。
|
|
9
|
+
*
|
|
10
|
+
* 启用与配置(_config.yml):
|
|
11
|
+
* text_pipeline:
|
|
12
|
+
* presets:
|
|
13
|
+
* - name: obsidian
|
|
14
|
+
* config:
|
|
15
|
+
* domain_prefix: '' # wikilink/mdlink 的链接前缀
|
|
16
|
+
* callout: { enable: true } # callout 出厂默认关
|
|
17
|
+
* mermaid: { theme: dark } # 任意 node 级子配置 / priority 覆盖
|
|
18
|
+
*/
|
|
19
|
+
module.exports = {
|
|
20
|
+
name: 'obsidian',
|
|
21
|
+
nodes: [
|
|
22
|
+
require('./converters/comment'),
|
|
23
|
+
require('./converters/embed'), // 必须在 wikilink 前:![[x]] 不能被当作 ![[x]] 的 [[x]] 部分
|
|
24
|
+
require('./converters/wikilink'),
|
|
25
|
+
require('./converters/highlight'),
|
|
26
|
+
require('./converters/blockid'),
|
|
27
|
+
require('./converters/mdlink'),
|
|
28
|
+
require('./converters/mermaid'),
|
|
29
|
+
require('./converters/callout')
|
|
30
|
+
],
|
|
31
|
+
init(hexo) {
|
|
32
|
+
// wikilink/mdlink 共享的文章索引:每轮 generate 前失效重建
|
|
33
|
+
hexo.extend.filter.register('before_generate', () => {
|
|
34
|
+
invalidatePostIndex(hexo);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 文章索引服务:把 hexo 的 Post 集合建成多键索引(title / slug / source 路径),
|
|
5
|
+
* 供链接类 converter 把 Obsidian 目标解析成 abbrlink 永久链接。
|
|
6
|
+
* 缓存以 hexo 实例为键,engine 在 before_generate 时调用 invalidatePostIndex。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function trimSlashes(value) {
|
|
10
|
+
return String(value || '').replace(/^\/+|\/+$/g, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stripMarkdownExtension(value) {
|
|
14
|
+
return value.replace(/\.(md|markdown)$/i, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeLookupKey(value) {
|
|
18
|
+
return String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stripLeadingDotSegments(value) {
|
|
22
|
+
return value.replace(/^(\.\.\/|\.\/)+/, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeDecodeURI(value) {
|
|
26
|
+
try {
|
|
27
|
+
return decodeURIComponent(value);
|
|
28
|
+
} catch (_err) {
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildPostIndex(hexo) {
|
|
34
|
+
let posts = [];
|
|
35
|
+
if (hexo.model && typeof hexo.model === 'function') {
|
|
36
|
+
const postModel = hexo.model('Post');
|
|
37
|
+
if (postModel && typeof postModel.toArray === 'function') {
|
|
38
|
+
posts = postModel.toArray();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!posts.length && hexo.locals && typeof hexo.locals.get === 'function') {
|
|
43
|
+
const postQuery = hexo.locals.get('posts');
|
|
44
|
+
posts = postQuery ? postQuery.toArray() : [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const byTitle = new Map();
|
|
48
|
+
const bySlug = new Map();
|
|
49
|
+
const bySourcePath = new Map();
|
|
50
|
+
const bySourceBase = new Map();
|
|
51
|
+
|
|
52
|
+
let indexedCount = 0;
|
|
53
|
+
|
|
54
|
+
for (const post of posts) {
|
|
55
|
+
// abbrlink(frontmatter 标签)优先;没有时兜底用 hexo 生成的 post.path
|
|
56
|
+
if (!post || (!post.abbrlink && !post.path)) continue;
|
|
57
|
+
indexedCount += 1;
|
|
58
|
+
|
|
59
|
+
if (post.title) {
|
|
60
|
+
byTitle.set(String(post.title).trim().toLowerCase(), post);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (post.slug) {
|
|
64
|
+
bySlug.set(String(post.slug).trim().toLowerCase(), post);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (post.source) {
|
|
68
|
+
const normalizedSource = normalizeLookupKey(post.source).replace(/^.*?_posts\//, '');
|
|
69
|
+
const sourceNoExt = stripMarkdownExtension(normalizedSource);
|
|
70
|
+
const sourceBase = sourceNoExt.includes('/') ? sourceNoExt.slice(sourceNoExt.lastIndexOf('/') + 1) : sourceNoExt;
|
|
71
|
+
|
|
72
|
+
if (sourceNoExt) {
|
|
73
|
+
bySourcePath.set(sourceNoExt, post);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (sourceBase) {
|
|
77
|
+
bySourceBase.set(sourceBase, post);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { byTitle, bySlug, bySourcePath, bySourceBase, indexedCount };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildTargetCandidates(target) {
|
|
86
|
+
const normalized = normalizeLookupKey(stripLeadingDotSegments(target));
|
|
87
|
+
if (!normalized) return [];
|
|
88
|
+
|
|
89
|
+
const candidates = new Set();
|
|
90
|
+
const noExt = stripMarkdownExtension(normalized);
|
|
91
|
+
|
|
92
|
+
candidates.add(normalized);
|
|
93
|
+
candidates.add(noExt);
|
|
94
|
+
|
|
95
|
+
if (normalized.includes('/')) {
|
|
96
|
+
const base = normalized.slice(normalized.lastIndexOf('/') + 1);
|
|
97
|
+
candidates.add(base);
|
|
98
|
+
candidates.add(stripMarkdownExtension(base));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return Array.from(candidates).filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolvePostByTarget(index, target) {
|
|
105
|
+
const candidates = buildTargetCandidates(target);
|
|
106
|
+
|
|
107
|
+
for (const key of candidates) {
|
|
108
|
+
const post =
|
|
109
|
+
index.byTitle.get(key) ||
|
|
110
|
+
index.bySlug.get(key) ||
|
|
111
|
+
index.bySourcePath.get(key) ||
|
|
112
|
+
index.bySourceBase.get(key);
|
|
113
|
+
if (post) return post;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildPostHref(post, domainPrefix) {
|
|
120
|
+
let hrefPath;
|
|
121
|
+
if (post.abbrlink) {
|
|
122
|
+
hrefPath = '/posts/' + trimSlashes(post.abbrlink);
|
|
123
|
+
} else if (post.path) {
|
|
124
|
+
hrefPath = '/' + trimSlashes(String(post.path));
|
|
125
|
+
} else {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return domainPrefix ? domainPrefix + hrefPath : hrefPath;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cache = new WeakMap();
|
|
132
|
+
|
|
133
|
+
function getPostIndex(hexo) {
|
|
134
|
+
let index = cache.get(hexo);
|
|
135
|
+
if (!index || !index.indexedCount) {
|
|
136
|
+
index = buildPostIndex(hexo);
|
|
137
|
+
cache.set(hexo, index);
|
|
138
|
+
}
|
|
139
|
+
return index;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function invalidatePostIndex(hexo) {
|
|
143
|
+
cache.delete(hexo);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
trimSlashes,
|
|
148
|
+
stripMarkdownExtension,
|
|
149
|
+
normalizeLookupKey,
|
|
150
|
+
stripLeadingDotSegments,
|
|
151
|
+
safeDecodeURI,
|
|
152
|
+
buildPostIndex,
|
|
153
|
+
buildTargetCandidates,
|
|
154
|
+
resolvePostByTarget,
|
|
155
|
+
buildPostHref,
|
|
156
|
+
getPostIndex,
|
|
157
|
+
invalidatePostIndex
|
|
158
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hexo-text-pipeline",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A general-purpose hooks bus for Hexo's render pipeline: hang your own scripts/commands on any text stage (text in, text out, edit-and-use), with a checker system as the safety net. Ships an Obsidian Flavored Markdown preset.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"lib",
|
|
9
|
+
"README.md",
|
|
10
|
+
"README.zh-CN.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test",
|
|
14
|
+
"prepublishOnly": "npm test"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"hexo",
|
|
18
|
+
"hexo-plugin",
|
|
19
|
+
"hooks",
|
|
20
|
+
"pipeline",
|
|
21
|
+
"text-transform",
|
|
22
|
+
"obsidian",
|
|
23
|
+
"obsidian-flavored-markdown",
|
|
24
|
+
"wikilink",
|
|
25
|
+
"mermaid",
|
|
26
|
+
"callout"
|
|
27
|
+
],
|
|
28
|
+
"author": "Sentixxx",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/moesin-lab/hexo-text-pipeline.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/moesin-lab/hexo-text-pipeline#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/moesin-lab/hexo-text-pipeline/issues"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=16"
|
|
40
|
+
}
|
|
41
|
+
}
|