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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sentixxx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ **English** | [简体中文](README.zh-CN.md)
2
+
3
+ # hexo-text-pipeline
4
+
5
+ A general-purpose hooks bus for Hexo's render pipeline.
6
+
7
+ Every text-in/text-out point of Hexo's rendering process is exposed as a stage. You hang things on stages — your own scripts, shell commands, or packaged presets — through plain declarative config. A checker system backstops everything: misconfiguration is caught before the first post renders, and a failing node is skipped, never your build.
8
+
9
+ The design goal: most of what a small Hexo plugin does is "transform some text at some point of the pipeline". That shouldn't require publishing a package, or even config — **writing a plugin is writing one file**. Create `text-pipeline/` at your site root and drop this in:
10
+
11
+ ```js
12
+ // text-pipeline/arrow.js — this is a complete plugin
13
+ module.exports = {
14
+ replace: [[/-->/g, '→']]
15
+ };
16
+ ```
17
+
18
+ It runs on the next `hexo generate`: zero-config discovery, code blocks automatically protected in the markdown stage, logic edits apply on the next render, and a broken plugin is skipped — never your build. Full contract: [docs/PLUGINS.md](docs/PLUGINS.md).
19
+
20
+ ## Stages
21
+
22
+ | Stage | Text flowing through |
23
+ |-------|----------------------|
24
+ | `before_post_render` | per-post **markdown**, before rendering |
25
+ | `after_post_render` | per-post **HTML fragment**, after rendering |
26
+ | `after_render:html` | **full page HTML**, after template rendering |
27
+ | `after_render:css` / `after_render:js` | generated assets |
28
+
29
+ These map 1:1 to [Hexo's filter API](https://hexo.io/api/filter). Non-text filters (`template_locals`, `server_middleware`, …) are deliberately out of scope.
30
+
31
+ ## Four ways to hang a node
32
+
33
+ **1. Single-file plugin (the default answer)** — every `.js` file in the `text-pipeline/` directory mounts automatically. Export a node object in the unified contract shape (`name` defaults to the filename); a `replace` rule list is the declarative form of `convert`; `_config.yml` can override `enable` / `slot` / `priority` per plugin name, remaining sub-config reaches the plugin as `ctx.config`. Details: [docs/PLUGINS.md](docs/PLUGINS.md).
34
+
35
+ ```js
36
+ // text-pipeline/ruby.js
37
+ module.exports = {
38
+ stage: 'before_post_render',
39
+ match: '\\{ruby',
40
+ convert: (text, ctx) => text.replace(/\{ruby (.+?)\}/g, '<ruby>$1</ruby>')
41
+ };
42
+ ```
43
+
44
+ **2. Local script (hook)** — `module.exports = (text, ctx) => text`, resolved against the Hexo root, re-required on every run. Edit the file, the next render picks it up. Use it when you want the plain-function shape with placement living in YAML.
45
+
46
+ ```yaml
47
+ hooks:
48
+ - script: scripts/minify.js
49
+ stage: after_render:html
50
+ ```
51
+
52
+ **3. External command** — content on stdin, transformed content on stdout. Any language. Context via env vars: `HTP_STAGE` / `HTP_SLOT` always; `HTP_POST_SOURCE` / `HTP_POST_PATH` / `HTP_POST_TITLE` on post stages, `HTP_FILE_PATH` on string stages.
53
+
54
+ ```yaml
55
+ hooks:
56
+ - command: python scripts/furigana.py
57
+ stage: before_post_render # default stage
58
+ name: furigana # optional, for logs
59
+ priority: 20 # optional, default 10, lower runs first
60
+ timeout: 10000 # optional, ms
61
+ match: '\\{furigana' # optional regex: skip the hook (and the spawn) when the text doesn't match
62
+ slot: late # optional: late (default, sees the stage's final text) | early (raw text)
63
+ ```
64
+
65
+ **4. Programmatic** — other plugins (or a script in your site's `scripts/` dir) can register nodes directly:
66
+
67
+ ```js
68
+ hexo.textPipeline.register({
69
+ name: 'exclaim',
70
+ stage: 'before_post_render',
71
+ priority: 5,
72
+ convert: (text, ctx) => text + '!'
73
+ });
74
+ ```
75
+
76
+ Registration shares the same defaults and validation as hooks (slot defaults to `late`, `match` regex pre-checks work too). `ctx` is `{ hexo, stage, pluginConfig, utils, log }`, plus `ctx.post` on post stages / `ctx.file` on string stages; `ctx.utils` ships `replaceOutsideCode` / `segmentInlineCode` for safely skipping code blocks in the markdown stage.
77
+
78
+ ### Execution order: two slots per stage
79
+
80
+ Each stage has two mounting slots, registered around Hexo's own filters:
81
+
82
+ ```
83
+ [5] early slot — preset nodes by default (they need the raw text, e.g. mermaid
84
+ must see fenced blocks before the highlighter eats them)
85
+ [10] hexo internals (code highlighting, …) and other plugins
86
+ [100] late slot — your hooks by default (they see the final text of the stage)
87
+ ```
88
+
89
+ Override with `slot: early` on a hook (run before everything) or `slot: late` on a preset node. Within a slot, nodes run by ascending `priority` (default 10), ties by declaration order. `hexo pipeline` prints the exact resolved order so you never have to guess.
90
+
91
+ ## The checker system (the safety net)
92
+
93
+ 1. **Static checks at registration** — before any post is touched: unknown config keys (with did-you-mean suggestions), invalid stages, duplicate node names, non-numeric priorities, missing script files, and order-ambiguity warnings when nodes from different sources share a priority.
94
+ 2. **Runtime guards on every execution** — a node that throws or returns a non-string is skipped with a warning and the original text flows on; a node that fails 3 times in a row is circuit-broken for the rest of the run; suspicious output (non-empty input wiped to empty, or 20x size explosion) is flagged but accepted.
95
+ 3. **`hexo pipeline`** — prints every stage's resolved node order (priority + source) plus all check results, so conflicts are visible before you deploy.
96
+
97
+ Default policy is warn-and-skip: your build never breaks because of one bad hook. Set `strict: true` (for CI) to turn config errors and node failures into build failures.
98
+
99
+ ## Developing hooks (debug mode)
100
+
101
+ Two tools answer "what does my hook actually receive at this stage?":
102
+
103
+ **`hexo pipeline --dry-run source/_posts/x.md`** — runs the `before_post_render` chain on one file, printing each node's effect as a line diff (skipped / no change / changed / FAILED), without generating anything.
104
+
105
+ **tap** — during a real `hexo generate` / `hexo s`, dumps the text flowing through every stage to snapshot files:
106
+
107
+ ```yaml
108
+ text_pipeline:
109
+ tap:
110
+ enable: true
111
+ match: my-post # strongly recommended: only capture matching sources/paths
112
+ dir: .text-pipeline-tap
113
+ ```
114
+
115
+ ```
116
+ .text-pipeline-tap/_posts_my-post.md/after_post_render.late/
117
+ ├── 00-input.txt ← exactly what a (late-slot) hook on this stage receives
118
+ ├── 01-hook_my-hook.txt ← text after each node that changed it
119
+ └── … ← the last file is the slot's final output
120
+ ```
121
+
122
+ One snapshot directory per stage and slot (`<stage>.early` / `<stage>.late`).
123
+
124
+ Each render cycle replaces the previous snapshot. Add the tap dir to `.gitignore` and turn `enable` off for normal builds.
125
+
126
+ ## Built-in preset: `obsidian`
127
+
128
+ Compiles Obsidian Flavored Markdown for Hexo. Enable with `presets: [obsidian]`.
129
+
130
+ | Node | Syntax | Default | Behavior |
131
+ |------|--------|---------|----------|
132
+ | `comment` | `%%inline%%`, multi-line `%% … %%` | on | Stripped before rendering (literal inside code) |
133
+ | `embed` | `![[image.png\|300]]`, `![[Note]]` | on | Images → markdown image / `<img width>` (`asset_prefix` config); resolvable note embeds → link; others untouched |
134
+ | `wikilink` | `[[target#anchor\|alias]]`, `[[#heading]]` | on | Rewritten to the post's permalink (`abbrlink` first, `post.path` fallback); same-page headings → `#anchor`; block-ref anchors (`#^id`) degrade to the post link |
135
+ | `highlight` | `==text==` | on | `<mark>text</mark>` |
136
+ | `blockid` | trailing `^block-id` | on | Stripped (invisible in Obsidian reading view too) |
137
+ | `mdlink` | Leftover `.md` links in HTML | on | Fallback rewrite to the post's permalink |
138
+ | `mermaid` | ` ```mermaid ` fenced blocks | on | Swapped to `<pre class="mermaid">` so highlighters don't eat the diagram; lazy CDN loader injected |
139
+ | `callout` | `> [!type] Title` | **off** | `<div class="callout callout-type">`; off because most renderers/themes already support callouts |
140
+
141
+ ```yaml
142
+ presets:
143
+ - name: obsidian
144
+ config:
145
+ domain_prefix: '' # link prefix for wikilink/mdlink/embed
146
+ callout: { enable: true } # opt in
147
+ embed: { asset_prefix: /images } # prepended to embedded image paths
148
+ mermaid: { theme: dark, priority: 15 } # any node: sub-config + priority override
149
+ ```
150
+
151
+ ## Installation
152
+
153
+ ```bash
154
+ npm install hexo-text-pipeline --save
155
+ ```
156
+
157
+ ## Full configuration reference
158
+
159
+ ```yaml
160
+ text_pipeline:
161
+ enable: true # master switch
162
+ debug: false # verbose logging
163
+ strict: false # config errors / node failures fail the build (CI)
164
+ inject_css: true # nodes' default styles (e.g. callout)
165
+ inject_js: true # nodes' frontend scripts (e.g. mermaid loader)
166
+ presets: [] # built-in name | npm package | ./local/path | { name, config }
167
+ hooks: [] # { script | command, stage, slot, priority, name, timeout, match, enable }
168
+ plugins_dir: text-pipeline # single-file plugin directory; false disables discovery
169
+ plugins: {} # per-plugin overrides: { <name>: { enable, slot, priority, ...rest lands in ctx.config } }
170
+ tap: # debug mode: dump per-stage text snapshots (see "Developing hooks")
171
+ enable: false
172
+ match: ''
173
+ dir: .text-pipeline-tap
174
+ ```
175
+
176
+ ## Development
177
+
178
+ Zero runtime dependencies, Node >= 16.
179
+
180
+ ```bash
181
+ npm test # node --test
182
+ ```
183
+
184
+ - **Single-file plugins (start here to write a plugin)**: [docs/PLUGINS.md](docs/PLUGINS.md)
185
+ - Hooks API reference (stage inputs, ctx fields, debugging workflow): [docs/HOOKS-API.md](docs/HOOKS-API.md)
186
+ - Architecture, stage table, node contract: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
187
+ - Extending: hook vs preset node vs new preset: [docs/EXTENDING.md](docs/EXTENDING.md)
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,191 @@
1
+ [English](README.md) | **简体中文**
2
+
3
+ # hexo-text-pipeline
4
+
5
+ Hexo 渲染管线的通用 hooks 总线。
6
+
7
+ Hexo 渲染过程中所有"文本进、文本出"的执行点都被暴露为 stage。你用纯声明式配置往 stage 上挂东西——自己的脚本、shell 命令、或打包好的 preset。checker 系统全程兜底:配置错误在第一篇文章被处理之前就被发现,单个节点失败只会被跳过,构建永远不炸。
8
+
9
+ 设计目标:绝大多数小型 Hexo 插件做的事,本质都是"在管线的某个点变换一段文本"。这不该需要发包、不该需要配置——**写插件就是写一个文件**。在站点根目录建 `text-pipeline/`,丢进去:
10
+
11
+ ```js
12
+ // text-pipeline/arrow.js —— 这就是一个完整插件
13
+ module.exports = {
14
+ replace: [[/-->/g, '→']]
15
+ };
16
+ ```
17
+
18
+ 下一次 `hexo generate` 它就在跑了:零配置自动发现,markdown 阶段自动跳过代码块,逻辑改动即改即用,坏了只会被跳过、构建永远不炸。完整契约见 [docs/PLUGINS.zh-CN.md](docs/PLUGINS.zh-CN.md)。
19
+
20
+ ## Stage 表
21
+
22
+ | Stage | 流经的文本 |
23
+ |-------|-----------|
24
+ | `before_post_render` | 单篇文章的 **markdown**(渲染前) |
25
+ | `after_post_render` | 单篇文章的 **HTML 片段**(渲染后) |
26
+ | `after_render:html` | 模板套完后的**完整页面 HTML** |
27
+ | `after_render:css` / `after_render:js` | 生成的静态资源 |
28
+
29
+ 与 [Hexo filter API](https://hexo.io/api/filter) 一一对应。非文本的 filter(`template_locals`、`server_middleware` 等)刻意不在范围内。
30
+
31
+ ## 四种挂载方式
32
+
33
+ **1. 单文件插件(默认答案)** —— `text-pipeline/` 目录里每个 `.js` 文件自动挂载。导出与统一契约同形的 node 对象(`name` 缺省取文件名),`replace` 规则表是 `convert` 的声明式写法;`_config.yml` 可按插件名覆盖 `enable` / `slot` / `priority`,其余子配置进 `ctx.config`。详见 [docs/PLUGINS.zh-CN.md](docs/PLUGINS.zh-CN.md)。
34
+
35
+ ```js
36
+ // text-pipeline/ruby.js
37
+ module.exports = {
38
+ stage: 'before_post_render',
39
+ match: '\\{ruby',
40
+ convert: (text, ctx) => text.replace(/\{ruby (.+?)\}/g, '<ruby>$1</ruby>')
41
+ };
42
+ ```
43
+
44
+ **2. 本地脚本(hook)** —— `module.exports = (text, ctx) => text`,相对 Hexo 根目录解析,每次执行重新加载。改完文件,下一次渲染就生效。想要纯函数形态、placement 写在 YAML 里时用它。
45
+
46
+ ```yaml
47
+ hooks:
48
+ - script: scripts/minify.js
49
+ stage: after_render:html
50
+ ```
51
+
52
+ **3. 外部命令** —— 正文从 stdin 进,变换结果从 stdout 出,任何语言。上下文走环境变量:`HTP_STAGE` / `HTP_SLOT` 恒有,post 类 stage 给 `HTP_POST_SOURCE` / `HTP_POST_PATH` / `HTP_POST_TITLE`,string 类给 `HTP_FILE_PATH`。
53
+
54
+ ```yaml
55
+ hooks:
56
+ - command: python scripts/furigana.py
57
+ stage: before_post_render # 默认 stage
58
+ name: furigana # 可选,日志标识
59
+ priority: 20 # 可选,默认 10,小者先跑
60
+ timeout: 10000 # 可选,毫秒
61
+ match: '\\{furigana' # 可选正则:文本不命中直接跳过(command 可省一次 spawn)
62
+ slot: late # 可选:late(默认,看到该 stage 最终文本)| early(原始文本)
63
+ ```
64
+
65
+ **4. 程序化注册** —— 其他插件(或站点 `scripts/` 目录里的脚本)可以直接注册 node:
66
+
67
+ ```js
68
+ hexo.textPipeline.register({
69
+ name: 'exclaim',
70
+ stage: 'before_post_render',
71
+ priority: 5,
72
+ convert: (text, ctx) => text + '!'
73
+ });
74
+ ```
75
+
76
+ 注册的默认值与校验和 hooks 同一套规则(slot 默认 `late`,也支持 `match` 正则预判)。`ctx` 为 `{ hexo, stage, pluginConfig, utils, log }`,post 类 stage 另有 `ctx.post`、string 类有 `ctx.file`;`ctx.utils` 自带 `replaceOutsideCode` / `segmentInlineCode`,在 markdown 阶段做行内替换时安全跳过代码块。
77
+
78
+ ### 执行顺序:每个 stage 两个挂点
79
+
80
+ 每个 stage 有两个挂点,注册在 Hexo 自己的 filter 前后:
81
+
82
+ ```
83
+ [5] early 挂点 —— preset node 默认在这里(它们需要原始文本,
84
+ 比如 mermaid 必须在高亮器吃掉围栏代码块之前看到它)
85
+ [10] hexo 内置 filter(代码高亮等)和其他插件
86
+ [100] late 挂点 —— 你的 hook 默认在这里(看到该 stage 的最终文本)
87
+ ```
88
+
89
+ hook 加 `slot: early` 可以抢到所有人之前;preset node 加 `slot: late` 可以压到最后。同一挂点内按 `priority` 升序(默认 10),同级按声明顺序。`hexo pipeline` 会打印最终解析出的精确顺序,永远不用猜。
90
+
91
+ ## Checker 系统(兜底)
92
+
93
+ 1. **注册期静态检查**——在任何文章被处理之前:未知配置键(带 did-you-mean 建议)、非法 stage、node 重名、priority 类型错误、脚本文件缺失、不同来源的 node 共享同一 priority 的顺序歧义提示。
94
+ 2. **运行期防护**——每次执行都过守卫:抛错或返回非字符串的 node 被跳过并告警,原文继续流向下一环;连续失败 3 次的 node 整轮熔断;可疑输出(非空输入被清空、体积膨胀 20 倍)会被标记但放行。
95
+ 3. **`hexo pipeline` 诊断命令**——打印每个 stage 解析后的 node 顺序(priority + 来源)和全部检查结果,冲突在部署前就能看见。
96
+
97
+ 默认策略是 warn-and-skip:构建永远不会因为一个坏 hook 失败。`strict: true`(给 CI 用)则把配置错误和节点失败变成构建失败。
98
+
99
+ ## 开发 hook(调试模式)
100
+
101
+ 两个工具回答"我的 hook 在这个 stage 到底收到什么":
102
+
103
+ **`hexo pipeline --dry-run source/_posts/x.md`**——对单个文件跑 `before_post_render` 链,逐 node 打印效果(跳过 / 无变化 / 变更行级 diff / 失败),不生成任何东西。
104
+
105
+ **tap(管线抽头)**——在真实 `hexo generate` / `hexo s` 过程中,把每个 stage 流过的文本落盘成快照:
106
+
107
+ ```yaml
108
+ text_pipeline:
109
+ tap:
110
+ enable: true
111
+ match: my-post # 强烈建议设置:只抓匹配的文章/页面
112
+ dir: .text-pipeline-tap
113
+ ```
114
+
115
+ ```
116
+ .text-pipeline-tap/_posts_my-post.md/after_post_render.late/
117
+ ├── 00-input.txt ← 挂在该 stage(late 挂点)的 hook 收到的就是这个
118
+ ├── 01-hook_my-hook.txt ← 每个改动了文本的 node 改完后的样子
119
+ └── … ← 最后一个文件即该挂点的最终输出
120
+ ```
121
+
122
+ 每个 stage 的每个挂点一个快照目录(`<stage>.early` / `<stage>.late`)。
123
+
124
+ 每轮渲染替换上一轮快照。tap 目录记得加进 `.gitignore`,正常构建时关掉 `enable`。
125
+
126
+ ## 内置 preset:`obsidian`
127
+
128
+ 把 Obsidian Flavored Markdown 编译为 Hexo 友好输出。`presets: [obsidian]` 启用。
129
+
130
+ | Node | 语法 | 默认 | 行为 |
131
+ |------|------|------|------|
132
+ | `comment` | `%%行内%%`、跨行 `%% … %%` | 开 | 渲染前剥离(代码内为字面量) |
133
+ | `embed` | `![[图片.png\|300]]`、`![[笔记]]` | 开 | 图片 → markdown 图片 / `<img width>`(`asset_prefix` 配置);可解析的笔记嵌入 → 链接;其他原样保留 |
134
+ | `wikilink` | `[[目标#锚点\|别名]]`、`[[#标题]]` | 开 | 重写为文章永久链接(`abbrlink` 优先,回退 `post.path`);同页标题 → `#锚点`;块引用锚点(`#^id`)降级为文章链接 |
135
+ | `highlight` | `==文本==` | 开 | `<mark>文本</mark>` |
136
+ | `blockid` | 行尾 `^block-id` | 开 | 剥离(Obsidian 阅读视图里同样不可见) |
137
+ | `mdlink` | HTML 里残留的 `.md` 链接 | 开 | 兜底重写为文章永久链接 |
138
+ | `mermaid` | ` ```mermaid ` 围栏块 | 开 | 换成 `<pre class="mermaid">` 绕开语法高亮;按需注入懒加载 CDN 脚本 |
139
+ | `callout` | `> [!type] 标题` | **关** | `<div class="callout callout-type">`;主流渲染器/主题已多自带支持,所以默认关 |
140
+
141
+ ```yaml
142
+ presets:
143
+ - name: obsidian
144
+ config:
145
+ domain_prefix: '' # wikilink/mdlink/embed 的链接前缀
146
+ callout: { enable: true } # 按需打开
147
+ embed: { asset_prefix: /images } # 嵌入图片路径的前缀
148
+ mermaid: { theme: dark, priority: 15 } # 任意 node:子配置 + priority 覆盖
149
+ ```
150
+
151
+ ## 安装
152
+
153
+ ```bash
154
+ npm install hexo-text-pipeline --save
155
+ ```
156
+
157
+ ## 完整配置参考
158
+
159
+ ```yaml
160
+ text_pipeline:
161
+ enable: true # 总开关
162
+ debug: false # 详细日志
163
+ strict: false # 配置错误 / 节点失败让构建失败(CI 用)
164
+ inject_css: true # node 的默认样式(如 callout)
165
+ inject_js: true # node 的前端脚本(如 mermaid 加载器)
166
+ presets: [] # 内置名 | npm 包 | ./本地路径 | { name, config }
167
+ hooks: [] # { script | command, stage, slot, priority, name, timeout, match, enable }
168
+ plugins_dir: text-pipeline # 单文件插件目录;false 关闭自动发现
169
+ plugins: {} # 按插件名覆盖:{ <name>: { enable, slot, priority, ...其余进 ctx.config } }
170
+ tap: # 调试模式:落盘每个 stage 的文本快照(见"开发 hook")
171
+ enable: false
172
+ match: ''
173
+ dir: .text-pipeline-tap
174
+ ```
175
+
176
+ ## 开发
177
+
178
+ 零运行时依赖,Node >= 16。
179
+
180
+ ```bash
181
+ npm test # node --test
182
+ ```
183
+
184
+ - **单文件插件(写插件从这里开始)**:[docs/PLUGINS.zh-CN.md](docs/PLUGINS.zh-CN.md)
185
+ - Hooks 接口文档(stage 输入、ctx 字段、调试工作流):[docs/HOOKS-API.zh-CN.md](docs/HOOKS-API.zh-CN.md)
186
+ - 架构、stage 表、node 契约:[docs/ARCHITECTURE.zh-CN.md](docs/ARCHITECTURE.zh-CN.md)
187
+ - 扩展指南:hook vs preset node vs 新 preset:[docs/EXTENDING.zh-CN.md](docs/EXTENDING.zh-CN.md)
188
+
189
+ ## 许可证
190
+
191
+ MIT
package/index.js ADDED
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ const engine = require('./lib/core/engine');
4
+
5
+ function register(hexo) {
6
+ engine.register(hexo);
7
+ }
8
+
9
+ const runtimeHexo =
10
+ (typeof globalThis !== 'undefined' && globalThis.hexo) ||
11
+ (typeof hexo !== 'undefined' ? hexo : undefined);
12
+ if (runtimeHexo) {
13
+ register(runtimeHexo);
14
+ }
15
+
16
+ module.exports = register;
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const { STAGES } = require('./stages');
4
+ const { resolvePlacement, resolveConvert, DEFAULT_PRIORITY } = require('./node-contract');
5
+ const guard = require('./markdown-guard');
6
+
7
+ /**
8
+ * 中央 node 注册表。engine 在 filter 执行时懒查询,所以注册可以发生在任何时刻
9
+ * (配置加载、preset 加载、其他插件经 hexo.textPipeline.register 程序化注册)。
10
+ *
11
+ * node 统一契约(唯一接口):
12
+ * {
13
+ * name, // 唯一标识;preset 的 node 自动带 '<preset>:' 前缀
14
+ * stage, // stages.js 表里的键,默认 before_post_render
15
+ * slot: 'early' | 'late', // 挂点:early 在 hexo 内置/其他插件之前,late 在其之后
16
+ * // 默认值见 node-contract.js 的 SLOT_DEFAULTS(preset early,hook/api late)
17
+ * priority: 10, // 同挂点内小者先跑,同级按注册顺序
18
+ * test(text), // 可选,廉价预判;match(正则)是它的声明式写法
19
+ * convert(text, ctx), // 纯函数:文本进、文本出;replace 规则表是它的声明式写法
20
+ * // ([[RegExp, 替换], ...],markdown 阶段自动套 markdown-guard)
21
+ * css, js, // 可选,默认样式/前端脚本
22
+ * config, presetConfig, // 可选;存在时原样出现在 ctx 上(hook 没有,ctx 上也不会有)
23
+ * origin, // 'preset:<name>' | 'hook:command' | 'hook:script' | 'api'
24
+ * }
25
+ *
26
+ * 放置字段的默认值与校验统一在 node-contract.js——register 与 config hooks /
27
+ * preset 加载共享同一套规则。
28
+ */
29
+ function createRegistry() {
30
+ const entries = [];
31
+ let seq = 0;
32
+
33
+ return {
34
+ add(node) {
35
+ entries.push({ node, seq: (seq += 1) });
36
+ },
37
+ // slot 省略时返回该 stage 全部 node(doctor 用);指定时只返回该挂点的
38
+ forStage(stage, slot) {
39
+ return entries
40
+ .filter((entry) => entry.node.stage === stage && (!slot || (entry.node.slot || 'early') === slot))
41
+ .sort((a, b) => {
42
+ const pa = Number.isFinite(a.node.priority) ? a.node.priority : DEFAULT_PRIORITY;
43
+ const pb = Number.isFinite(b.node.priority) ? b.node.priority : DEFAULT_PRIORITY;
44
+ return pa - pb || a.seq - b.seq;
45
+ })
46
+ .map((entry) => entry.node);
47
+ },
48
+ all() {
49
+ return entries.map((entry) => entry.node);
50
+ }
51
+ };
52
+ }
53
+
54
+ /**
55
+ * 暴露为 hexo.textPipeline 的程序化 API:其他插件/脚本也能往管线挂 node。
56
+ * utils 给 script hook 用(如在 markdown 阶段跳过代码块)。
57
+ */
58
+ function createPublicApi(registry, { onRegister, log } = {}) {
59
+ return {
60
+ stages: Object.keys(STAGES),
61
+ utils: {
62
+ replaceOutsideCode: guard.replaceOutsideCode,
63
+ replaceOutsideInlineCode: guard.replaceOutsideInlineCode,
64
+ segmentInlineCode: guard.segmentInlineCode
65
+ },
66
+ register(node) {
67
+ if (!node || typeof node.name !== 'string' || !node.name) {
68
+ throw new TypeError('textPipeline.register expects { name, stage, convert(text, ctx) | replace }');
69
+ }
70
+ const resolved = resolvePlacement(node, 'api');
71
+ if (resolved.error) {
72
+ throw new TypeError('textPipeline.register: ' + resolved.error);
73
+ }
74
+ const converted = resolveConvert(Object.assign({}, node, { stage: resolved.placement.stage }));
75
+ if (converted.error) {
76
+ throw new TypeError('textPipeline.register: node "' + node.name + '" ' + converted.error);
77
+ }
78
+ const wrapped = Object.assign({ origin: 'api' }, node, {
79
+ stage: resolved.placement.stage,
80
+ slot: resolved.placement.slot,
81
+ priority: resolved.placement.priority,
82
+ convert: converted.convert
83
+ });
84
+ if (typeof wrapped.test !== 'function' && resolved.placement.test) {
85
+ wrapped.test = resolved.placement.test;
86
+ }
87
+ // 与启动期 checkNodes 同一条规则:重名不拦截,但要告警(日志/tap/doctor 里分不清)
88
+ if (log && registry.all().some((existing) => existing.name === wrapped.name)) {
89
+ log.warn('duplicate node name "' + wrapped.name + '" — both will run; rename one to tell them apart in logs');
90
+ }
91
+ registry.add(wrapped);
92
+ if (typeof onRegister === 'function') onRegister(wrapped);
93
+ return wrapped;
94
+ }
95
+ };
96
+ }
97
+
98
+ module.exports = {
99
+ createRegistry,
100
+ createPublicApi,
101
+ DEFAULT_PRIORITY
102
+ };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const FAILURE_TRIP_THRESHOLD = 3;
4
+ const EXPLOSION_RATIO = 20;
5
+ const EXPLOSION_MIN_BYTES = 64 * 1024;
6
+
7
+ /**
8
+ * 运行期防护(兜底第二道防线)。每个 node 的每次执行都经过这里:
9
+ *
10
+ * - 硬防护:抛错 / 返回非字符串 → 丢弃该 node 的输出,原文继续流向下一环;
11
+ * strict 模式下改为抛出,让构建失败(给 CI 用)。
12
+ * - 熔断:同一 node 连续失败 FAILURE_TRIP_THRESHOLD 次后整轮禁用,
13
+ * 不再为每篇文章刷一遍相同的错误日志。
14
+ * - 软告警(不拦截,输出仍被采纳):输入非空但输出被清空;输出体积异常膨胀。
15
+ * 这两种都可能是正常行为(如注释剥离/资源内联),所以只提醒。
16
+ */
17
+ function createRuntimeGuard({ strict } = {}) {
18
+ const failures = new Map(); // node -> consecutive failure count
19
+ const tripped = new Set();
20
+
21
+ return {
22
+ run(node, text, ctx) {
23
+ if (tripped.has(node)) {
24
+ return text;
25
+ }
26
+
27
+ let output;
28
+ try {
29
+ output = node.convert(text, ctx);
30
+ if (typeof output !== 'string') {
31
+ throw new Error('returned ' + typeof output + ' instead of a string');
32
+ }
33
+ } catch (err) {
34
+ if (strict) {
35
+ throw new Error('[' + node.name + '] ' + (err && err.message));
36
+ }
37
+ const count = (failures.get(node) || 0) + 1;
38
+ failures.set(node, count);
39
+ ctx.log.warn(ctx.stage + ' failed, skipped: ' + (err && err.message));
40
+ if (count >= FAILURE_TRIP_THRESHOLD) {
41
+ tripped.add(node);
42
+ ctx.log.warn('disabled for the rest of this run after ' + count + ' consecutive failures');
43
+ }
44
+ return text;
45
+ }
46
+
47
+ failures.set(node, 0);
48
+
49
+ if (text.length > 0 && output.length === 0) {
50
+ ctx.log.warn(ctx.stage + ' produced empty output from non-empty input (accepted — verify this is intended)');
51
+ } else if (output.length > EXPLOSION_MIN_BYTES && output.length > text.length * EXPLOSION_RATIO) {
52
+ ctx.log.warn(
53
+ ctx.stage + ' output grew ' + Math.round(output.length / Math.max(text.length, 1)) + 'x (' + output.length + ' bytes) — verify this is intended'
54
+ );
55
+ }
56
+
57
+ return output;
58
+ },
59
+ isTripped(node) {
60
+ return tripped.has(node);
61
+ },
62
+ // 每轮 generate 开始时由 engine 调用:hexo server 长进程下,
63
+ // 上一轮被熔断的 node 在新一轮重新获得机会(脚本可能已被修好——即改即用)
64
+ reset() {
65
+ failures.clear();
66
+ tripped.clear();
67
+ }
68
+ };
69
+ }
70
+
71
+ module.exports = { createRuntimeGuard, FAILURE_TRIP_THRESHOLD };