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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/README.zh-CN.md +191 -0
  4. package/index.js +16 -0
  5. package/lib/core/api.js +102 -0
  6. package/lib/core/checker/runtime.js +71 -0
  7. package/lib/core/checker/static.js +168 -0
  8. package/lib/core/config.js +74 -0
  9. package/lib/core/console/pipeline.js +203 -0
  10. package/lib/core/engine.js +207 -0
  11. package/lib/core/loaders/command.js +56 -0
  12. package/lib/core/loaders/hooks.js +83 -0
  13. package/lib/core/loaders/plugin-dir.js +183 -0
  14. package/lib/core/loaders/preset.js +131 -0
  15. package/lib/core/loaders/script.js +50 -0
  16. package/lib/core/markdown-guard.js +89 -0
  17. package/lib/core/node-contract.js +133 -0
  18. package/lib/core/stages.js +53 -0
  19. package/lib/core/tap.js +73 -0
  20. package/lib/presets/obsidian/converters/_template/index.js +44 -0
  21. package/lib/presets/obsidian/converters/blockid/index.js +25 -0
  22. package/lib/presets/obsidian/converters/callout/index.js +84 -0
  23. package/lib/presets/obsidian/converters/callout/parse.js +60 -0
  24. package/lib/presets/obsidian/converters/callout/render.js +40 -0
  25. package/lib/presets/obsidian/converters/callout/styles.js +20 -0
  26. package/lib/presets/obsidian/converters/comment/index.js +106 -0
  27. package/lib/presets/obsidian/converters/embed/index.js +79 -0
  28. package/lib/presets/obsidian/converters/highlight/index.js +23 -0
  29. package/lib/presets/obsidian/converters/mdlink/index.js +63 -0
  30. package/lib/presets/obsidian/converters/mermaid/index.js +102 -0
  31. package/lib/presets/obsidian/converters/wikilink/index.js +65 -0
  32. package/lib/presets/obsidian/index.js +37 -0
  33. package/lib/presets/obsidian/post-index.js +158 -0
  34. package/package.json +41 -0
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { STAGES } = require('../stages');
5
+
6
+ const KNOWN_TOP_KEYS = ['enable', 'debug', 'strict', 'inject_css', 'inject_js', 'presets', 'hooks', 'plugins', 'plugins_dir', 'tap'];
7
+ const KNOWN_TAP_KEYS = ['enable', 'match', 'dir'];
8
+ const KNOWN_HOOK_KEYS = ['command', 'script', 'stage', 'slot', 'priority', 'name', 'timeout', 'enable', 'match'];
9
+
10
+ function editDistance(a, b) {
11
+ const rows = a.length + 1;
12
+ const cols = b.length + 1;
13
+ let prev = Array.from({ length: cols }, (_, j) => j);
14
+
15
+ for (let i = 1; i < rows; i += 1) {
16
+ const current = [i];
17
+ for (let j = 1; j < cols; j += 1) {
18
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
19
+ current.push(Math.min(prev[j] + 1, current[j - 1] + 1, prev[j - 1] + cost));
20
+ }
21
+ prev = current;
22
+ }
23
+ return prev[cols - 1];
24
+ }
25
+
26
+ function suggest(key, candidates) {
27
+ const lower = key.toLowerCase();
28
+ let best = null;
29
+ let bestDistance = 3; // 编辑距离 <= 2 才算像
30
+
31
+ for (const candidate of candidates) {
32
+ const distance = editDistance(lower, candidate);
33
+ if (distance < bestDistance) {
34
+ bestDistance = distance;
35
+ best = candidate;
36
+ }
37
+ }
38
+ return best ? ' (did you mean "' + best + '"?)' : '';
39
+ }
40
+
41
+ /**
42
+ * 注册期静态检查(兜底第一道防线):在任何文章被处理之前发现配置问题。
43
+ * 返回 issue 列表 { level: 'error' | 'warn', message };
44
+ * strict 模式下 error 级会让构建失败,否则全部降为日志告警。
45
+ */
46
+ function checkPluginConfig(rawConfig) {
47
+ const issues = [];
48
+ if (!rawConfig || typeof rawConfig !== 'object') return issues;
49
+
50
+ for (const key of Object.keys(rawConfig)) {
51
+ if (!KNOWN_TOP_KEYS.includes(key)) {
52
+ issues.push({
53
+ level: 'warn',
54
+ message: 'unknown config key "text_pipeline.' + key + '"' + suggest(key, KNOWN_TOP_KEYS)
55
+ });
56
+ }
57
+ }
58
+ if ('presets' in rawConfig && !Array.isArray(rawConfig.presets)) {
59
+ issues.push({ level: 'error', message: 'text_pipeline.presets must be a list' });
60
+ }
61
+ if ('hooks' in rawConfig && !Array.isArray(rawConfig.hooks)) {
62
+ issues.push({ level: 'error', message: 'text_pipeline.hooks must be a list' });
63
+ }
64
+ if ('plugins_dir' in rawConfig && rawConfig.plugins_dir !== false && typeof rawConfig.plugins_dir !== 'string') {
65
+ issues.push({ level: 'error', message: 'text_pipeline.plugins_dir must be a directory name string or false' });
66
+ }
67
+ if (
68
+ 'plugins' in rawConfig &&
69
+ (!rawConfig.plugins || typeof rawConfig.plugins !== 'object' || Array.isArray(rawConfig.plugins))
70
+ ) {
71
+ issues.push({ level: 'error', message: 'text_pipeline.plugins must be a map of <plugin name> -> overrides' });
72
+ } else if (rawConfig.plugins && typeof rawConfig.plugins === 'object') {
73
+ for (const [name, sub] of Object.entries(rawConfig.plugins)) {
74
+ if (!sub || typeof sub !== 'object' || Array.isArray(sub)) {
75
+ // `arrow: false` 之类的写法意图明显但会被静默忽略——指条明路
76
+ issues.push({
77
+ level: 'warn',
78
+ message: 'text_pipeline.plugins.' + name + ' must be an object (to disable a plugin use { enable: false })'
79
+ });
80
+ }
81
+ }
82
+ }
83
+ for (const [index, entry] of (Array.isArray(rawConfig.hooks) ? rawConfig.hooks : []).entries()) {
84
+ if (!entry || typeof entry !== 'object') continue;
85
+ for (const key of Object.keys(entry)) {
86
+ if (!KNOWN_HOOK_KEYS.includes(key)) {
87
+ issues.push({
88
+ level: 'warn',
89
+ message: 'unknown key "' + key + '" in hooks[' + index + ']' + suggest(key, KNOWN_HOOK_KEYS)
90
+ });
91
+ }
92
+ }
93
+ }
94
+ if (rawConfig.tap && typeof rawConfig.tap === 'object') {
95
+ for (const key of Object.keys(rawConfig.tap)) {
96
+ if (!KNOWN_TAP_KEYS.includes(key)) {
97
+ issues.push({
98
+ level: 'warn',
99
+ message: 'unknown key "' + key + '" in text_pipeline.tap' + suggest(key, KNOWN_TAP_KEYS)
100
+ });
101
+ }
102
+ }
103
+ }
104
+ return issues;
105
+ }
106
+
107
+ /**
108
+ * node 级检查:stage 合法性、重名、priority 类型、script 文件可达性(advisory:
109
+ * 文件可以等会儿再创建——即改即用,所以只 warn 不拦截)、同 stage 同 priority 的顺序歧义提示。
110
+ */
111
+ function checkNodes(nodes) {
112
+ const issues = [];
113
+ const seen = new Map();
114
+
115
+ for (const node of nodes) {
116
+ if (!STAGES[node.stage]) {
117
+ issues.push({
118
+ level: 'error',
119
+ message: '"' + node.name + '" targets unknown stage "' + node.stage + '" (valid: ' + Object.keys(STAGES).join(', ') + ')'
120
+ });
121
+ }
122
+ if (seen.has(node.name)) {
123
+ issues.push({
124
+ level: 'warn',
125
+ message: 'duplicate node name "' + node.name + '" — both will run; rename one to tell them apart in logs'
126
+ });
127
+ }
128
+ seen.set(node.name, node);
129
+
130
+ if (node.priority !== undefined && !Number.isFinite(node.priority)) {
131
+ issues.push({ level: 'error', message: '"' + node.name + '" has a non-numeric priority' });
132
+ }
133
+ if (node.slot !== undefined && node.slot !== 'early' && node.slot !== 'late') {
134
+ issues.push({ level: 'error', message: '"' + node.name + '" has invalid slot "' + node.slot + '" (use early or late)' });
135
+ }
136
+ if (node.scriptPath && !fs.existsSync(node.scriptPath)) {
137
+ issues.push({
138
+ level: 'warn',
139
+ message: '"' + node.name + '" script not found yet: ' + node.scriptPath + ' (will be retried on each render)'
140
+ });
141
+ }
142
+ }
143
+
144
+ // 顺序歧义提示:不同来源(不同 preset / hook)的 node 落在同 stage 同 priority 时,
145
+ // 相对顺序只由注册顺序决定——提示用户用 priority 显式表达意图。
146
+ // 同一来源内部共享 priority 是常态(preset 自己声明的顺序),不提示。
147
+ const byStagePriority = new Map();
148
+ for (const node of nodes) {
149
+ const key = node.stage + '.' + (node.slot || 'early') + '@' + (Number.isFinite(node.priority) ? node.priority : 10);
150
+ if (!byStagePriority.has(key)) byStagePriority.set(key, []);
151
+ byStagePriority.get(key).push(node);
152
+ }
153
+ for (const [key, group] of byStagePriority) {
154
+ const origins = new Set(group.map((node) => node.origin || 'unknown'));
155
+ if (origins.size > 1) {
156
+ // info 级:这是常见且行为良定义的情况(preset 先于 hook),只在 doctor 报告里展示,
157
+ // 不在构建日志刷 warn——否则最普通的配置都会告警,用户会学会无视警告
158
+ issues.push({
159
+ level: 'info',
160
+ message: 'nodes from different sources share priority at ' + key + ' (' + group.map((n) => n.name).join(' → ') + '); order follows registration — set explicit priorities to pin it'
161
+ });
162
+ }
163
+ }
164
+
165
+ return issues;
166
+ }
167
+
168
+ module.exports = { checkPluginConfig, checkNodes, suggest };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ function normalizeBase(base) {
4
+ const raw = String(base || '').trim();
5
+ if (!raw) return '';
6
+ return raw.replace(/\/+$/, '');
7
+ }
8
+
9
+ /**
10
+ * 归一化 hexo.config.text_pipeline。
11
+ * 顶层开关 + 全局选项 + presets(插件的插件)+ hooks(用户脚本/命令)。
12
+ * 字段合法性由 checker/static 负责,这里只做形状归一,未知字段不丢(raw 保留给 checker)。
13
+ */
14
+ const DEFAULT_PLUGINS_DIR = 'text-pipeline';
15
+
16
+ function normalizeConfig(hexoConfig) {
17
+ const raw = (hexoConfig && hexoConfig.text_pipeline) || {};
18
+ return {
19
+ enable: raw.enable !== false,
20
+ debug: Boolean(raw.debug),
21
+ strict: Boolean(raw.strict),
22
+ injectCss: raw.inject_css !== false,
23
+ injectJs: raw.inject_js !== false,
24
+ presets: Array.isArray(raw.presets) ? raw.presets : [],
25
+ hooks: Array.isArray(raw.hooks) ? raw.hooks : [],
26
+ // 单文件插件目录:false 关闭自动发现,字符串改目录名,缺省用约定值
27
+ pluginsDir:
28
+ raw.plugins_dir === false
29
+ ? false
30
+ : typeof raw.plugins_dir === 'string' && raw.plugins_dir.trim()
31
+ ? raw.plugins_dir.trim()
32
+ : DEFAULT_PLUGINS_DIR,
33
+ plugins: raw.plugins && typeof raw.plugins === 'object' && !Array.isArray(raw.plugins) ? raw.plugins : {},
34
+ tap: raw.tap && typeof raw.tap === 'object' ? raw.tap : { enable: false },
35
+ raw
36
+ };
37
+ }
38
+
39
+ /**
40
+ * presets 条目两种写法:'obsidian' 或 { name: 'obsidian', config: {...} }。
41
+ */
42
+ function normalizePresetEntry(entry) {
43
+ if (typeof entry === 'string') {
44
+ return { name: entry.trim(), config: {} };
45
+ }
46
+ if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
47
+ return {
48
+ name: entry.name.trim(),
49
+ config: entry.config && typeof entry.config === 'object' ? entry.config : {}
50
+ };
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /** preset config 里某个 node 的子配置(presets[].config.<shortName>)。 */
56
+ function nodeConfig(presetConfig, shortName) {
57
+ const sub = presetConfig && presetConfig[shortName];
58
+ return sub && typeof sub === 'object' ? sub : {};
59
+ }
60
+
61
+ /** node 的 enable 解析顺序:用户子配置 > node.enabledByDefault > 默认开。preset 与单文件插件共用。 */
62
+ function isNodeEnabled(node, sub) {
63
+ if (typeof sub.enable === 'boolean') return sub.enable;
64
+ return node.enabledByDefault !== false;
65
+ }
66
+
67
+ module.exports = {
68
+ normalizeBase,
69
+ normalizeConfig,
70
+ normalizePresetEntry,
71
+ nodeConfig,
72
+ isNodeEnabled,
73
+ DEFAULT_PLUGINS_DIR
74
+ };
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { STAGES } = require('../stages');
6
+
7
+ const DIFF_MAX_LINES = 40;
8
+ const DIFF_DP_LIMIT = 2000;
9
+
10
+ /**
11
+ * `hexo pipeline` 诊断命令(兜底第三道防线):上线前把整条管线摊开看。
12
+ *
13
+ * 默认:打印每个 stage 的 node 执行顺序(priority + 来源)和全部静态检查结果。
14
+ * --dry-run <file>:读样例 markdown 文件,剥 frontmatter 后逐 node 跑
15
+ * before_post_render 链,打印每个 node 改了什么(行级 diff)——
16
+ * 两个 node 语义上互相打架时,在这里直接看见。
17
+ */
18
+ function formatReport(state) {
19
+ const lines = [];
20
+ lines.push('text-pipeline');
21
+ lines.push('');
22
+
23
+ for (const stageName of Object.keys(STAGES)) {
24
+ const slots = [
25
+ ['early', state.registry.forStage(stageName, 'early')],
26
+ ['late', state.registry.forStage(stageName, 'late')]
27
+ ];
28
+ if (!slots[0][1].length && !slots[1][1].length) continue;
29
+
30
+ lines.push(stageName + ' (' + STAGES[stageName].description + ')');
31
+ for (const [slot, nodes] of slots) {
32
+ if (!nodes.length) continue;
33
+ lines.push(' ' + slot + (slot === 'late' ? ' (after hexo internals & other plugins)' : ' (before hexo internals & other plugins)'));
34
+ nodes.forEach((node, index) => {
35
+ const priority = Number.isFinite(node.priority) ? node.priority : 10;
36
+ lines.push(' ' + (index + 1) + '. [' + priority + '] ' + node.name + ' <' + (node.origin || 'unknown') + '>');
37
+ });
38
+ }
39
+ lines.push('');
40
+ }
41
+ if (lines.length === 2) {
42
+ lines.push('(no nodes registered on any stage)');
43
+ lines.push('');
44
+ }
45
+
46
+ if (state.issues.length) {
47
+ lines.push('checks:');
48
+ for (const issue of state.issues) {
49
+ lines.push(' ' + issue.level.toUpperCase() + ': ' + issue.message);
50
+ }
51
+ } else {
52
+ lines.push('checks: all clear');
53
+ }
54
+
55
+ return lines.join('\n');
56
+ }
57
+
58
+ function stripFrontMatter(text) {
59
+ const match = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
60
+ return match ? text.slice(match[0].length) : text;
61
+ }
62
+
63
+ /** 行级 LCS diff,超大文件退化为只报行数变化。返回展示行数组。 */
64
+ function lineDiff(before, after) {
65
+ const a = before.split('\n');
66
+ const b = after.split('\n');
67
+
68
+ if (a.length > DIFF_DP_LIMIT || b.length > DIFF_DP_LIMIT) {
69
+ return [' (file too large for a diff: ' + a.length + ' -> ' + b.length + ' lines)'];
70
+ }
71
+
72
+ // 标准 LCS DP
73
+ const dp = Array.from({ length: a.length + 1 }, () => new Uint16Array(b.length + 1));
74
+ for (let i = a.length - 1; i >= 0; i -= 1) {
75
+ for (let j = b.length - 1; j >= 0; j -= 1) {
76
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
77
+ }
78
+ }
79
+
80
+ const out = [];
81
+ let i = 0;
82
+ let j = 0;
83
+ let truncated = false;
84
+ while (i < a.length && j < b.length) {
85
+ if (a[i] === b[j]) {
86
+ i += 1;
87
+ j += 1;
88
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
89
+ out.push(' - ' + a[i]);
90
+ i += 1;
91
+ } else {
92
+ out.push(' + ' + b[j]);
93
+ j += 1;
94
+ }
95
+ if (out.length >= DIFF_MAX_LINES) {
96
+ truncated = true;
97
+ break;
98
+ }
99
+ }
100
+ if (!truncated) {
101
+ while (i < a.length && out.length < DIFF_MAX_LINES) out.push(' - ' + a[i++]);
102
+ while (j < b.length && out.length < DIFF_MAX_LINES) out.push(' + ' + b[j++]);
103
+ if (i < a.length || j < b.length) truncated = true;
104
+ }
105
+ if (truncated) out.push(' … (diff truncated at ' + DIFF_MAX_LINES + ' lines)');
106
+ return out;
107
+ }
108
+
109
+ function formatDryRun(hexo, state, filePath) {
110
+ const resolved = path.resolve(hexo.base_dir || process.cwd(), filePath);
111
+ const raw = fs.readFileSync(resolved, 'utf8');
112
+ let current = stripFrontMatter(raw);
113
+
114
+ const lines = ['dry-run: ' + filePath + ' (stage: before_post_render)', ''];
115
+ const post = { source: filePath, path: '', title: path.basename(filePath, path.extname(filePath)) };
116
+
117
+ // early → late 串行;真实渲染中两者之间还有 hexo 内置 filter 和其他插件,这里不模拟
118
+ const chain = [
119
+ ...state.registry.forStage('before_post_render', 'early'),
120
+ ...state.registry.forStage('before_post_render', 'late')
121
+ ];
122
+
123
+ for (const node of chain) {
124
+ const warnings = [];
125
+ // 与 engine 的 ctx 构建保持一致:config / presetConfig 仅 node 自带时出现
126
+ const ctx = {
127
+ hexo,
128
+ post,
129
+ stage: 'before_post_render',
130
+ pluginConfig: state.pluginConfig,
131
+ utils: hexo.textPipeline && hexo.textPipeline.utils,
132
+ log: { warn: (m) => warnings.push(m), debug() {} }
133
+ };
134
+ if (node.config !== undefined) ctx.config = node.config;
135
+ if (node.presetConfig !== undefined) ctx.presetConfig = node.presetConfig;
136
+
137
+ if (typeof node.test === 'function' && !node.test(current)) {
138
+ lines.push('· ' + node.name + ': skipped (test)');
139
+ continue;
140
+ }
141
+
142
+ let output;
143
+ try {
144
+ output = node.convert(current, ctx);
145
+ if (typeof output !== 'string') {
146
+ throw new Error('returned ' + typeof output + ' instead of a string');
147
+ }
148
+ } catch (err) {
149
+ lines.push('✗ ' + node.name + ': FAILED — ' + (err && err.message) + ' (text passed through unchanged)');
150
+ continue;
151
+ }
152
+
153
+ for (const warning of warnings) {
154
+ lines.push('! ' + node.name + ': ' + warning);
155
+ }
156
+
157
+ if (output === current) {
158
+ lines.push('· ' + node.name + ': no change');
159
+ } else {
160
+ lines.push('✓ ' + node.name + ': changed (' + current.length + ' -> ' + output.length + ' chars)');
161
+ lines.push(...lineDiff(current, output));
162
+ current = output;
163
+ }
164
+ }
165
+
166
+ lines.push('');
167
+ lines.push('note: only before_post_render runs in a dry-run; later stages need rendered HTML.');
168
+ lines.push('note: hexo internal filters and other plugins (which run between the early and late slots) are not simulated.');
169
+ return lines.join('\n');
170
+ }
171
+
172
+ function registerDoctorCommand(hexo) {
173
+ if (!hexo.extend.console || typeof hexo.extend.console.register !== 'function') {
174
+ return;
175
+ }
176
+ hexo.extend.console.register(
177
+ 'pipeline',
178
+ 'Inspect the text pipeline: node order, check results; --dry-run <file> traces a sample file node by node',
179
+ {
180
+ options: [{ name: '--dry-run <file>', desc: 'run the before_post_render chain on a markdown file and show each node\'s diff' }]
181
+ },
182
+ function pipelineCommand(args) {
183
+ const state = this._textPipeline;
184
+ if (!state) {
185
+ console.log('text-pipeline is disabled (text_pipeline.enable: false) or not registered');
186
+ return;
187
+ }
188
+
189
+ const dryRunTarget = args && (args['dry-run'] || args.d);
190
+ if (typeof dryRunTarget === 'string' && dryRunTarget) {
191
+ // 文章索引等 preset 服务依赖数据库,先 load 再跑
192
+ const ready = typeof this.load === 'function' ? this.load() : Promise.resolve();
193
+ return ready.then(() => {
194
+ console.log(formatDryRun(this, state, dryRunTarget));
195
+ });
196
+ }
197
+
198
+ console.log(formatReport(state));
199
+ }
200
+ );
201
+ }
202
+
203
+ module.exports = { registerDoctorCommand, formatReport, formatDryRun, _internal: { lineDiff, stripFrontMatter } };
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ const { normalizeConfig } = require('./config');
4
+ const { STAGES, LATE_FILTER_PRIORITY } = require('./stages');
5
+ const { createRegistry, createPublicApi } = require('./api');
6
+ const { loadPresets } = require('./loaders/preset');
7
+ const { loadHooks } = require('./loaders/hooks');
8
+ const { loadPluginDir } = require('./loaders/plugin-dir');
9
+ const { checkPluginConfig, checkNodes } = require('./checker/static');
10
+ const { createRuntimeGuard } = require('./checker/runtime');
11
+ const { createTap } = require('./tap');
12
+ const { registerDoctorCommand } = require('./console/pipeline');
13
+
14
+ const registeredHexoInstances = new WeakSet();
15
+
16
+ function makeLogger(hexo, debug, name) {
17
+ const prefix = '[text-pipeline' + (name ? ':' + name : '') + '] ';
18
+ const hexoLog = hexo.log || {};
19
+ return {
20
+ debug(message) {
21
+ if (!debug || typeof hexoLog.info !== 'function') return;
22
+ hexoLog.info(prefix + message);
23
+ },
24
+ warn(message) {
25
+ if (typeof hexoLog.warn === 'function') {
26
+ hexoLog.warn(prefix + message);
27
+ }
28
+ }
29
+ };
30
+ }
31
+
32
+ /**
33
+ * 唯一调度者。注册流程(兜底链路):
34
+ * 1. 静态检查配置(checker/static)—— strict 下 error 直接让构建失败
35
+ * 2. 加载 presets(插件的插件)、hooks(用户脚本/命令)与单文件插件目录
36
+ * (plugin-dir,零配置自动发现)为统一 node
37
+ * 3. node 级静态检查(stage、重名、priority、脚本可达性、顺序歧义)
38
+ * 4. 为 stage 表里每个 stage 注册一个 hexo filter,执行时懒查注册表
39
+ * (所以 hexo.textPipeline.register 在任何时刻注册都生效)
40
+ * 5. 执行期每个 node 过 runtime guard:异常隔离、熔断、输出异常告警
41
+ *
42
+ * 同 stage 内顺序:priority 小者先跑,同级按注册顺序
43
+ * (加载顺序 preset → hook → plugin)。
44
+ */
45
+ function register(hexo) {
46
+ if (!hexo || !hexo.extend || !hexo.extend.filter || typeof hexo.extend.filter.register !== 'function') {
47
+ return;
48
+ }
49
+ if (registeredHexoInstances.has(hexo)) {
50
+ return;
51
+ }
52
+ registeredHexoInstances.add(hexo);
53
+
54
+ registerDoctorCommand(hexo); // 禁用时也注册,`hexo pipeline` 会提示插件未启用
55
+
56
+ const pluginConfig = normalizeConfig(hexo.config);
57
+ if (!pluginConfig.enable) {
58
+ return;
59
+ }
60
+
61
+ const engineLog = makeLogger(hexo, pluginConfig.debug);
62
+ const baseDir = hexo.base_dir;
63
+
64
+ const issues = checkPluginConfig(pluginConfig.raw);
65
+ const presetResult = loadPresets(pluginConfig.presets, baseDir);
66
+ const hookResult = loadHooks(pluginConfig.hooks, baseDir);
67
+ const pluginResult =
68
+ pluginConfig.pluginsDir === false
69
+ ? { nodes: [], issues: [] }
70
+ : loadPluginDir(pluginConfig.pluginsDir, pluginConfig.plugins, baseDir);
71
+ issues.push(...presetResult.issues, ...hookResult.issues, ...pluginResult.issues);
72
+
73
+ const candidates = presetResult.nodes.concat(hookResult.nodes, pluginResult.nodes);
74
+ issues.push(...checkNodes(candidates));
75
+
76
+ if (pluginConfig.strict) {
77
+ const errors = issues.filter((issue) => issue.level === 'error');
78
+ if (errors.length) {
79
+ throw new Error(
80
+ '[text-pipeline] strict mode: config check failed\n - ' + errors.map((e) => e.message).join('\n - ')
81
+ );
82
+ }
83
+ }
84
+ for (const issue of issues) {
85
+ if (issue.level === 'info') {
86
+ engineLog.debug(issue.message); // 仅 debug / `hexo pipeline` 可见
87
+ } else {
88
+ engineLog.warn(issue.level + ': ' + issue.message);
89
+ }
90
+ }
91
+
92
+ const registry = createRegistry();
93
+ for (const node of candidates) {
94
+ if (STAGES[node.stage]) registry.add(node);
95
+ }
96
+
97
+ const guard = createRuntimeGuard({ strict: pluginConfig.strict });
98
+ hexo.extend.filter.register('before_generate', () => {
99
+ guard.reset();
100
+ });
101
+
102
+ const tap = createTap(pluginConfig.tap, baseDir, engineLog);
103
+ if (tap) {
104
+ engineLog.warn('tap is ON — stage snapshots are written to ' + tap.root + ' (debug mode, disable for normal builds)');
105
+ }
106
+
107
+ const canInject = hexo.extend.injector && typeof hexo.extend.injector.register === 'function';
108
+ const injectAssets = (node) => {
109
+ if (!canInject) return;
110
+ if (pluginConfig.injectCss && node.css) {
111
+ hexo.extend.injector.register('head_end', '<style>' + node.css + '</style>');
112
+ }
113
+ if (pluginConfig.injectJs && node.js) {
114
+ const js = typeof node.js === 'function' ? node.js(node.config || {}) : node.js;
115
+ if (js) {
116
+ hexo.extend.injector.register('body_end', '<script>' + js + '</script>');
117
+ }
118
+ }
119
+ };
120
+
121
+ const api = createPublicApi(registry, { onRegister: injectAssets, log: engineLog });
122
+ hexo.textPipeline = api;
123
+ // doctor 命令与测试读取的内部状态
124
+ hexo._textPipeline = { registry, issues, pluginConfig, guard };
125
+
126
+ // target:post 类 stage 是 post 数据对象,string 类是 { path } 等输出元信息。
127
+ // ctx 上按 stage 类型分流成 post / file 两个名字,不再用 ctx.post 装非 post 的东西;
128
+ // config / presetConfig 只在 node 自带时出现(hook 没有,ctx 上也不会有)。
129
+ const runChain = (stageName, slot, text, target) => {
130
+ let current = text;
131
+
132
+ if (tap) tap.capture(stageName + '.' + slot, target, 'input', current);
133
+
134
+ for (const node of registry.forStage(stageName, slot)) {
135
+ if (typeof node.test === 'function' && !node.test(current)) continue;
136
+
137
+ const log = makeLogger(hexo, pluginConfig.debug, node.name);
138
+ const ctx = {
139
+ hexo,
140
+ stage: stageName,
141
+ pluginConfig,
142
+ utils: api.utils,
143
+ log
144
+ };
145
+ if (STAGES[stageName].kind === 'post') {
146
+ ctx.post = target;
147
+ } else {
148
+ ctx.file = target;
149
+ }
150
+ if (node.config !== undefined) ctx.config = node.config;
151
+ if (node.presetConfig !== undefined) ctx.presetConfig = node.presetConfig;
152
+
153
+ const next = guard.run(node, current, ctx);
154
+ if (next !== current) {
155
+ log.debug(stageName + ' applied source=' + ((target && (target.source || target.path)) || 'unknown'));
156
+ if (tap) tap.capture(stageName + '.' + slot, target, node.name, next);
157
+ }
158
+ current = next;
159
+ }
160
+
161
+ return current;
162
+ };
163
+
164
+ // 每个 stage 两个挂点:early 抢在 hexo 内置/其他插件之前,late 在其全部跑完之后
165
+ for (const stageName of Object.keys(STAGES)) {
166
+ const slots = [
167
+ ['early', STAGES[stageName].filterPriority],
168
+ ['late', LATE_FILTER_PRIORITY]
169
+ ];
170
+ for (const [slot, filterPriority] of slots) {
171
+ if (STAGES[stageName].kind === 'post') {
172
+ hexo.extend.filter.register(stageName, (data) => {
173
+ if (!data || typeof data.content !== 'string') {
174
+ return data;
175
+ }
176
+ data.content = runChain(stageName, slot, data.content, data);
177
+ return data;
178
+ }, filterPriority);
179
+ } else {
180
+ hexo.extend.filter.register(stageName, (text, data) => {
181
+ if (typeof text !== 'string') {
182
+ return text;
183
+ }
184
+ return runChain(stageName, slot, text, data || {});
185
+ }, filterPriority);
186
+ }
187
+ }
188
+ }
189
+
190
+ for (const node of registry.all()) {
191
+ injectAssets(node);
192
+ }
193
+
194
+ for (const { name, init } of presetResult.inits) {
195
+ try {
196
+ init(hexo);
197
+ } catch (err) {
198
+ engineLog.warn('preset "' + name + '" init failed: ' + (err && err.message));
199
+ }
200
+ }
201
+ }
202
+
203
+ module.exports = {
204
+ register,
205
+ registeredHexoInstances,
206
+ STAGES
207
+ };