hexo-theme-gnix 12.0.0 → 14.0.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 (45) hide show
  1. package/README.md +2 -0
  2. package/include/hexo/generator/archive.js +14 -1
  3. package/include/hexo/generator/index.js +0 -5
  4. package/include/hexo/generator/page.js +18 -4
  5. package/include/hexo/generator/tag.js +1 -1
  6. package/include/hexo/helper.js +0 -4
  7. package/include/hexo/i18n.js +31 -136
  8. package/include/hexo/obsidian-callouts.js +210 -0
  9. package/include/hexo/renderer.js +4 -14
  10. package/include/hexo/shiki.js +191 -0
  11. package/include/hexo/sitemap.js +184 -0
  12. package/include/util/i18n.js +92 -106
  13. package/languages/en.yml +4 -10
  14. package/languages/zh-CN.yml +4 -10
  15. package/layout/archive.jsx +155 -78
  16. package/layout/common/article.jsx +94 -108
  17. package/layout/common/article_cover.jsx +3 -3
  18. package/layout/common/article_info.jsx +11 -48
  19. package/layout/common/article_media.jsx +9 -2
  20. package/layout/common/footer.jsx +17 -106
  21. package/layout/common/head.jsx +3 -15
  22. package/layout/common/navbar.jsx +24 -87
  23. package/layout/common/scripts.jsx +1 -1
  24. package/layout/layout.jsx +37 -19
  25. package/layout/plugin/goatcounter.jsx +25 -0
  26. package/layout/tag.jsx +3 -70
  27. package/layout/tags.jsx +26 -23
  28. package/package.json +7 -13
  29. package/scripts/index.js +1 -0
  30. package/source/css/archive.css +287 -168
  31. package/source/css/callout_blocks.css +41 -21
  32. package/source/css/default.css +154 -132
  33. package/source/css/optional/mermaid.css +12 -6
  34. package/source/css/responsive.css +1 -45
  35. package/source/css/shiki/shiki.css +5 -4
  36. package/source/css/tags.css +53 -59
  37. package/source/js/components/archive-popup.js +313 -0
  38. package/source/js/components/friends-list.js +270 -0
  39. package/source/js/components/x-info-card.js +297 -0
  40. package/source/js/main.js +38 -34
  41. package/source/js/mdit/mermaid.js +10 -0
  42. package/include/hexo/generator/home.js +0 -64
  43. package/layout/index.jsx +0 -19
  44. package/layout/misc/paginator.jsx +0 -69
  45. package/source/js/host/iconify-icon/3.0.2/iconify-icon.min.js +0 -12
@@ -0,0 +1,191 @@
1
+ const { codeToHtml } = require("shiki");
2
+ const t = require("@shikijs/transformers");
3
+ const { transformerColorizedBrackets } = require("@shikijs/colorized-brackets");
4
+ const { mkdir, writeFile } = require("node:fs/promises");
5
+ const { dirname } = require("node:path");
6
+
7
+ const THEMES = {
8
+ light: "catppuccin-latte",
9
+ dark: "catppuccin-mocha",
10
+ song: "everforest-light",
11
+ nord: "nord",
12
+ tokyo: "tokyo-night",
13
+ rose: "rose-pine",
14
+ };
15
+
16
+ const TRANSFORMERS = [
17
+ t.transformerCompactLineOptions(),
18
+ t.transformerMetaHighlight(),
19
+ t.transformerMetaWordHighlight(),
20
+ t.transformerNotationDiff(),
21
+ t.transformerNotationErrorLevel(),
22
+ t.transformerNotationFocus(),
23
+ t.transformerNotationHighlight(),
24
+ t.transformerNotationWordHighlight(),
25
+ t.transformerRemoveLineBreak(),
26
+ t.transformerRemoveNotationEscape(),
27
+ t.transformerRenderWhitespace(),
28
+ transformerColorizedBrackets(),
29
+ ];
30
+
31
+ const SVG_WRAP =
32
+ '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="toggle-wrap" title="Toggle Wrap"><path d="m16 16-3 3 3 3"/><path d="M3 12h14.5a1 1 0 0 1 0 7H13"/><path d="M3 19h6"/><path d="M3 5h18"/></svg>';
33
+ const SVG_COPY =
34
+ '<div class="copy-notice"></div><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="copy-button"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><path d="M16 4h2a2 2 0 0 1 2 2v4"/><path d="M21 14H11"/><path d="m15 10-4 4 4 4"/></svg>';
35
+ const SVG_EXPAND =
36
+ '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="expand-icon"><path d="M12 22v-6"/><path d="M12 8V2"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/><path d="m15 19-3 3-3-3"/><path d="m15 5-3-3-3 3"/></svg>';
37
+ const SVG_COLLAPSE =
38
+ '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="collapse-icon"><path d="M12 22v-6"/><path d="M12 8V2"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/><path d="m15 19-3-3-3 3"/><path d="m15 5 3 3-3 3"/></svg>';
39
+
40
+ const RE_LINE = /<span class="line/g;
41
+
42
+ function escapeHtml(code) {
43
+ return code.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
44
+ }
45
+
46
+ function createShikiTools(lang, title, { lang: showLang, title: showTitle, wrapToggle, copyButton }) {
47
+ let left = '<div class="left"><div class="traffic-lights"> <span class="traffic-light red"></span> <span class="traffic-light yellow"></span> <span class="traffic-light green"></span> </div>';
48
+ if (showLang) left += `<div class="code-lang">${lang.toUpperCase()}</div>`;
49
+ left += "</div>";
50
+
51
+ let center = '<div class="center">';
52
+ if (showTitle && title) center += `\n<div class="code-title">${title}</div>`;
53
+ center += "\n</div>";
54
+
55
+ let right = '<div class="right">';
56
+ if (wrapToggle) right += `\n${SVG_WRAP}`;
57
+ if (copyButton) right += `\n${SVG_COPY}`;
58
+ right += "\n</div>";
59
+
60
+ return `<div class="shiki-tools">${left}${center}${right}</div>`;
61
+ }
62
+
63
+ async function writeCssAsync(cssGetter, cssOutputPath) {
64
+ if (!cssGetter || !cssOutputPath) return;
65
+ const css = cssGetter();
66
+ await mkdir(dirname(cssOutputPath), { recursive: true });
67
+ await writeFile(cssOutputPath, css, "utf8");
68
+ }
69
+
70
+ function computeCollapseAttributes(cfg, codeHtml) {
71
+ const codeLines = (codeHtml.match(RE_LINE) || []).length;
72
+ const shouldCollapse = cfg.collapseConfig.enable && codeLines > cfg.collapseConfig.maxLines;
73
+ return {
74
+ expandButton: shouldCollapse ? `<div class="code-expand-btn">${SVG_EXPAND}${SVG_COLLAPSE}</div>` : "",
75
+ collapseAttrs: shouldCollapse ? ` data-collapsible="true" data-max-lines="${cfg.collapseConfig.maxLines}" data-total-lines="${codeLines}"` : "",
76
+ };
77
+ }
78
+
79
+ function parseConfig(renderOptions) {
80
+ const options = renderOptions || {};
81
+ const { toolbar_items: ti = {}, style_to_class: stc } = options;
82
+
83
+ let enabledTransformers;
84
+ if (!options.transformers || options.transformers.includes("all")) {
85
+ enabledTransformers = [...TRANSFORMERS];
86
+ } else {
87
+ enabledTransformers = options.transformers.map((name) => TRANSFORMERS.find((tr) => tr.name === name)).filter(Boolean);
88
+ }
89
+
90
+ let toClass = null;
91
+ if (stc && stc.enable) {
92
+ toClass = t.transformerStyleToClass({ classPrefix: stc.class_prefix || "_sk_" });
93
+ enabledTransformers.push(toClass);
94
+ }
95
+
96
+ const maxLines = options.code_collapse != null ? options.code_collapse : 30;
97
+
98
+ return {
99
+ themes: THEMES,
100
+ excludes: options.exclude_languages || ["mermaid"],
101
+ aliases: options.language_aliases || {},
102
+ collapseConfig: { enable: maxLines > 0, maxLines },
103
+ styleToClass: {
104
+ enable: !!(stc && stc.enable),
105
+ cssGetter: toClass ? toClass.getCSS : undefined,
106
+ css_output_path: stc ? stc.css_output_path : undefined,
107
+ },
108
+ transformers: enabledTransformers,
109
+ toolbarItems: {
110
+ lang: ti.lang != null ? ti.lang : true,
111
+ title: ti.title != null ? ti.title : true,
112
+ wrapToggle: ti.wrapToggle != null ? ti.wrapToggle : true,
113
+ copyButton: ti.copyButton != null ? ti.copyButton : true,
114
+ },
115
+ };
116
+ }
117
+
118
+ function renderCode(md, renderOptions) {
119
+ const cfg = parseConfig(renderOptions);
120
+
121
+ md.renderer.rules.fence = async (tokens, idx) => {
122
+ const token = tokens[idx];
123
+ if (!token) return "";
124
+
125
+ const code = token.content;
126
+ const lang = token.info.split(/\s+/)[0] || "";
127
+ const attrs = token.info.split(/\s+/).slice(1).join(" ");
128
+
129
+ if (cfg.excludes.includes(lang)) {
130
+ const escaped = escapeHtml(code);
131
+ return `<pre><code class="${lang}">${escaped}</code></pre>`;
132
+ }
133
+ const normalizedCode = code.replace(/\r?\n$/, "");
134
+ const mappedLang = cfg.aliases[lang] || lang;
135
+ let codeHtml;
136
+ try {
137
+ codeHtml = await codeToHtml(normalizedCode, {
138
+ lang: mappedLang,
139
+ themes: cfg.themes,
140
+ transformers: cfg.transformers,
141
+ });
142
+ } catch (err) {
143
+ console.warn(`[shiki] Language \`${mappedLang}\` is not supported, falling back to \`txt\`.`);
144
+ codeHtml = await codeToHtml(normalizedCode, {
145
+ lang: "txt",
146
+ themes: cfg.themes,
147
+ transformers: cfg.transformers,
148
+ });
149
+ }
150
+ await writeCssAsync(cfg.styleToClass.cssGetter, cfg.styleToClass.css_output_path);
151
+ codeHtml = codeHtml.replace(/<pre[^>]*>/, (match) => match.replace(/\s*style\s*=\s*"[^"]*"\s*tabindex="0"/, ""));
152
+
153
+ const title = attrs || "";
154
+ const shikiToolsHtml = createShikiTools(lang || "", title, cfg.toolbarItems);
155
+ const { expandButton, collapseAttrs } = computeCollapseAttributes(cfg, codeHtml);
156
+ return `<figure class="shiki" ${collapseAttrs}> ${shikiToolsHtml} ${codeHtml}${expandButton} </figure>`;
157
+ };
158
+
159
+ md.renderer.rules.code_inline = async (tokens, idx, _options, _env, self) => {
160
+ const token = tokens[idx];
161
+ if (!token) return "";
162
+
163
+ const content = token.content.trim();
164
+ const match = content.match(/^\{(\w+)\}\s+(.+)$/);
165
+ if (match === null) {
166
+ return `<code${self.renderAttrs(token)}>${escapeHtml(content)}</code>`;
167
+ }
168
+ const [, lang, code] = match;
169
+ if (!lang || !code) return `<code>${content}</code>`;
170
+ let highlighted;
171
+ try {
172
+ highlighted = await codeToHtml(code, {
173
+ lang: lang,
174
+ themes: cfg.themes,
175
+ structure: "inline",
176
+ });
177
+ } catch (err) {
178
+ console.warn(`[shiki] Language \`${lang}\` is not supported, falling back to \`txt\`.`);
179
+ highlighted = await codeToHtml(code, {
180
+ lang: "txt",
181
+ themes: cfg.themes,
182
+ structure: "inline",
183
+ });
184
+ }
185
+ return `<code${self.renderAttrs(token)}>${highlighted}</code>`;
186
+ };
187
+ }
188
+
189
+ module.exports = renderCode;
190
+ module.exports.default = renderCode;
191
+
@@ -0,0 +1,184 @@
1
+ // Adapted from hexo-generator-sitemap (MIT, (c) Tommy Chen)
2
+ // https://github.com/hexojs/hexo-generator-sitemap
3
+
4
+ const { extname } = require("node:path");
5
+ const { encodeURL, url_for } = require("hexo-util");
6
+
7
+ const DEFAULT_CONFIG = {
8
+ path: ["sitemap.xml", "sitemap.txt"],
9
+ rel: false,
10
+ tags: true,
11
+ categories: true,
12
+ };
13
+
14
+ const DEFAULT_SKIP_PATTERNS = ["**/*.js", "**/*.css"];
15
+ const REL_SITEMAP_RE = /rel=['|"]?sitemap['|"]?/i;
16
+ const HEAD_RE = /<head>(?!<\/head>).+?<\/head>/s;
17
+ const REGEX_ESCAPE_CHARS = "\\^$.+()[]{}|";
18
+
19
+ function globToRegExp(pattern) {
20
+ let regex = "";
21
+ for (let i = 0; i < pattern.length; i++) {
22
+ const ch = pattern[i];
23
+ if (ch === "*") {
24
+ if (pattern[i + 1] === "*") {
25
+ regex += ".*";
26
+ i++;
27
+ if (pattern[i + 1] === "/") i++;
28
+ } else {
29
+ regex += "[^/]*";
30
+ }
31
+ } else if (ch === "?") {
32
+ regex += "[^/]";
33
+ } else if (REGEX_ESCAPE_CHARS.includes(ch)) {
34
+ regex += `\\${ch}`;
35
+ } else {
36
+ regex += ch;
37
+ }
38
+ }
39
+ return new RegExp(`^${regex}$`);
40
+ }
41
+
42
+ function buildMatcher(patterns) {
43
+ const compiled = patterns.map(globToRegExp);
44
+ return (value) => compiled.some((re) => re.test(value));
45
+ }
46
+
47
+ function normalizePaths(rawPath) {
48
+ const paths = Array.isArray(rawPath) ? rawPath : typeof rawPath === "string" ? [rawPath] : DEFAULT_CONFIG.path;
49
+ return paths.filter((p) => typeof p === "string" && p.trim()).map((p) => (extname(p) ? p : `${p}.xml`));
50
+ }
51
+
52
+ function formatDate(date) {
53
+ return date.toISOString().substring(0, 10);
54
+ }
55
+
56
+ function getLastMod(post) {
57
+ const value = post.updated || post.date;
58
+ if (!value) return null;
59
+ if (typeof value.toDate === "function") return value.toDate();
60
+ return value instanceof Date ? value : null;
61
+ }
62
+
63
+ function getSortKey(post) {
64
+ const value = post.updated;
65
+ if (!value) return 0;
66
+ if (typeof value.valueOf === "function") return value.valueOf();
67
+ return value instanceof Date ? value.getTime() : 0;
68
+ }
69
+
70
+ function renderXml({ posts, tags, categories, siteUrl, now }) {
71
+ const formattedNow = formatDate(now);
72
+
73
+ const postEntries = posts
74
+ .map((post) => {
75
+ const lastMod = getLastMod(post);
76
+ const lastModLine = lastMod ? `\n <lastmod>${formatDate(lastMod)}</lastmod>` : "";
77
+ return ` <url>
78
+ <loc>${encodeURL(post.permalink)}</loc>${lastModLine}
79
+ <changefreq>monthly</changefreq>
80
+ <priority>0.6</priority>
81
+ </url>`;
82
+ })
83
+ .join("\n");
84
+
85
+ const taxonomyEntries = (items, freq, priority) =>
86
+ items
87
+ .map(
88
+ (item) => ` <url>
89
+ <loc>${encodeURL(item.permalink)}</loc>
90
+ <lastmod>${formattedNow}</lastmod>
91
+ <changefreq>${freq}</changefreq>
92
+ <priority>${priority}</priority>
93
+ </url>`,
94
+ )
95
+ .join("\n");
96
+
97
+ const sections = [
98
+ postEntries,
99
+ ` <url>
100
+ <loc>${encodeURL(siteUrl)}</loc>
101
+ <lastmod>${formattedNow}</lastmod>
102
+ <changefreq>daily</changefreq>
103
+ <priority>1.0</priority>
104
+ </url>`,
105
+ taxonomyEntries(tags, "weekly", "0.2"),
106
+ taxonomyEntries(categories, "weekly", "0.2"),
107
+ ].filter(Boolean);
108
+
109
+ return `<?xml version="1.0" encoding="UTF-8"?>
110
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
111
+ ${sections.join("\n\n")}
112
+ </urlset>
113
+ `;
114
+ }
115
+
116
+ function renderTxt({ posts, tags, categories, siteUrl }) {
117
+ const lines = [
118
+ ...posts.map((post) => encodeURL(post.permalink)),
119
+ encodeURL(siteUrl),
120
+ ...tags.map((tag) => encodeURL(tag.permalink)),
121
+ ...categories.map((cat) => encodeURL(cat.permalink)),
122
+ ];
123
+ return `${lines.join("\n")}\n`;
124
+ }
125
+
126
+ const RENDERERS = {
127
+ ".xml": renderXml,
128
+ ".txt": renderTxt,
129
+ };
130
+
131
+ module.exports = (hexo) => {
132
+ const config = Object.assign({}, DEFAULT_CONFIG, hexo.config.sitemap);
133
+ config.path = normalizePaths(config.path);
134
+ hexo.config.sitemap = config;
135
+
136
+ hexo.extend.generator.register("sitemap", function (locals) {
137
+ const skipPatterns = [...DEFAULT_SKIP_PATTERNS];
138
+ const userSkip = this.config.skip_render;
139
+ if (Array.isArray(userSkip)) {
140
+ skipPatterns.push(...userSkip);
141
+ } else if (typeof userSkip === "string" && userSkip.length > 0) {
142
+ skipPatterns.push(userSkip);
143
+ }
144
+
145
+ const isSkipped = buildMatcher(skipPatterns);
146
+ const posts = []
147
+ .concat(locals.posts.toArray(), locals.pages.toArray())
148
+ .filter((post) => post.sitemap !== false && !isSkipped(post.source))
149
+ .sort((a, b) => getSortKey(b) - getSortKey(a));
150
+
151
+ if (posts.length === 0) {
152
+ config.rel = false;
153
+ return;
154
+ }
155
+
156
+ const context = {
157
+ posts,
158
+ tags: config.tags ? locals.tags.toArray() : [],
159
+ categories: config.categories ? locals.categories.toArray() : [],
160
+ siteUrl: this.config.url,
161
+ now: new Date(),
162
+ };
163
+
164
+ return config.path
165
+ .map((p) => {
166
+ const renderer = RENDERERS[extname(p)];
167
+ return renderer ? { path: p, data: renderer(context) } : null;
168
+ })
169
+ .filter(Boolean);
170
+ });
171
+
172
+ if (config.rel === true) {
173
+ hexo.extend.filter.register("after_render:html", function (data) {
174
+ const sitemapConfig = hexo.config.sitemap;
175
+ if (!sitemapConfig.rel || REL_SITEMAP_RE.test(data)) return data;
176
+
177
+ const xmlPath = sitemapConfig.path.find((p) => extname(p) === ".xml");
178
+ if (!xmlPath) return data;
179
+
180
+ const tag = `<link rel="sitemap" type="application/xml" title="Sitemap" href="${url_for.call(this, xmlPath)}">`;
181
+ return data.replace(HEAD_RE, (str) => str.replace("</head>", `${tag}</head>`));
182
+ });
183
+ }
184
+ };
@@ -1,17 +1,6 @@
1
1
  const path = require("node:path");
2
2
 
3
- const DEFAULT_LANGUAGES = {
4
- cn: {
5
- label: "Chinese",
6
- locale: "zh-CN",
7
- prefix: "cn",
8
- },
9
- en: {
10
- label: "English",
11
- locale: "en",
12
- prefix: "en",
13
- },
14
- };
3
+ // ─── 基础字符串工具 ────────────────────────────────────────────────
15
4
 
16
5
  function trimSlashes(value) {
17
6
  return String(value || "").replace(/^\/+|\/+$/g, "");
@@ -34,6 +23,8 @@ function isExternalUrl(value) {
34
23
  return /^(?:[a-z][a-z\d+.-]*:)?\/\//i.test(value) || /^(?:mailto|tel|data):/i.test(value) || value.startsWith("#");
35
24
  }
36
25
 
26
+ // ─── i18n 配置解析(带缓存)─────────────────────────────────────────
27
+
37
28
  const i18nConfigCache = new WeakMap();
38
29
 
39
30
  function getI18nConfig(config = {}) {
@@ -41,27 +32,23 @@ function getI18nConfig(config = {}) {
41
32
  return i18nConfigCache.get(config);
42
33
  }
43
34
 
44
- const configI18n = config.i18n || {};
45
- const themeI18n = config.theme_config?.i18n || {};
46
- const raw = configI18n.enabled === true || configI18n.languages ? configI18n : themeI18n.enabled === true || themeI18n.languages ? themeI18n : configI18n;
47
- const rawLanguages = raw.languages || DEFAULT_LANGUAGES;
35
+ const raw = config.i18n || {};
36
+ const rawLanguages = raw.languages || {};
48
37
  const languages = {};
49
38
 
50
39
  Object.keys(rawLanguages).forEach((key) => {
51
40
  const normalizedKey = normalizeLanguageKey(key);
52
- const value = typeof rawLanguages[key] === "string" ? { locale: rawLanguages[key] } : rawLanguages[key] || {};
53
- const locale = normalizeLocale(value.locale || value.lang || value.language || normalizedKey);
54
- const prefix = value.prefix ?? value.path ?? value.url_prefix;
41
+ const value = rawLanguages[key] || {};
55
42
  languages[normalizedKey] = {
56
43
  key: normalizedKey,
57
- label: value.label || value.name || locale || normalizedKey,
58
- locale,
59
- prefix: normalizePrefix(prefix, normalizedKey),
44
+ label: value.label || normalizedKey,
45
+ locale: normalizeLocale(value.locale || normalizedKey),
46
+ prefix: normalizePrefix(value.prefix, normalizedKey),
60
47
  };
61
48
  });
62
49
 
63
50
  const keys = Object.keys(languages);
64
- const configuredDefault = normalizeLanguageKey(raw.default || raw.default_language || raw.defaultLanguage || keys[0]);
51
+ const configuredDefault = normalizeLanguageKey(raw.default || keys[0]);
65
52
  const defaultLanguage = languages[configuredDefault] ? configuredDefault : keys[0];
66
53
 
67
54
  const result = {
@@ -92,22 +79,23 @@ function getLanguage(config = {}, key) {
92
79
  return i18n.languages[normalizedKey] || i18n.languages[i18n.defaultLanguage];
93
80
  }
94
81
 
95
- function getLanguageKeyFromLocale(config = {}, locale) {
96
- const normalizedLocale = normalizeLocale(locale).toLowerCase();
97
- if (!normalizedLocale) return null;
82
+ function getLanguageBasePath(config, key) {
83
+ return getLanguage(config, key)?.prefix || "";
84
+ }
85
+
86
+ // ─── 语言键查询(私有辅助)─────────────────────────────────────────
87
+
88
+ function getLanguageKeyFromLocale(config, locale) {
89
+ const normalized = normalizeLocale(locale).toLowerCase();
90
+ if (!normalized) return null;
98
91
 
99
92
  const i18n = getI18nConfig(config);
100
- if (i18n.languages[normalizedLocale]) return normalizedLocale;
93
+ if (i18n.languages[normalized]) return normalized;
101
94
 
102
- return (
103
- Object.keys(i18n.languages).find((key) => {
104
- const language = i18n.languages[key];
105
- return language.locale.toLowerCase() === normalizedLocale;
106
- }) || null
107
- );
95
+ return Object.keys(i18n.languages).find((key) => i18n.languages[key].locale.toLowerCase() === normalized) || null;
108
96
  }
109
97
 
110
- function getLanguageKeyFromPath(value, config = {}) {
98
+ function getLanguageKeyFromPath(value, config) {
111
99
  const normalized = trimSlashes(value);
112
100
  if (!normalized) return null;
113
101
 
@@ -120,30 +108,69 @@ function getLanguageKeyFromPath(value, config = {}) {
120
108
  );
121
109
  }
122
110
 
123
- function getLanguageKeyFromSource(value, config = {}) {
124
- const normalized = trimSlashes(value).replace(/\\/g, "/");
125
- if (!normalized) return null;
111
+ // ─── 源路径解析:从文件名识别 __<lang> 后缀 ──────────────────────────
126
112
 
127
- const firstSegment = normalized.split("/")[0];
128
- const i18n = getI18nConfig(config);
129
- return i18n.languages[firstSegment] ? firstSegment : null;
113
+ function parseLocalizedSource(source) {
114
+ if (typeof source !== "string" || !source) {
115
+ return { langKey: null, baseSource: "" };
116
+ }
117
+
118
+ const normalized = source.replace(/\\/g, "/");
119
+ const lastSlash = normalized.lastIndexOf("/");
120
+ const dir = lastSlash >= 0 ? normalized.slice(0, lastSlash + 1) : "";
121
+ const filename = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
122
+ const ext = path.posix.extname(filename);
123
+ const stem = ext ? filename.slice(0, -ext.length) : filename;
124
+
125
+ const match = stem.match(/^(.+?)__([a-zA-Z][\w-]*)$/);
126
+ if (!match) return { langKey: null, baseSource: normalized };
127
+
128
+ return {
129
+ langKey: match[2],
130
+ baseSource: dir + match[1] + ext,
131
+ };
132
+ }
133
+
134
+ function inferI18nKeyFromSource(source) {
135
+ if (typeof source !== "string") return "";
136
+ const { baseSource } = parseLocalizedSource(source);
137
+ const normalized = trimSlashes(baseSource || source).replace(/\\/g, "/");
138
+ const ext = path.posix.extname(normalized);
139
+ const withoutExt = ext ? normalized.slice(0, -ext.length) : normalized;
140
+ const parts = withoutExt.split("/").filter(Boolean);
141
+ if (!parts.length) return "";
142
+
143
+ const last = parts[parts.length - 1];
144
+ if (last === "index" && parts.length >= 2) {
145
+ return parts[parts.length - 2];
146
+ }
147
+ return last;
148
+ }
149
+
150
+ function getI18nKey(item = {}) {
151
+ if (item.i18n_key) return item.i18n_key;
152
+
153
+ const fromSource = inferI18nKeyFromSource(item.source);
154
+ if (fromSource) return fromSource;
155
+
156
+ return item.slug || "";
130
157
  }
131
158
 
159
+ // ─── 单个 post / page 的语言归属查询 ────────────────────────────────
160
+
132
161
  function getPageLanguageKey(page = {}, config = {}) {
133
162
  if (!isI18nEnabled(config)) {
134
- const configuredLanguage = Array.isArray(config.language) ? config.language[0] : config.language;
135
- return getLanguageKeyFromLocale(config, page.lang || page.language || configuredLanguage) || normalizeLanguageKey(page.lang || page.language || configuredLanguage) || getDefaultLanguageKey(config);
163
+ const fallback = Array.isArray(config.language) ? config.language[0] : config.language;
164
+ return getLanguageKeyFromLocale(config, page.lang || page.language || fallback) || getDefaultLanguageKey(config);
136
165
  }
137
166
 
138
- const explicit = normalizeLanguageKey(page.i18n_lang || page.i18n?.lang || page.i18n?.language);
167
+ const explicit = normalizeLanguageKey(page.i18n_lang);
139
168
  if (explicit && getLanguage(config, explicit)?.key === explicit) return explicit;
140
169
 
141
- const fromSource = getLanguageKeyFromSource(page.source || page.full_source || "", config);
170
+ const fromSource = parseLocalizedSource(page.source || page.full_source || "").langKey;
142
171
  if (fromSource) return fromSource;
143
172
 
144
- const pathDescriptor = Object.getOwnPropertyDescriptor(page, "path");
145
- const pathValue = pathDescriptor && typeof pathDescriptor.get !== "function" ? pathDescriptor.value : "";
146
- const fromPath = getLanguageKeyFromPath(pathValue || page.canonical_path || "", config);
173
+ const fromPath = getLanguageKeyFromPath(page.path || page.canonical_path || "", config);
147
174
  if (fromPath) return fromPath;
148
175
 
149
176
  const fromLocale = getLanguageKeyFromLocale(config, page.lang || page.language);
@@ -154,30 +181,22 @@ function getPageLanguageKey(page = {}, config = {}) {
154
181
 
155
182
  function getPageLocale(page = {}, config = {}) {
156
183
  if (!isI18nEnabled(config)) {
157
- const configuredLanguage = Array.isArray(config.language) ? config.language[0] : config.language;
158
- return normalizeLocale(page.lang || page.language || configuredLanguage || "");
184
+ const fallback = Array.isArray(config.language) ? config.language[0] : config.language;
185
+ return normalizeLocale(page.lang || page.language || fallback || "");
159
186
  }
160
187
 
161
188
  const language = getLanguage(config, getPageLanguageKey(page, config));
162
189
  return language?.locale || normalizeLocale(page.lang || page.language) || "";
163
190
  }
164
191
 
165
- function getLanguageLabel(config = {}, keyOrLocale) {
166
- const key = getLanguage(config, keyOrLocale)?.key === normalizeLanguageKey(keyOrLocale) ? normalizeLanguageKey(keyOrLocale) : getLanguageKeyFromLocale(config, keyOrLocale);
167
- const language = getLanguage(config, key || getDefaultLanguageKey(config));
168
- return language?.label || keyOrLocale;
169
- }
170
-
171
- function getLanguageBasePath(config = {}, key) {
172
- return getLanguage(config, key)?.prefix || "";
173
- }
192
+ // ─── 路径生成 ──────────────────────────────────────────────────────
174
193
 
175
194
  function joinRoute(...parts) {
176
195
  const joined = parts.map(trimSlashes).filter(Boolean).join("/");
177
196
  return joined ? `${joined}/` : "";
178
197
  }
179
198
 
180
- function stripLanguagePrefix(value, config = {}) {
199
+ function stripLanguagePrefix(value, config) {
181
200
  const normalized = trimSlashes(value);
182
201
  const key = getLanguageKeyFromPath(normalized, config);
183
202
  if (!key) return normalized;
@@ -193,35 +212,12 @@ function localizePath(value, key, config = {}) {
193
212
 
194
213
  const [pathAndQuery, hash = ""] = value.split("#");
195
214
  const [pathname, query = ""] = pathAndQuery.split("?");
196
- const route = stripLanguagePrefix(pathname, config);
215
+ const route = trimSlashes(stripLanguagePrefix(pathname, config));
197
216
  const base = trimSlashes(getLanguageBasePath(config, key || getDefaultLanguageKey(config)));
198
- const normalizedRoute = trimSlashes(route);
199
- const joined = [base, normalizedRoute].filter(Boolean).join("/");
200
- const hasFileExtension = /\.[^/]+$/.test(normalizedRoute);
217
+ const joined = [base, route].filter(Boolean).join("/");
218
+ const hasFileExtension = /\.[^/]+$/.test(route);
201
219
  const localized = `/${joined}${joined && !hasFileExtension ? "/" : ""}`;
202
- const queryPart = query ? `?${query}` : "";
203
- const hashPart = hash ? `#${hash}` : "";
204
- return `${localized}${queryPart}${hashPart}`;
205
- }
206
-
207
- function toArray(collection) {
208
- if (!collection) return [];
209
- if (typeof collection.toArray === "function") return collection.toArray();
210
- if (Array.isArray(collection.data)) return collection.data;
211
- if (Array.isArray(collection)) return collection;
212
- return [];
213
- }
214
-
215
- function filterByLanguage(collection, key, config = {}) {
216
- if (!isI18nEnabled(config)) return collection;
217
- if (typeof collection?.filter === "function" && !Array.isArray(collection)) {
218
- return collection.filter((item) => getPageLanguageKey(item, config) === key);
219
- }
220
- return toArray(collection).filter((item) => getPageLanguageKey(item, config) === key);
221
- }
222
-
223
- function getI18nKey(item = {}) {
224
- return item.i18n_key || item.i18n?.key || item.translation_key || item.slug || inferI18nKeyFromSource(item.source);
220
+ return `${localized}${query ? `?${query}` : ""}${hash ? `#${hash}` : ""}`;
225
221
  }
226
222
 
227
223
  function getLocalizedTagPath(tag, key, config = {}) {
@@ -237,44 +233,34 @@ function getLocalizedTagPath(tag, key, config = {}) {
237
233
  return joinRoute(getLanguageBasePath(config, key), tagDir, slug);
238
234
  }
239
235
 
240
- function inferI18nKeyFromSource(source) {
241
- if (typeof source !== "string") return "";
242
- const normalized = trimSlashes(source).replace(/\\/g, "/");
243
- const ext = path.posix.extname(normalized);
244
- const withoutExt = ext ? normalized.slice(0, -ext.length) : normalized;
245
- const parts = withoutExt.split("/").filter(Boolean);
246
- if (!parts.length) return "";
236
+ // ─── 集合按语言筛选 ────────────────────────────────────────────────
247
237
 
248
- const last = parts[parts.length - 1];
249
- if (last === "index" && parts.length >= 2) {
250
- return parts[parts.length - 2];
238
+ function filterByLanguage(collection, key, config = {}) {
239
+ if (!isI18nEnabled(config)) return collection;
240
+ // Hexo Query 对象:保留链式能力
241
+ if (typeof collection?.filter === "function" && !Array.isArray(collection)) {
242
+ return collection.filter((item) => getPageLanguageKey(item, config) === key);
251
243
  }
252
-
253
- return last;
244
+ // 普通数组 / 含 toArray 的对象
245
+ const arr = Array.isArray(collection) ? collection : typeof collection?.toArray === "function" ? collection.toArray() : [];
246
+ return arr.filter((item) => getPageLanguageKey(item, config) === key);
254
247
  }
255
248
 
256
249
  module.exports = {
257
250
  filterByLanguage,
258
251
  getDefaultLanguageKey,
259
- getI18nConfig,
260
252
  getI18nKey,
261
253
  getLanguage,
262
254
  getLanguageBasePath,
263
- getLanguageKeyFromLocale,
264
- getLanguageKeyFromPath,
265
- getLanguageKeyFromSource,
266
255
  getLanguageKeys,
267
- getLanguageLabel,
268
256
  getLocalizedTagPath,
269
257
  getPageLanguageKey,
270
258
  getPageLocale,
271
259
  inferI18nKeyFromSource,
272
260
  isExternalUrl,
273
261
  isI18nEnabled,
274
- joinRoute,
275
262
  localizePath,
276
263
  normalizeLocale,
277
- stripLanguagePrefix,
278
- toArray,
264
+ parseLocalizedSource,
279
265
  trimSlashes,
280
266
  };