文章样式测试

标题

h1

h2

h3

h4

h5
h6

代码块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
  /** 把代码块替换为 .monaco-block 容器,返回渲染任务 */
function createHost(block) {
var text = extractText(block.el);
var lang = inferLang(block.el);
var lines = Math.max(1, text.split('\n').length);
var host = document.createElement('div');
host.className = 'monaco-block';
// 提取代码块标题(hexo <figcaption>)
var caption = block.el.querySelector ? block.el.querySelector('figcaption') : null;
var title = caption && caption.textContent.trim() ? caption.textContent.trim() : '';
if (title) {
var titleBar = document.createElement('div');
titleBar.className = 'monaco-block-title';
titleBar.textContent = title;
host.appendChild(titleBar);
}
var height = title ? 30 : 0;
host.style.height = Math.min(520, lines * 20 + height + 32) + 'px';
host.setAttribute('data-monaco-lang', lang || 'plaintext');
var parent = block.el.parentNode;
parent.replaceChild(host, block.el);
return { host: host, text: text, lang: lang || 'plaintext' };
}

/** 懒加载 monaco(本地 vendor → CDN 兜底) */
function ensureMonaco() {
if (window.monaco) return Promise.resolve();
if (loadPromise) return loadPromise;

loadPromise = new Promise(function (resolve, reject) {
var css = document.createElement('link');
css.rel = 'stylesheet';
css.href = MONACO_BASE + '/editor/editor.main.css';
document.head.appendChild(css);

var script = document.createElement('script');
script.src = MONACO_BASE + '/loader.js';
script.onload = function () { bootMonaco(resolve, reject, MONACO_BASE); };
script.onerror = function () {
var s2 = document.createElement('script');
s2.src = MONACO_CDN + '/loader.js';
s2.onload = function () { bootMonaco(resolve, reject, MONACO_CDN); };
s2.onerror = function () { reject(new Error('monaco load failed')); };
document.head.appendChild(s2);
};
document.head.appendChild(script);
});
return loadPromise;
}

/**
* Hexo 脚本 - 自动在 generate/server 前加载说说数据
* 扫描 source/_talks/ 目录,渲染 Markdown,输出 source/_data/talks.json
* 支持局部加密:正文中用 <encrypt password="xxx">内容</encrypt> 标记加密片段
*/
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { marked } = require("marked");

const TALKS_DIR = path.join(hexo.base_dir, "source", "_talks");
const OUTPUT_FILE = path.join(hexo.base_dir, "source", "_data", "talks.json");

marked.setOptions({ gfm: true, breaks: true });

const ENC_ALGO = "aes-256-cbc";
const KEY_LENGTH = 32;
const IV_LENGTH = 16;
const PBKDF2_ITER = 100000;
const PBKDF2_DIGEST = "sha256";

function deriveKey(password, salt) {
return crypto.pbkdf2Sync(password, salt, PBKDF2_ITER, KEY_LENGTH, PBKDF2_DIGEST);
}

function encryptContent(text, password) {
const salt = crypto.randomBytes(16);
const key = deriveKey(password, salt);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
const enc = cipher.update(text, "utf8", "hex") + cipher.final("hex");
return salt.toString("hex") + "$" + iv.toString("hex") + "$" + enc;
}

function parseFrontMatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { meta: {}, body: raw };
const meta = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
let value = line.slice(idx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
meta[key] = value;
}
return { meta, body: match[2].trim() };
}

function fixImagePath(html) {
return html.replace(/src="(?!https?:\/\/|\/|data:)([^"]*assets\/[^"]*)"/g, 'src="/$1"');
}

function renderMarkdown(text) {
if (!text) return "";
let html = marked.parse(text, { gfm: true, breaks: true });
html = typeof html === "string" ? html.trim() : String(html).trim();
return fixImagePath(html);
}

// 判断是否为全角字符(中文/日文/全角标点等占 2 个半角宽度,用于估算显示宽度)
function isFullWidthChar(ch) {
const code = ch.codePointAt(0);
return (
code >= 0x1100 && (
code <= 0x115f || // Hangul Jamo
code === 0x2329 || code === 0x232a || // 〈 〉
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) || // CJK 等
(code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
(code >= 0xf900 && code <= 0xfaff) || // CJK 兼容表意文字
(code >= 0xfe10 && code <= 0xfe19) || // 全角竖排标点
(code >= 0xfe30 && code <= 0xfe6f) || // CJK 兼容字符
(code >= 0xff00 && code <= 0xff60) || // 全角 ASCII 形式
(code >= 0xffe0 && code <= 0xffe6) || // 全角货币符号等
(code >= 0x1f300 && code <= 0x1faff) || // Emoji
(code >= 0x20000 && code <= 0x3fffd)) // CJK 扩展 B 及以上
);
}

// 估算字符串的显示宽度(全角=2,半角=1)
function displayWidth(str) {
let w = 0;
for (const ch of String(str)) {
w += isFullWidthChar(ch) ? 2 : 1;
}
return w;
}

// 按原文每行生成马赛克:行数与原文一致、每行长度与对应行一致,行间用 <br> 分隔
function buildMosaicHtml(innerContent) {
const mosaicBase = "lorem ipsum dolor sit amet ";
const lines = String(innerContent)
.trim()
.split(/\r?\n/)
.filter((l) => l.trim().length > 0);
return lines
.map((line) => {
const w = Math.max(displayWidth(line), 2);
return mosaicBase.repeat(Math.ceil(w / mosaicBase.length)).substring(0, w);
})
.join("<br>");
}

/**
* 从原始 Markdown body 中提取 <encrypt>...</encrypt> 块,使用统一密码
* 返回 { cleanBody, sections }
*/
function processEncryptTags(rawBody, talkId, encryptPassword) {
const sections = [];
if (!encryptPassword) return { cleanBody: rawBody, sections };

const regex = /<encrypt>([\s\S]*?)<\/encrypt>/gi;
let idx = 0;

const cleanBody = rawBody.replace(regex, (fullMatch, innerContent) => {
const innerHtml = renderMarkdown(innerContent.trim());
const encrypted = encryptContent(innerHtml, encryptPassword);
const sectionId = talkId + "-" + idx;
idx++;
sections.push({ id: sectionId, encrypted: encrypted });

// 按原文行结构生成马赛克(每行宽度与对应行一致,行间 <br>,体现内容量与分行)
const mosaicHtml = buildMosaicHtml(innerContent);
return '<span class="talk-encrypt-blob" data-encrypt-id="' + sectionId + '"><span class="encrypt-mosaic">' + mosaicHtml + '</span></span>';
});

return { cleanBody, sections };
}

/**
* 聚合相邻 30 分钟内的说说(追加时长模式:组内最后一条 + 30 分钟内有新消息则并入并重置窗口),
* 合并后显示时间范围 HH:MM-HH:MM
*/
const AGGREGATE_WINDOW_MS = 30 * 60 * 1000;

function parseTalkTime(dateStr) {
const s = String(dateStr || "").trim();
const t = new Date(s.replace(" ", "T")).getTime();
return Number.isNaN(t) ? 0 : t;
}

function normalizeTime(timeStr) {
return String(timeStr || "").slice(0, 5);
}

function aggregateTalks(talks) {
const asc = [...talks].sort((a, b) => parseTalkTime(a.date) - parseTalkTime(b.date));
const groups = [];
for (const t of asc) {
const ts = parseTalkTime(t.date);
const last = groups.length ? groups[groups.length - 1] : null;
if (last && ts - last.lastTs <= AGGREGATE_WINDOW_MS) {
last.list.push(t);
last.lastTs = ts; // 窗口重新计算
} else {
groups.push({ lastTs: ts, list: [t] });
}
}
return groups
.map((g) => {
if (g.list.length === 1) {
return { ...g.list[0], timeStr: normalizeTime(g.list[0].timeStr) };
}
const first = g.list[0];
const lastT = g.list[g.list.length - 1];
const firstTime = normalizeTime(first.timeStr);
const lastTime = normalizeTime(lastT.timeStr);
const timeStr = firstTime && lastTime && firstTime !== lastTime
? `${firstTime}-${lastTime}`
: firstTime || lastTime;
return {
...first,
timeStr,
date: first.date,
dateStr: first.dateStr,
// 除最后一条外,每条消息末尾加换行图标(尽量插到 </p> 内,紧跟最后一个字)
content: g.list.map((x, i) => {
let c = x.content || "";
if (i < g.list.length - 1) {
c = /<\/p>\s*$/.test(c)
? c.replace(/<\/p>\s*$/, '<span class="talk-break"> ↵</span></p>')
: c + '<span class="talk-break"> ↵</span>';
}
return c;
}).join(""), // 文字+图片顺序拼接
tags: [...new Set(g.list.flatMap((x) => x.tags || []))],
encryptedSections: g.list.flatMap((x) => x.encryptedSections || []),
encrypted: g.list.some((x) => x.encrypted),
likes: g.list.reduce((s, x) => s + (x.likes || 0), 0),
// 方案 A:保留组内原始成员(每条含独立 id/content/图片/tags),
// 前端拆条展示,每条可独立编辑;加密段仍走顶层扁平化 encryptedSections 索引
members: g.list.map((x) => ({
id: x.id,
timeStr: normalizeTime(x.timeStr),
content: x.content,
image: x.image,
tags: x.tags || [],
})),
};
})
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
}

function loadTalks() {
if (!fs.existsSync(TALKS_DIR)) {
fs.mkdirSync(TALKS_DIR, { recursive: true });
}

const encryptPassword = hexo.config.talks_encrypt && hexo.config.talks_encrypt.password
? hexo.config.talks_encrypt.password : "";

const files = fs.readdirSync(TALKS_DIR).filter((f) => path.extname(f) === ".md").sort();
let talks = [];
let hasAnyEncrypted = false;

for (const f of files) {
try {
const raw = fs.readFileSync(path.join(TALKS_DIR, f), "utf-8");
const { meta, body } = parseFrontMatter(raw);
const talkId = f.replace(/\.md$/, "");
const dateStr = meta.date || "";
const tags = meta.tags ? meta.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];

const { cleanBody, sections } = processEncryptTags(body, talkId, encryptPassword);
const content = renderMarkdown(cleanBody);

if (sections.length > 0) hasAnyEncrypted = true;

talks.push({
id: talkId,
date: dateStr,
dateStr: dateStr.split(" ")[0] || dateStr,
timeStr: (dateStr.split(" ").slice(1).join(" ") || "").slice(0, 5),
city: meta.city || "",
weather: meta.weather || "",
temp: meta.temp || "",
image: meta.image || "",
tags: tags,
likes: parseInt(meta.likes) || 0,
comments: meta.comments !== "false",
encrypted: sections.length > 0,
encryptedSections: sections,
content: content,
});
} catch (err) {
hexo.log.warn(` ⚠ 解析说说 ${f} 失败: ${err.message}`);
}
}

talks.sort((a, b) => b.date.localeCompare(a.date));

// 聚合相邻 30 分钟内的说说(追加时长模式)
talks = aggregateTalks(talks);

const dataDir = path.dirname(OUTPUT_FILE);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}

// 比较时排除加密 hex(每次随机 salt/IV 会不同),避免死循环
function stableForCompare(json) {
return json.replace(/"[a-f0-9]{32}\$[a-f0-9]{32}\$[a-f0-9]+"/g, '"__ENC__"');
}

const newContent = JSON.stringify(talks, null, 2);
const oldContent = fs.existsSync(OUTPUT_FILE) ? fs.readFileSync(OUTPUT_FILE, "utf-8") : "";

if (stableForCompare(newContent) !== stableForCompare(oldContent)) {
fs.writeFileSync(OUTPUT_FILE, newContent, "utf-8");
hexo.log.info(`📢 说说: 加载了 ${talks.length} 条` + (hasAnyEncrypted ? "(含加密内容)" : ""));
}

return talks;
}

// 在 generate 和 server 之前自动生成数据
hexo.on("generateBefore", () => {
const talks = loadTalks();

// 关键:除了写文件,还要直接更新 Data 模型。
// hexo 的 site.data 来自 Data 模型(在 source.process() 阶段加载 _data/*.json 填充),
// generateBefore 触发时 Data 模型已经加载完,重新写文件不会让它生效。
// 这里把最新数据塞进 Data 模型,配合 generateBefore 之后的 locals.invalidate(),
// 渲染时 site.data.talks 就能拿到最新数据(即使 talks.json 被删除/过期)。
try {
const Data = hexo.model("Data");
Data.save({ _id: "talks", data: talks });
} catch (err) {
hexo.log.warn(` ⚠ 更新 Data 模型失败: ${err.message}`);
}
});

test

1
2
3
console.log('Hello from the post!')
console.log('Hello from the post!')
console.log('Hello from the post!')

test

1
console.log('Hello from the post!')

test
test
inline span