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,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
6
|
+
const MAX_BUFFER = 32 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* command hook → node:外部命令,正文从 stdin 进、变换结果从 stdout 出,任何语言。
|
|
10
|
+
* 上下文走环境变量:HTP_STAGE / HTP_SLOT 恒有;post 类 stage 给
|
|
11
|
+
* HTP_POST_SOURCE / HTP_POST_PATH / HTP_POST_TITLE,string 类给 HTP_FILE_PATH——
|
|
12
|
+
* 与 ctx.post / ctx.file 的分流一一对应,不再用空的 POST 变量冒充。
|
|
13
|
+
* 非零退出 / 启动失败 → 抛错,由 runtime checker 隔离(跳过该 node,原文继续)。
|
|
14
|
+
*/
|
|
15
|
+
function createCommandNode(hook) {
|
|
16
|
+
return {
|
|
17
|
+
name: 'hook:' + hook.name,
|
|
18
|
+
stage: hook.stage,
|
|
19
|
+
slot: hook.slot,
|
|
20
|
+
priority: hook.priority,
|
|
21
|
+
origin: 'hook:command',
|
|
22
|
+
convert(content, ctx) {
|
|
23
|
+
const env = Object.assign({}, process.env, {
|
|
24
|
+
HTP_STAGE: hook.stage,
|
|
25
|
+
HTP_SLOT: hook.slot
|
|
26
|
+
});
|
|
27
|
+
if (ctx && ctx.post) {
|
|
28
|
+
env.HTP_POST_SOURCE = ctx.post.source || '';
|
|
29
|
+
env.HTP_POST_PATH = ctx.post.path || '';
|
|
30
|
+
env.HTP_POST_TITLE = ctx.post.title || '';
|
|
31
|
+
}
|
|
32
|
+
if (ctx && ctx.file) {
|
|
33
|
+
env.HTP_FILE_PATH = ctx.file.path || '';
|
|
34
|
+
}
|
|
35
|
+
const result = spawnSync(hook.command, {
|
|
36
|
+
input: content,
|
|
37
|
+
shell: true,
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
timeout: hook.timeout || DEFAULT_TIMEOUT_MS,
|
|
40
|
+
maxBuffer: MAX_BUFFER,
|
|
41
|
+
env
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (result.error) {
|
|
45
|
+
throw new Error('command failed to start: ' + result.error.message);
|
|
46
|
+
}
|
|
47
|
+
if (result.status !== 0) {
|
|
48
|
+
const stderr = (result.stderr || '').trim().slice(0, 500);
|
|
49
|
+
throw new Error('command exited with ' + result.status + (stderr ? ': ' + stderr : ''));
|
|
50
|
+
}
|
|
51
|
+
return result.stdout;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { createCommandNode, DEFAULT_TIMEOUT_MS };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolvePlacement } = require('../node-contract');
|
|
4
|
+
const { createCommandNode } = require('./command');
|
|
5
|
+
const { createScriptNode } = require('./script');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 解析配置里的 hooks 数组(用户随时插入的自定义脚本),两种形态:
|
|
9
|
+
*
|
|
10
|
+
* text_pipeline:
|
|
11
|
+
* hooks:
|
|
12
|
+
* - command: python scripts/furigana.py # 外部命令(stdin → stdout)
|
|
13
|
+
* stage: before_post_render # 可选,默认 before_post_render
|
|
14
|
+
* priority: 20 # 可选,默认 10,小者先跑
|
|
15
|
+
* name: furigana # 可选,日志标识
|
|
16
|
+
* timeout: 10000 # 可选,毫秒(仅 command)
|
|
17
|
+
* - script: scripts/toc.js # 本地 JS:module.exports = (text, ctx) => text
|
|
18
|
+
* match: '\\[\\[toc\\]\\]' # 可选:正则预判,文本不命中就跳过(command 可省一次 spawn)
|
|
19
|
+
* slot: late # 可选:默认 late(在 hexo 内置/其他插件之后,
|
|
20
|
+
* # 看到最终文本);slot: early 抢到它们之前
|
|
21
|
+
*
|
|
22
|
+
* stage / slot / priority / match 的默认值与校验在 node-contract.js,
|
|
23
|
+
* 与 textPipeline.register、preset 加载共用同一套规则。
|
|
24
|
+
* 非法条目收集为 issue(warn 或 strict 下报错),不中断其他条目。
|
|
25
|
+
*/
|
|
26
|
+
function normalizeHookEntry(raw, index) {
|
|
27
|
+
if (!raw || typeof raw !== 'object') {
|
|
28
|
+
return { error: 'hooks[' + index + '] must be an object with a command or script field' };
|
|
29
|
+
}
|
|
30
|
+
if (raw.enable === false) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const command = typeof raw.command === 'string' ? raw.command.trim() : '';
|
|
35
|
+
const script = typeof raw.script === 'string' ? raw.script.trim() : '';
|
|
36
|
+
if (!command && !script) {
|
|
37
|
+
return { error: 'hooks[' + index + '] is missing a command or script' };
|
|
38
|
+
}
|
|
39
|
+
if (command && script) {
|
|
40
|
+
return { error: 'hooks[' + index + '] sets both command and script; pick one' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const resolved = resolvePlacement(raw, 'hook');
|
|
44
|
+
if (resolved.error) {
|
|
45
|
+
return { error: 'hooks[' + index + '] has ' + resolved.error };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
hook: {
|
|
50
|
+
name: typeof raw.name === 'string' && raw.name ? raw.name : 'hook-' + index,
|
|
51
|
+
stage: resolved.placement.stage,
|
|
52
|
+
slot: resolved.placement.slot,
|
|
53
|
+
priority: resolved.placement.priority,
|
|
54
|
+
test: resolved.placement.test,
|
|
55
|
+
command,
|
|
56
|
+
script,
|
|
57
|
+
timeout: Number.isFinite(raw.timeout) && raw.timeout > 0 ? raw.timeout : undefined
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadHooks(rawHooks, baseDir) {
|
|
63
|
+
const nodes = [];
|
|
64
|
+
const issues = [];
|
|
65
|
+
|
|
66
|
+
(rawHooks || []).forEach((raw, index) => {
|
|
67
|
+
const parsed = normalizeHookEntry(raw, index);
|
|
68
|
+
if (!parsed) return;
|
|
69
|
+
if (parsed.error) {
|
|
70
|
+
issues.push({ level: 'error', message: parsed.error });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const node = parsed.hook.command ? createCommandNode(parsed.hook) : createScriptNode(parsed.hook, baseDir);
|
|
74
|
+
if (parsed.hook.test) {
|
|
75
|
+
node.test = parsed.hook.test;
|
|
76
|
+
}
|
|
77
|
+
nodes.push(node);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return { nodes, issues };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { loadHooks, _internal: { normalizeHookEntry } };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { nodeConfig, isNodeEnabled } = require('../config');
|
|
6
|
+
const { resolvePlacement, resolveConvert } = require('../node-contract');
|
|
7
|
+
const { invalidateModuleTree } = require('./script');
|
|
8
|
+
const { suggest } = require('../checker/static');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 单文件插件目录(第四条注册路径,role 'plugin')——写插件 = 写一个文件:
|
|
12
|
+
*
|
|
13
|
+
* <hexo 根>/text-pipeline/arrow.js
|
|
14
|
+
* module.exports = { replace: [[/-->/g, '→']] };
|
|
15
|
+
*
|
|
16
|
+
* 约定:
|
|
17
|
+
* - 目录里每个顶层 .js 文件自动挂载,零配置生效;`_` 前缀文件跳过(共享辅助模块);
|
|
18
|
+
* 按文件名排序保证注册顺序确定。
|
|
19
|
+
* - 导出单个 node 对象(与统一契约同形;name 缺省取文件名)或 node 数组
|
|
20
|
+
* (数组元素必须显式 name)。不收裸函数——`(text, ctx) => text` 的函数语义
|
|
21
|
+
* 由 hooks 的 script 条目承载。
|
|
22
|
+
* - name 不加前缀(用户自己起的名);origin 为 'plugin:<文件名>'。
|
|
23
|
+
* - _config.yml 的 text_pipeline.plugins.<name> 可覆盖 enable / slot / priority,
|
|
24
|
+
* 其余键随整个子对象进 ctx.config(语义与 preset 子配置一致);stage 不可覆盖。
|
|
25
|
+
*
|
|
26
|
+
* 热重载(与 script hook 同一套失效语义):convert / replace / test / match
|
|
27
|
+
* 即改即用——每次执行重新 require 文件取新声明;stage / slot / priority / name /
|
|
28
|
+
* css / js / enabledByDefault 在注册期固化,改动需重启;目录增删文件同样需重启。
|
|
29
|
+
*/
|
|
30
|
+
function listPluginFiles(root) {
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return null; // 目录不存在:零配置默认值不该制造噪音
|
|
36
|
+
}
|
|
37
|
+
return entries
|
|
38
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.js') && !entry.name.startsWith('_'))
|
|
39
|
+
.map((entry) => entry.name)
|
|
40
|
+
.sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function freshPrecheckPasses(decl, content) {
|
|
44
|
+
if (typeof decl.test === 'function' && !decl.test(content)) return false;
|
|
45
|
+
if (decl.match !== undefined) {
|
|
46
|
+
const regex = decl.match instanceof RegExp ? decl.match : new RegExp(decl.match);
|
|
47
|
+
if (!regex.test(content)) return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildPluginNode(entry, { fileName, scriptPath, shortName, sub, placement }) {
|
|
53
|
+
return {
|
|
54
|
+
name: shortName,
|
|
55
|
+
stage: placement.stage,
|
|
56
|
+
slot: placement.slot,
|
|
57
|
+
priority: placement.priority,
|
|
58
|
+
origin: 'plugin:' + fileName,
|
|
59
|
+
scriptPath,
|
|
60
|
+
config: sub,
|
|
61
|
+
css: entry.css,
|
|
62
|
+
js: entry.js,
|
|
63
|
+
// 热重载入口:每次执行取文件的最新声明(test/match 预判也吃最新值),
|
|
64
|
+
// guard 跟随注册期固化的 stage——声明里改 stage 不会生效,必须重启。
|
|
65
|
+
convert(content, ctx) {
|
|
66
|
+
invalidateModuleTree(scriptPath);
|
|
67
|
+
const fresh = require(scriptPath);
|
|
68
|
+
const decl = Array.isArray(fresh) ? fresh.find((n) => n && n.name === shortName) : fresh;
|
|
69
|
+
if (!decl || typeof decl !== 'object') {
|
|
70
|
+
throw new Error('node "' + shortName + '" no longer found in ' + fileName + ' — restart hexo to re-discover plugins');
|
|
71
|
+
}
|
|
72
|
+
if (!freshPrecheckPasses(decl, content)) return content;
|
|
73
|
+
const converted = resolveConvert(Object.assign({}, decl, { stage: placement.stage }));
|
|
74
|
+
if (converted.error) {
|
|
75
|
+
throw new Error(converted.error);
|
|
76
|
+
}
|
|
77
|
+
return converted.convert(content, ctx);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function checkDanglingOverrides(pluginsOverrides, discoveredNames, issues) {
|
|
83
|
+
for (const key of Object.keys(pluginsOverrides || {})) {
|
|
84
|
+
if (!discoveredNames.includes(key)) {
|
|
85
|
+
issues.push({
|
|
86
|
+
level: 'warn',
|
|
87
|
+
message: 'text_pipeline.plugins.' + key + ' matches no discovered plugin' + suggest(key, discoveredNames)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function loadPluginDir(pluginsDir, pluginsOverrides, baseDir) {
|
|
94
|
+
const nodes = [];
|
|
95
|
+
const issues = [];
|
|
96
|
+
const root = path.resolve(baseDir || process.cwd(), pluginsDir);
|
|
97
|
+
|
|
98
|
+
const files = listPluginFiles(root);
|
|
99
|
+
if (!files) {
|
|
100
|
+
// 目录不存在本身不是问题(零配置默认值),但悬空的覆盖配置要告警
|
|
101
|
+
checkDanglingOverrides(pluginsOverrides, [], issues);
|
|
102
|
+
return { nodes, issues };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const discoveredNames = [];
|
|
106
|
+
for (const fileName of files) {
|
|
107
|
+
const scriptPath = path.join(root, fileName);
|
|
108
|
+
let exported;
|
|
109
|
+
try {
|
|
110
|
+
exported = require(scriptPath);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
issues.push({
|
|
113
|
+
level: 'error',
|
|
114
|
+
message: 'plugin "' + fileName + '" failed to load: ' + (err && err.message) + ' (fix the file and restart hexo)'
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof exported === 'function') {
|
|
120
|
+
issues.push({
|
|
121
|
+
level: 'error',
|
|
122
|
+
message:
|
|
123
|
+
'plugin "' + fileName + '" exports a function — plugin files export a node object; for a plain (text, ctx) => text transform use a hooks script entry instead'
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!exported || typeof exported !== 'object') {
|
|
128
|
+
issues.push({
|
|
129
|
+
level: 'error',
|
|
130
|
+
message: 'plugin "' + fileName + '" must export a node object or an array of nodes'
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const entries = Array.isArray(exported) ? exported : [exported];
|
|
136
|
+
entries.forEach((entry, index) => {
|
|
137
|
+
if (!entry || typeof entry !== 'object') {
|
|
138
|
+
issues.push({ level: 'error', message: 'plugin "' + fileName + '" node [' + index + '] must be an object' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const explicitName = typeof entry.name === 'string' && entry.name ? entry.name : '';
|
|
142
|
+
if (Array.isArray(exported) && !explicitName) {
|
|
143
|
+
issues.push({
|
|
144
|
+
level: 'error',
|
|
145
|
+
message: 'plugin "' + fileName + '" node [' + index + '] is missing a name (array exports must name every node)'
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const shortName = explicitName || path.basename(fileName, '.js');
|
|
150
|
+
discoveredNames.push(shortName);
|
|
151
|
+
|
|
152
|
+
const sub = nodeConfig(pluginsOverrides, shortName);
|
|
153
|
+
if (!isNodeEnabled(entry, sub)) return;
|
|
154
|
+
|
|
155
|
+
const placed = resolvePlacement(
|
|
156
|
+
{
|
|
157
|
+
stage: entry.stage,
|
|
158
|
+
slot: sub.slot !== undefined ? sub.slot : entry.slot,
|
|
159
|
+
priority: sub.priority !== undefined ? sub.priority : entry.priority
|
|
160
|
+
},
|
|
161
|
+
'plugin'
|
|
162
|
+
);
|
|
163
|
+
if (placed.error) {
|
|
164
|
+
issues.push({ level: 'error', message: 'plugin "' + fileName + '" node "' + shortName + '" has ' + placed.error });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// 注册期预检 convert/replace 的合法性;编译结果丢弃,执行时按最新声明重新解析
|
|
168
|
+
const converted = resolveConvert(Object.assign({}, entry, { stage: placed.placement.stage }));
|
|
169
|
+
if (converted.error) {
|
|
170
|
+
issues.push({ level: 'error', message: 'plugin "' + fileName + '" node "' + shortName + '" ' + converted.error });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
nodes.push(buildPluginNode(entry, { fileName, scriptPath, shortName, sub, placement: placed.placement }));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
checkDanglingOverrides(pluginsOverrides, discoveredNames, issues);
|
|
179
|
+
|
|
180
|
+
return { nodes, issues };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { loadPluginDir, _internal: { listPluginFiles } };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const { normalizePresetEntry, nodeConfig, isNodeEnabled } = require('../config');
|
|
6
|
+
const { resolvePlacement, resolveConvert } = require('../node-contract');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* preset loader —— "插件的插件"。一个 preset 是一组打包好的 node:
|
|
10
|
+
*
|
|
11
|
+
* module.exports = {
|
|
12
|
+
* name: 'obsidian',
|
|
13
|
+
* nodes: [ ...node对象(与统一契约同形,name 用短名)... ],
|
|
14
|
+
* init(hexo) {} // 可选:一次性副作用(如注册缓存失效 filter)
|
|
15
|
+
* };
|
|
16
|
+
*
|
|
17
|
+
* 配置:
|
|
18
|
+
* text_pipeline:
|
|
19
|
+
* presets:
|
|
20
|
+
* - obsidian # 内置(lib/presets/<name>)或 npm 包名
|
|
21
|
+
* - ./pipeline/my-preset # 站点本地目录/文件(相对 Hexo 根目录)
|
|
22
|
+
* - name: obsidian # 完整形态:带 preset 级配置
|
|
23
|
+
* config:
|
|
24
|
+
* domain_prefix: ''
|
|
25
|
+
* callout: { enable: true, priority: 15 } # node 级覆盖:enable / slot / priority / 其他子配置
|
|
26
|
+
*
|
|
27
|
+
* node 的 enable 解析顺序:用户配置 > node.enabledByDefault > 默认开。
|
|
28
|
+
*/
|
|
29
|
+
function resolvePresetModule(name, baseDir) {
|
|
30
|
+
if (name.startsWith('.') || path.isAbsolute(name)) {
|
|
31
|
+
return require(path.resolve(baseDir || process.cwd(), name));
|
|
32
|
+
}
|
|
33
|
+
const builtIn = path.join(__dirname, '..', '..', 'presets', name);
|
|
34
|
+
if (fs.existsSync(builtIn) || fs.existsSync(builtIn + '.js')) {
|
|
35
|
+
return require(builtIn);
|
|
36
|
+
}
|
|
37
|
+
return require(name); // npm 包
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function wrapPresetNode(node, presetName, presetConfig) {
|
|
41
|
+
const sub = nodeConfig(presetConfig, node.name);
|
|
42
|
+
// 用户配置覆盖 node 自带声明;默认值与校验和其他注册路径同一套(node-contract.js)
|
|
43
|
+
const resolved = resolvePlacement(
|
|
44
|
+
{
|
|
45
|
+
stage: node.stage,
|
|
46
|
+
slot: sub.slot !== undefined ? sub.slot : node.slot,
|
|
47
|
+
priority: sub.priority !== undefined ? sub.priority : node.priority
|
|
48
|
+
},
|
|
49
|
+
'preset'
|
|
50
|
+
);
|
|
51
|
+
if (resolved.error) {
|
|
52
|
+
return { error: 'preset node "' + presetName + ':' + node.name + '" has ' + resolved.error };
|
|
53
|
+
}
|
|
54
|
+
const converted = resolveConvert(node);
|
|
55
|
+
if (converted.error) {
|
|
56
|
+
return { error: 'preset node "' + presetName + ':' + node.name + '" ' + converted.error };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
node: Object.assign({}, node, {
|
|
60
|
+
name: presetName + ':' + node.name,
|
|
61
|
+
shortName: node.name,
|
|
62
|
+
origin: 'preset:' + presetName,
|
|
63
|
+
stage: resolved.placement.stage,
|
|
64
|
+
slot: resolved.placement.slot,
|
|
65
|
+
priority: resolved.placement.priority,
|
|
66
|
+
convert: converted.convert,
|
|
67
|
+
config: sub,
|
|
68
|
+
presetConfig
|
|
69
|
+
})
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadPresets(rawPresets, baseDir) {
|
|
74
|
+
const nodes = [];
|
|
75
|
+
const inits = [];
|
|
76
|
+
const issues = [];
|
|
77
|
+
|
|
78
|
+
(rawPresets || []).forEach((rawEntry, index) => {
|
|
79
|
+
const entry = normalizePresetEntry(rawEntry);
|
|
80
|
+
if (!entry || !entry.name) {
|
|
81
|
+
issues.push({ level: 'error', message: 'presets[' + index + '] must be a name string or { name, config }' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let mod;
|
|
86
|
+
try {
|
|
87
|
+
mod = resolvePresetModule(entry.name, baseDir);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
issues.push({
|
|
90
|
+
level: 'error',
|
|
91
|
+
message: 'preset "' + entry.name + '" failed to load: ' + (err && err.message)
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const presetName = (mod && mod.name) || entry.name;
|
|
97
|
+
const presetNodes = Array.isArray(mod) ? mod : mod && Array.isArray(mod.nodes) ? mod.nodes : null;
|
|
98
|
+
if (!presetNodes) {
|
|
99
|
+
issues.push({
|
|
100
|
+
level: 'error',
|
|
101
|
+
message: 'preset "' + entry.name + '" must export { nodes: [...] } or an array of nodes'
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const node of presetNodes) {
|
|
107
|
+
if (!node || typeof node.name !== 'string') {
|
|
108
|
+
issues.push({
|
|
109
|
+
level: 'error',
|
|
110
|
+
message: 'preset "' + presetName + '" contains an invalid node (need { name, stage, convert | replace })'
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!isNodeEnabled(node, nodeConfig(entry.config, node.name))) continue;
|
|
115
|
+
const wrapped = wrapPresetNode(node, presetName, entry.config);
|
|
116
|
+
if (wrapped.error) {
|
|
117
|
+
issues.push({ level: 'error', message: wrapped.error });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
nodes.push(wrapped.node);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (mod && typeof mod.init === 'function') {
|
|
124
|
+
inits.push({ name: presetName, init: mod.init });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return { nodes, inits, issues };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { loadPresets, _internal: { resolvePresetModule, wrapPresetNode } };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* script hook → node:本地 JS 文件,module.exports = (text, ctx) => text。
|
|
7
|
+
* 相对 Hexo 根目录解析;每次执行重新 require —— 即改即用,hexo server 下
|
|
8
|
+
* 改完脚本下一次渲染就生效,不用重启。
|
|
9
|
+
*/
|
|
10
|
+
function resolveScriptPath(scriptPath, baseDir) {
|
|
11
|
+
return path.resolve(baseDir || process.cwd(), scriptPath);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 精确失效脚本及其本地依赖树(不碰 node_modules):
|
|
16
|
+
* 脚本 require 的辅助模块改了也要即改即用,但不能误伤无关缓存。
|
|
17
|
+
*/
|
|
18
|
+
function invalidateModuleTree(modulePath, seen = new Set()) {
|
|
19
|
+
const cached = require.cache[modulePath];
|
|
20
|
+
if (!cached || seen.has(modulePath)) return;
|
|
21
|
+
seen.add(modulePath);
|
|
22
|
+
for (const child of cached.children) {
|
|
23
|
+
if (!child.filename.includes(path.sep + 'node_modules' + path.sep)) {
|
|
24
|
+
invalidateModuleTree(child.filename, seen);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
delete require.cache[modulePath];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createScriptNode(hook, baseDir) {
|
|
31
|
+
const resolved = resolveScriptPath(hook.script, baseDir);
|
|
32
|
+
return {
|
|
33
|
+
name: 'hook:' + hook.name,
|
|
34
|
+
stage: hook.stage,
|
|
35
|
+
slot: hook.slot,
|
|
36
|
+
priority: hook.priority,
|
|
37
|
+
origin: 'hook:script',
|
|
38
|
+
scriptPath: resolved,
|
|
39
|
+
convert(content, ctx) {
|
|
40
|
+
invalidateModuleTree(resolved);
|
|
41
|
+
const fn = require(resolved);
|
|
42
|
+
if (typeof fn !== 'function') {
|
|
43
|
+
throw new Error('script must export a function (text, ctx) => text');
|
|
44
|
+
}
|
|
45
|
+
return fn(content, ctx);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { createScriptNode, resolveScriptPath, invalidateModuleTree, _internal: { invalidateModuleTree } };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Markdown 阶段的代码保护工具:converter 在 before_post_render 做行内替换时,
|
|
5
|
+
* 用 replaceOutsideCode 包住 replacer,避免改写 fenced code / inline code 里的内容。
|
|
6
|
+
* HTML 阶段(after_post_render)不需要本模块——代码已渲染为 <pre>/<code>。
|
|
7
|
+
*/
|
|
8
|
+
/** 把单行拆成 { isCode, text } 片段序列,isCode 为 true 的片段是行内代码(含反引号)。 */
|
|
9
|
+
function segmentInlineCode(line) {
|
|
10
|
+
const segments = [];
|
|
11
|
+
let cursor = 0;
|
|
12
|
+
|
|
13
|
+
while (cursor < line.length) {
|
|
14
|
+
const open = line.indexOf('`', cursor);
|
|
15
|
+
if (open === -1) {
|
|
16
|
+
segments.push({ isCode: false, text: line.slice(cursor) });
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (open > cursor) {
|
|
21
|
+
segments.push({ isCode: false, text: line.slice(cursor, open) });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let ticks = 1;
|
|
25
|
+
while (open + ticks < line.length && line[open + ticks] === '`') {
|
|
26
|
+
ticks += 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const closingToken = '`'.repeat(ticks);
|
|
30
|
+
const close = line.indexOf(closingToken, open + ticks);
|
|
31
|
+
if (close === -1) {
|
|
32
|
+
segments.push({ isCode: false, text: line.slice(open) });
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
segments.push({ isCode: true, text: line.slice(open, close + ticks) });
|
|
37
|
+
cursor = close + ticks;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return segments;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function replaceOutsideInlineCode(line, replacer) {
|
|
44
|
+
return segmentInlineCode(line)
|
|
45
|
+
.map((seg) => (seg.isCode ? seg.text : replacer(seg.text)))
|
|
46
|
+
.join('');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function replaceOutsideCode(content, replacer) {
|
|
50
|
+
const lines = content.split('\n');
|
|
51
|
+
let inFence = false;
|
|
52
|
+
let fenceChar = '';
|
|
53
|
+
let fenceLen = 0;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
56
|
+
const line = lines[i];
|
|
57
|
+
const match = line.match(/^ {0,3}([`~]{3,})/);
|
|
58
|
+
|
|
59
|
+
if (match) {
|
|
60
|
+
const token = match[1];
|
|
61
|
+
const marker = token[0];
|
|
62
|
+
const length = token.length;
|
|
63
|
+
|
|
64
|
+
if (!inFence) {
|
|
65
|
+
inFence = true;
|
|
66
|
+
fenceChar = marker;
|
|
67
|
+
fenceLen = length;
|
|
68
|
+
} else if (marker === fenceChar && length >= fenceLen) {
|
|
69
|
+
inFence = false;
|
|
70
|
+
fenceChar = '';
|
|
71
|
+
fenceLen = 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!inFence) {
|
|
78
|
+
lines[i] = replaceOutsideInlineCode(line, replacer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
segmentInlineCode,
|
|
87
|
+
replaceOutsideInlineCode,
|
|
88
|
+
replaceOutsideCode
|
|
89
|
+
};
|