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,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { STAGES } = require('./stages');
|
|
4
|
+
const guard = require('./markdown-guard');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_PRIORITY = 10;
|
|
7
|
+
const DEFAULT_STAGE = 'before_post_render';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* slot 默认值的唯一出处(按注册路径的角色分):
|
|
11
|
+
* - preset node 默认 early——多数变换需要原始文本(围栏代码块还没被 hexo 吞掉);
|
|
12
|
+
* - config hook、单文件插件(plugin 目录)与 textPipeline.register 默认 late——
|
|
13
|
+
* 用户/第三方想看到该 stage 的最终文本。
|
|
14
|
+
* 任何路径都可以用 slot 字段显式覆盖。
|
|
15
|
+
*/
|
|
16
|
+
const SLOT_DEFAULTS = { preset: 'early', hook: 'late', plugin: 'late', api: 'late' };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 放置字段(stage / slot / priority / match)的唯一归一化与校验入口。
|
|
20
|
+
* 三条注册路径——preset 加载、config hooks、hexo.textPipeline.register——
|
|
21
|
+
* 都经过这里:默认值和校验规则只此一份,不会再因入口不同而行为分裂。
|
|
22
|
+
*
|
|
23
|
+
* raw 取 { stage?, slot?, priority?, match? };match(字符串或 RegExp)编译成
|
|
24
|
+
* test(text) 廉价预判——它只是 test 的声明式写法,两者是同一个概念。
|
|
25
|
+
* 返回 { placement: { stage, slot, priority, test? } } 或 { error }。
|
|
26
|
+
*/
|
|
27
|
+
function resolvePlacement(raw, role) {
|
|
28
|
+
if (!SLOT_DEFAULTS[role]) {
|
|
29
|
+
throw new TypeError('resolvePlacement: unknown role "' + role + '"');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const stage = raw.stage === undefined ? DEFAULT_STAGE : raw.stage;
|
|
33
|
+
if (!STAGES[stage]) {
|
|
34
|
+
return { error: 'unknown stage "' + stage + '" (valid: ' + Object.keys(STAGES).join(', ') + ')' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const slot = raw.slot === undefined ? SLOT_DEFAULTS[role] : raw.slot;
|
|
38
|
+
if (slot !== 'early' && slot !== 'late') {
|
|
39
|
+
return { error: 'invalid slot "' + raw.slot + '" (use early or late)' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (raw.priority !== undefined && !Number.isFinite(raw.priority)) {
|
|
43
|
+
return { error: 'priority must be a number, got ' + JSON.stringify(raw.priority) };
|
|
44
|
+
}
|
|
45
|
+
const priority = raw.priority === undefined ? DEFAULT_PRIORITY : raw.priority;
|
|
46
|
+
|
|
47
|
+
const placement = { stage, slot, priority };
|
|
48
|
+
if (raw.match !== undefined) {
|
|
49
|
+
let regex;
|
|
50
|
+
try {
|
|
51
|
+
regex = raw.match instanceof RegExp ? raw.match : new RegExp(raw.match);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { error: 'invalid match regex: ' + (err && err.message) };
|
|
54
|
+
}
|
|
55
|
+
placement.test = (text) => regex.test(text);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { placement };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* replace DSL → convert 编译。规则形如 [[RegExp, string|fn], ...]:
|
|
63
|
+
* - pattern 必须是 RegExp(DSL 活在 JS 文件里,正则字面量零成本;不收字符串,
|
|
64
|
+
* 省掉转义/标志的歧义面);缺 g 标志自动补全——"replace 列表"的声明式语义
|
|
65
|
+
* 就是全文替换,只换第一个属于 corner case,请写 convert。
|
|
66
|
+
* - useGuard 时整组规则套 markdown-guard(跳过围栏+行内代码)。
|
|
67
|
+
*/
|
|
68
|
+
function compileReplace(rules, useGuard) {
|
|
69
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
70
|
+
return { error: 'replace must be a non-empty array of [RegExp, replacement] pairs' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const compiled = [];
|
|
74
|
+
for (const [index, rule] of rules.entries()) {
|
|
75
|
+
if (!Array.isArray(rule) || rule.length !== 2) {
|
|
76
|
+
return { error: 'replace[' + index + '] must be a [RegExp, replacement] pair' };
|
|
77
|
+
}
|
|
78
|
+
const [pattern, replacement] = rule;
|
|
79
|
+
if (!(pattern instanceof RegExp)) {
|
|
80
|
+
return { error: 'replace[' + index + '] pattern must be a RegExp literal (got ' + typeof pattern + ')' };
|
|
81
|
+
}
|
|
82
|
+
if (typeof replacement !== 'string' && typeof replacement !== 'function') {
|
|
83
|
+
return { error: 'replace[' + index + '] replacement must be a string or a function' };
|
|
84
|
+
}
|
|
85
|
+
const global = pattern.flags.includes('g') ? pattern : new RegExp(pattern.source, pattern.flags + 'g');
|
|
86
|
+
compiled.push([global, replacement]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const applyAll = (segment) => {
|
|
90
|
+
let current = segment;
|
|
91
|
+
for (const [pattern, replacement] of compiled) {
|
|
92
|
+
current = current.replace(pattern, replacement);
|
|
93
|
+
}
|
|
94
|
+
return current;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (useGuard) {
|
|
98
|
+
return { convert: (text) => guard.replaceOutsideCode(text, applyAll) };
|
|
99
|
+
}
|
|
100
|
+
return { convert: (text) => applyAll(text) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* convert 的唯一裁决入口:convert 与 replace 恰有其一——replace 是 convert 的
|
|
105
|
+
* 声明式写法(如同 match 之于 test),所有注册路径同一套规则。
|
|
106
|
+
* node 落在 before_post_render(输入是 markdown)时,replace 编译出的 convert
|
|
107
|
+
* 自动套 markdown-guard 防误伤代码;需要碰代码块就写 convert。
|
|
108
|
+
* 返回 { convert } 或 { error }。
|
|
109
|
+
*/
|
|
110
|
+
function resolveConvert(node) {
|
|
111
|
+
const hasConvert = typeof node.convert === 'function';
|
|
112
|
+
const hasReplace = node.replace !== undefined;
|
|
113
|
+
if (hasConvert && hasReplace) {
|
|
114
|
+
return { error: 'sets both convert and replace; pick one' };
|
|
115
|
+
}
|
|
116
|
+
if (hasConvert) {
|
|
117
|
+
return { convert: node.convert };
|
|
118
|
+
}
|
|
119
|
+
if (hasReplace) {
|
|
120
|
+
const stage = node.stage === undefined ? DEFAULT_STAGE : node.stage;
|
|
121
|
+
return compileReplace(node.replace, stage === 'before_post_render');
|
|
122
|
+
}
|
|
123
|
+
return { error: 'needs a convert(text, ctx) function or a replace rule list' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
resolvePlacement,
|
|
128
|
+
compileReplace,
|
|
129
|
+
resolveConvert,
|
|
130
|
+
DEFAULT_PRIORITY,
|
|
131
|
+
DEFAULT_STAGE,
|
|
132
|
+
SLOT_DEFAULTS
|
|
133
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 管线暴露的全部 stage —— Hexo 渲染管线上所有"文本进、文本出"的执行点
|
|
5
|
+
* (filter 名与 Hexo 官方一致,见 https://hexo.io/api/filter)。
|
|
6
|
+
*
|
|
7
|
+
* 这是本插件的边界:template_locals / server_middleware 等不是文本变换的
|
|
8
|
+
* filter 不在此暴露,需要时请直接注册 Hexo filter。
|
|
9
|
+
*
|
|
10
|
+
* kind 决定 engine 如何接驳 hexo filter:
|
|
11
|
+
* - 'post':filter 收 post 数据对象,文本在 data.content(逐篇文章)
|
|
12
|
+
* - 'string':filter 收 (text, data) 并返回新文本(整页/资源,data 含 path 等元信息)
|
|
13
|
+
*
|
|
14
|
+
* 每个 stage 有两个挂点(slot),engine 各注册一个 hexo filter:
|
|
15
|
+
* - early:filterPriority(post 类 5),抢在 hexo 内置 filter(backtick_code_block、
|
|
16
|
+
* excerpt 等,优先级 10)和其他插件之前——mermaid 等需要原始 markdown 的
|
|
17
|
+
* preset node 默认在这里;
|
|
18
|
+
* - late:LATE_FILTER_PRIORITY(100),在 hexo 内置和其他插件全部跑完之后——
|
|
19
|
+
* 用户 hook 默认在这里,看到的是该 stage 的最终文本。
|
|
20
|
+
* node 用 slot 字段在两个挂点间移动。
|
|
21
|
+
*
|
|
22
|
+
* 新增 stage 只需在这张表加一项,engine 与 checker 自动覆盖。
|
|
23
|
+
*/
|
|
24
|
+
const LATE_FILTER_PRIORITY = 100;
|
|
25
|
+
const STAGES = {
|
|
26
|
+
before_post_render: {
|
|
27
|
+
kind: 'post',
|
|
28
|
+
filterPriority: 5,
|
|
29
|
+
description: 'per-post markdown, before markdown rendering'
|
|
30
|
+
},
|
|
31
|
+
after_post_render: {
|
|
32
|
+
kind: 'post',
|
|
33
|
+
filterPriority: 5,
|
|
34
|
+
description: 'per-post HTML fragment, after markdown rendering'
|
|
35
|
+
},
|
|
36
|
+
'after_render:html': {
|
|
37
|
+
kind: 'string',
|
|
38
|
+
filterPriority: 10,
|
|
39
|
+
description: 'full page HTML, after template rendering'
|
|
40
|
+
},
|
|
41
|
+
'after_render:css': {
|
|
42
|
+
kind: 'string',
|
|
43
|
+
filterPriority: 10,
|
|
44
|
+
description: 'generated CSS asset'
|
|
45
|
+
},
|
|
46
|
+
'after_render:js': {
|
|
47
|
+
kind: 'string',
|
|
48
|
+
filterPriority: 10,
|
|
49
|
+
description: 'generated JS asset'
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
module.exports = { STAGES, LATE_FILTER_PRIORITY };
|
package/lib/core/tap.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DIR = '.text-pipeline-tap';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* tap(管线抽头)—— 开发 hook 用的调试模式。开启后在真实渲染过程中,
|
|
10
|
+
* 把每个 stage 的输入文本和每个改动了文本的 node 的输出落盘:
|
|
11
|
+
*
|
|
12
|
+
* <dir>/<文章或页面路径>/<stage>/00-input.txt ← 你的 hook 在该 stage 收到的就是这个
|
|
13
|
+
* <dir>/<文章或页面路径>/<stage>/01-obsidian:comment.txt
|
|
14
|
+
* <dir>/<文章或页面路径>/<stage>/02-hook:my-hook.txt ← 最后一个文件即该 stage 最终输出
|
|
15
|
+
*
|
|
16
|
+
* 配置:
|
|
17
|
+
* text_pipeline:
|
|
18
|
+
* tap:
|
|
19
|
+
* enable: true
|
|
20
|
+
* match: 'my-post' # 可选:只抓 source/path 匹配该正则的文章/页面(强烈建议设置)
|
|
21
|
+
* dir: .text-pipeline-tap # 可选:输出目录,相对 Hexo 根
|
|
22
|
+
*
|
|
23
|
+
* 每轮渲染对同一 stage 重新落盘(先清空再写),目录里永远是最近一次的快照。
|
|
24
|
+
*/
|
|
25
|
+
function sanitize(name) {
|
|
26
|
+
return String(name).replace(/[^\w.-]+/g, '_').slice(0, 120) || 'unknown';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createTap(tapConfig, baseDir, log) {
|
|
30
|
+
if (!tapConfig || tapConfig.enable !== true) return null;
|
|
31
|
+
|
|
32
|
+
const root = path.resolve(baseDir || process.cwd(), typeof tapConfig.dir === 'string' && tapConfig.dir ? tapConfig.dir : DEFAULT_DIR);
|
|
33
|
+
|
|
34
|
+
let matchRegex = null;
|
|
35
|
+
if (tapConfig.match !== undefined) {
|
|
36
|
+
try {
|
|
37
|
+
matchRegex = new RegExp(tapConfig.match);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
log.warn('tap.match is not a valid regex, tap disabled: ' + (err && err.message));
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sequences = new Map(); // stage 目录 → 下一个序号
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
root,
|
|
48
|
+
capture(stage, target, label, text) {
|
|
49
|
+
const id = (target && (target.source || target.path)) || 'unknown';
|
|
50
|
+
if (matchRegex && !matchRegex.test(id)) return;
|
|
51
|
+
|
|
52
|
+
const dir = path.join(root, sanitize(id), sanitize(stage));
|
|
53
|
+
try {
|
|
54
|
+
if (label === 'input') {
|
|
55
|
+
// 新一轮该 stage 的快照:清掉上一轮的
|
|
56
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
sequences.set(dir, 1);
|
|
59
|
+
fs.writeFileSync(path.join(dir, '00-input.txt'), text);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const seq = sequences.get(dir) || 1;
|
|
63
|
+
sequences.set(dir, seq + 1);
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(path.join(dir, String(seq).padStart(2, '0') + '-' + sanitize(label) + '.txt'), text);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
log.warn('tap write failed: ' + (err && err.message));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { createTap, _internal: { sanitize } };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// node 模板:复制本目录为 converters/<name>/,按需拆出 parse.js / render.js,
|
|
4
|
+
// 完成后在 preset 入口(lib/presets/obsidian/index.js)的 nodes 数组注册。
|
|
5
|
+
// 本目录不在注册表里,仅作起点。
|
|
6
|
+
// 提示:站点专属的变换不需要写 preset node——在站点的 text-pipeline/ 目录
|
|
7
|
+
// 丢一个单文件插件即可(docs/PLUGINS.zh-CN.md),纯函数形态则用 hooks 的 script 条目。
|
|
8
|
+
|
|
9
|
+
// const { replaceOutsideCode } = require('../../../../core/markdown-guard');
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
// 短名;配置键:presets[].config.<name>,运行时全名为 obsidian:<name>
|
|
13
|
+
name: 'template',
|
|
14
|
+
|
|
15
|
+
// stage 全表见 lib/core/stages.js。
|
|
16
|
+
// 'before_post_render':输入是 markdown,行内替换务必用 markdown-guard 跳过代码。
|
|
17
|
+
// 'after_post_render':输入是渲染后的 HTML。
|
|
18
|
+
stage: 'before_post_render',
|
|
19
|
+
|
|
20
|
+
// 可选:默认优先级 10,小者先跑;用户可在配置里覆盖。
|
|
21
|
+
// priority: 10,
|
|
22
|
+
|
|
23
|
+
// 可选:出厂默认关闭(用户 config.<name>.enable 永远优先)。
|
|
24
|
+
// enabledByDefault: false,
|
|
25
|
+
|
|
26
|
+
// 可选:默认 CSS 字符串,engine 会在 inject_css 开启时注入 head_end。
|
|
27
|
+
// css: require('./styles'),
|
|
28
|
+
|
|
29
|
+
// 可选:前端脚本,inject_js 开启时注入 body_end;字符串或 (子配置) => 字符串。
|
|
30
|
+
// js: (config) => '...',
|
|
31
|
+
|
|
32
|
+
// 廉价预判,返回 false 直接跳过 convert。
|
|
33
|
+
test(content) {
|
|
34
|
+
return content.includes('TODO-marker');
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// 必须无副作用:不改 ctx,不读 hexo 全局状态(需要文章索引用 ../post-index)。
|
|
38
|
+
// ctx = { hexo, stage, pluginConfig, utils, log,
|
|
39
|
+
// post(post 类 stage)| file(string 类 stage,{ path }),
|
|
40
|
+
// config(node 子配置), presetConfig }
|
|
41
|
+
convert(content, ctx) {
|
|
42
|
+
return content;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { replaceOutsideCode } = require('../../../../core/markdown-guard');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Markdown 阶段剥离 Obsidian 块定义标记:行尾的 ` ^block-id`
|
|
7
|
+
* (以及独占一行的 `^block-id`)。它们只服务于 Obsidian 内部的块引用,
|
|
8
|
+
* 在 Obsidian 阅读视图里也不可见,不应出现在渲染结果里。
|
|
9
|
+
*/
|
|
10
|
+
function stripBlockIds(content) {
|
|
11
|
+
// 标记必须在行首独占或跟在空白后("x^2" 这类行内字面 ^ 不动)
|
|
12
|
+
return replaceOutsideCode(content, (segment) => segment.replace(/(^|\s+)\^[A-Za-z0-9-]+$/, ''));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
name: 'blockid',
|
|
17
|
+
stage: 'before_post_render',
|
|
18
|
+
test(content) {
|
|
19
|
+
return content.includes('^');
|
|
20
|
+
},
|
|
21
|
+
convert(content) {
|
|
22
|
+
return stripBlockIds(content);
|
|
23
|
+
},
|
|
24
|
+
_internal: { stripBlockIds }
|
|
25
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseCalloutInner } = require('./parse');
|
|
4
|
+
const { renderCallout } = require('./render');
|
|
5
|
+
const css = require('./styles');
|
|
6
|
+
|
|
7
|
+
const OPEN_TAG = '<blockquote';
|
|
8
|
+
const CLOSE_TAG = '</blockquote>';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 在 HTML 里找到与 openEnd 处开标签配对的 </blockquote>。
|
|
12
|
+
* 返回 { closeStart, closeEnd },未闭合返回 null。
|
|
13
|
+
*/
|
|
14
|
+
function findMatchingClose(html, openEnd) {
|
|
15
|
+
let depth = 1;
|
|
16
|
+
let cursor = openEnd;
|
|
17
|
+
|
|
18
|
+
while (depth > 0) {
|
|
19
|
+
const nextOpen = html.indexOf(OPEN_TAG, cursor);
|
|
20
|
+
const nextClose = html.indexOf(CLOSE_TAG, cursor);
|
|
21
|
+
|
|
22
|
+
if (nextClose === -1) return null;
|
|
23
|
+
|
|
24
|
+
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
25
|
+
depth += 1;
|
|
26
|
+
cursor = nextOpen + OPEN_TAG.length;
|
|
27
|
+
} else {
|
|
28
|
+
depth -= 1;
|
|
29
|
+
if (depth === 0) {
|
|
30
|
+
return { closeStart: nextClose, closeEnd: nextClose + CLOSE_TAG.length };
|
|
31
|
+
}
|
|
32
|
+
cursor = nextClose + CLOSE_TAG.length;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function transformCallouts(html) {
|
|
40
|
+
let result = '';
|
|
41
|
+
let pos = 0;
|
|
42
|
+
|
|
43
|
+
while (pos < html.length) {
|
|
44
|
+
const open = html.indexOf(OPEN_TAG, pos);
|
|
45
|
+
if (open === -1) break;
|
|
46
|
+
|
|
47
|
+
const openTagEnd = html.indexOf('>', open);
|
|
48
|
+
if (openTagEnd === -1) break;
|
|
49
|
+
|
|
50
|
+
const match = findMatchingClose(html, openTagEnd + 1);
|
|
51
|
+
if (!match) break;
|
|
52
|
+
|
|
53
|
+
const inner = html.slice(openTagEnd + 1, match.closeStart);
|
|
54
|
+
const parsed = parseCalloutInner(inner);
|
|
55
|
+
|
|
56
|
+
result += html.slice(pos, open);
|
|
57
|
+
if (parsed) {
|
|
58
|
+
result += renderCallout(parsed, transformCallouts(parsed.body));
|
|
59
|
+
} else {
|
|
60
|
+
// 普通引用保留原样,但递归处理内部可能嵌套的 callout
|
|
61
|
+
result += html.slice(open, openTagEnd + 1) + transformCallouts(inner) + CLOSE_TAG;
|
|
62
|
+
}
|
|
63
|
+
pos = match.closeEnd;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
result += html.slice(pos);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
name: 'callout',
|
|
72
|
+
stage: 'after_post_render',
|
|
73
|
+
// 主流 markdown 渲染器/主题已多自带 callout 支持,默认关闭避免双重渲染;
|
|
74
|
+
// 需要时在 preset 配置里 callout: { enable: true } 打开
|
|
75
|
+
enabledByDefault: false,
|
|
76
|
+
css,
|
|
77
|
+
test(content) {
|
|
78
|
+
return content.includes('[!') && content.includes(OPEN_TAG);
|
|
79
|
+
},
|
|
80
|
+
convert(content) {
|
|
81
|
+
return transformCallouts(content);
|
|
82
|
+
},
|
|
83
|
+
_internal: { transformCallouts, findMatchingClose }
|
|
84
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 解析渲染后的 blockquote 内 HTML,识别 Obsidian callout 头部:
|
|
5
|
+
* <p>[!type] title<br>body...</p> 或 <p>[!type] title</p><p>body...</p>
|
|
6
|
+
* 兼容 hexo-renderer-marked 的 breaks: true(<br>)和 breaks: false(\n)两种换行输出。
|
|
7
|
+
* 返回 { type, fold, title, body },不是 callout 时返回 null。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const HEAD_RE = /^\s*<p>\[!([a-zA-Z0-9_-]+)\]([+-])?[ \t]*/;
|
|
11
|
+
|
|
12
|
+
function findTitleEnd(rest) {
|
|
13
|
+
const candidates = [];
|
|
14
|
+
|
|
15
|
+
const br = rest.match(/<br\s*\/?>(\r?\n)?/);
|
|
16
|
+
if (br) candidates.push({ index: br.index, length: br[0].length, kind: 'br' });
|
|
17
|
+
|
|
18
|
+
const nl = rest.indexOf('\n');
|
|
19
|
+
if (nl !== -1) candidates.push({ index: nl, length: 1, kind: 'newline' });
|
|
20
|
+
|
|
21
|
+
const pEnd = rest.indexOf('</p>');
|
|
22
|
+
if (pEnd !== -1) candidates.push({ index: pEnd, length: '</p>'.length, kind: 'p-end' });
|
|
23
|
+
|
|
24
|
+
if (!candidates.length) {
|
|
25
|
+
return { index: rest.length, length: 0, kind: 'eof' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
candidates.sort((a, b) => a.index - b.index);
|
|
29
|
+
return candidates[0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseCalloutInner(inner) {
|
|
33
|
+
const head = inner.match(HEAD_RE);
|
|
34
|
+
if (!head) return null;
|
|
35
|
+
|
|
36
|
+
const type = head[1].toLowerCase();
|
|
37
|
+
const fold = head[2] || '';
|
|
38
|
+
const rest = inner.slice(head[0].length);
|
|
39
|
+
|
|
40
|
+
const end = findTitleEnd(rest);
|
|
41
|
+
const title = rest.slice(0, end.index).trim();
|
|
42
|
+
const after = rest.slice(end.index + end.length);
|
|
43
|
+
|
|
44
|
+
let body;
|
|
45
|
+
if (end.kind === 'p-end') {
|
|
46
|
+
// 标题独占首段,正文是后续兄弟元素
|
|
47
|
+
body = after.replace(/^\r?\n/, '');
|
|
48
|
+
} else if (end.kind === 'eof') {
|
|
49
|
+
body = '';
|
|
50
|
+
} else {
|
|
51
|
+
// 标题和正文同段:去掉标题行后把段落补回去
|
|
52
|
+
body = '<p>' + after;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { type, fold, title, body: body.trim() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
parseCalloutInner
|
|
60
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 把解析出的 callout 结构渲染为 HTML。
|
|
5
|
+
* 普通 callout 输出 div 结构;折叠语法 [!type]- / [!type]+ 输出 <details>(+ 为默认展开)。
|
|
6
|
+
* type 已被 parse 的正则限定为 [a-zA-Z0-9_-],可直接进入 class/attribute。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function defaultTitle(type) {
|
|
10
|
+
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function renderCallout(parsed, bodyHtml) {
|
|
14
|
+
const type = parsed.type;
|
|
15
|
+
const title = parsed.title || defaultTitle(type);
|
|
16
|
+
const classAttr = 'callout callout-' + type;
|
|
17
|
+
const content = bodyHtml ? '<div class="callout-content">' + bodyHtml + '</div>' : '';
|
|
18
|
+
|
|
19
|
+
if (parsed.fold) {
|
|
20
|
+
const openAttr = parsed.fold === '+' ? ' open' : '';
|
|
21
|
+
return (
|
|
22
|
+
'<details class="' + classAttr + '" data-callout="' + type + '"' + openAttr + '>' +
|
|
23
|
+
'<summary class="callout-title">' + title + '</summary>' +
|
|
24
|
+
content +
|
|
25
|
+
'</details>'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
'<div class="' + classAttr + '" data-callout="' + type + '">' +
|
|
31
|
+
'<div class="callout-title">' + title + '</div>' +
|
|
32
|
+
content +
|
|
33
|
+
'</div>'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
renderCallout,
|
|
39
|
+
defaultTitle
|
|
40
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 默认样式,经 hexo injector 注入 head_end;text_pipeline.inject_css: false 可关闭,
|
|
5
|
+
* 关闭后结构类名(callout / callout-title / callout-content / data-callout)由主题自行接管。
|
|
6
|
+
*/
|
|
7
|
+
module.exports = `
|
|
8
|
+
.callout{--callout-color:68,138,255;border-left:4px solid rgb(var(--callout-color));border-radius:4px;padding:.75rem 1rem;margin:1rem 0;background:rgba(var(--callout-color),.08)}
|
|
9
|
+
.callout-title{font-weight:600;color:rgb(var(--callout-color));margin:0 0 .25rem}
|
|
10
|
+
.callout-content>:first-child{margin-top:.25rem}
|
|
11
|
+
.callout-content>:last-child{margin-bottom:0}
|
|
12
|
+
details.callout>summary.callout-title{cursor:pointer}
|
|
13
|
+
.callout[data-callout=tip],.callout[data-callout=hint],.callout[data-callout=important]{--callout-color:0,191,188}
|
|
14
|
+
.callout[data-callout=success],.callout[data-callout=check],.callout[data-callout=done]{--callout-color:68,207,110}
|
|
15
|
+
.callout[data-callout=question],.callout[data-callout=help],.callout[data-callout=faq]{--callout-color:233,196,106}
|
|
16
|
+
.callout[data-callout=warning],.callout[data-callout=caution],.callout[data-callout=attention]{--callout-color:236,117,0}
|
|
17
|
+
.callout[data-callout=danger],.callout[data-callout=error],.callout[data-callout=failure],.callout[data-callout=fail],.callout[data-callout=missing],.callout[data-callout=bug]{--callout-color:233,49,71}
|
|
18
|
+
.callout[data-callout=example]{--callout-color:168,130,255}
|
|
19
|
+
.callout[data-callout=quote],.callout[data-callout=cite],.callout[data-callout=diary]{--callout-color:158,158,158}
|
|
20
|
+
`.trim();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { segmentInlineCode } = require('../../../../core/markdown-guard');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Markdown 阶段剥离 Obsidian 注释:行内 %%...%% 与跨行 %% ... %%。
|
|
7
|
+
* 语义约定(与 Obsidian 一致或取其安全子集):
|
|
8
|
+
* - fenced code / inline code 里的 %% 是字面量,不开启注释;
|
|
9
|
+
* - 注释一旦开启,吞掉直到下一个 %%(中间的代码、围栏一并视为注释内容);
|
|
10
|
+
* - 跨行注释的首行残余与末行残余合并成一行;
|
|
11
|
+
* - 整行都被注释吃掉时不留空行;
|
|
12
|
+
* - 未闭合的 %% 注释到文件末尾(Obsidian 同此行为)。
|
|
13
|
+
*/
|
|
14
|
+
function stripObsidianComments(content) {
|
|
15
|
+
const lines = content.split('\n');
|
|
16
|
+
const out = [];
|
|
17
|
+
|
|
18
|
+
let inFence = false;
|
|
19
|
+
let fenceChar = '';
|
|
20
|
+
let fenceLen = 0;
|
|
21
|
+
let inComment = false;
|
|
22
|
+
let carry = null; // 跨行注释开启行的前缀残余,等闭合行合并
|
|
23
|
+
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
if (!inComment) {
|
|
26
|
+
const match = line.match(/^ {0,3}([`~]{3,})/);
|
|
27
|
+
if (match) {
|
|
28
|
+
const token = match[1];
|
|
29
|
+
if (!inFence) {
|
|
30
|
+
inFence = true;
|
|
31
|
+
fenceChar = token[0];
|
|
32
|
+
fenceLen = token.length;
|
|
33
|
+
} else if (token[0] === fenceChar && token.length >= fenceLen) {
|
|
34
|
+
inFence = false;
|
|
35
|
+
}
|
|
36
|
+
out.push(line);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (inFence) {
|
|
40
|
+
out.push(line);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let lineOut = '';
|
|
46
|
+
let touched = inComment;
|
|
47
|
+
|
|
48
|
+
for (const seg of segmentInlineCode(line)) {
|
|
49
|
+
if (seg.isCode && !inComment) {
|
|
50
|
+
lineOut += seg.text;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const text = seg.text;
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < text.length) {
|
|
56
|
+
const idx = text.indexOf('%%', i);
|
|
57
|
+
if (idx === -1) {
|
|
58
|
+
if (!inComment) lineOut += text.slice(i);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
touched = true;
|
|
62
|
+
if (!inComment) {
|
|
63
|
+
lineOut += text.slice(i, idx);
|
|
64
|
+
inComment = true;
|
|
65
|
+
} else {
|
|
66
|
+
inComment = false;
|
|
67
|
+
}
|
|
68
|
+
i = idx + 2;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (inComment) {
|
|
73
|
+
// 注释延续到下一行:行首残余暂存,整行被吞时什么都不留
|
|
74
|
+
if (carry === null) carry = lineOut;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (carry !== null) {
|
|
79
|
+
lineOut = carry + lineOut;
|
|
80
|
+
carry = null;
|
|
81
|
+
}
|
|
82
|
+
if (touched && lineOut === '') {
|
|
83
|
+
continue; // 整行只有注释,不留空行
|
|
84
|
+
}
|
|
85
|
+
out.push(lineOut);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 未闭合注释:carry 是开启行的残余,按 Obsidian 语义其后内容全部隐藏
|
|
89
|
+
if (carry !== null && carry !== '') {
|
|
90
|
+
out.push(carry);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return out.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
name: 'comment',
|
|
98
|
+
stage: 'before_post_render',
|
|
99
|
+
test(content) {
|
|
100
|
+
return content.includes('%%');
|
|
101
|
+
},
|
|
102
|
+
convert(content) {
|
|
103
|
+
return stripObsidianComments(content);
|
|
104
|
+
},
|
|
105
|
+
_internal: { stripObsidianComments }
|
|
106
|
+
};
|