Customization
This page explains how to add your own things to the blog: custom JS/CSS, the inject_point mechanism, custom tag plugins, and how theme config is overridden. Editing code is a “developer-level” operation — read the Installation chapter first to understand the directory structure, and pick the release method per the Deployment chapter when done.
Core principle: don’t modify the theme source unless you have to. Prefer inject_point injection and config overrides — theme upgrades (submodule
themes/matery/) won’t drag you into conflicts.
1. Custom JS/CSS
Purpose: append scripts and styles site-wide or per-page; the most common kind of customization.
Method 1: put files under source/ and reference them in the page (no theme source changes):
<!-- reference directly in the post/page body -->
<link rel="stylesheet" href="/js/my-custom.css">
<script src="/js/my-custom.js" defer></script>- Files go in
source/js/,source/css/or any directory; they’re copied as-is to the matchingpublic/path at build time - Note the
skip_rendersection of_config.yml: some directories undersource/(e.g.nav/,docs/,live2d/) skip rendering — custom HTML in those directories stays as-is; plain.js/.cssare unaffected - In production with CDN, static asset URLs are decided by the theme
cdnsection (see the Global Configuration chapter); custom files referenced directly as/js/xxx.jsare served from the site’s own domain
Method 2: inject_point global injection (recommended, next section) — scripts/styles take effect on all pages without touching theme files.
2. The inject_point Mechanism
Purpose: insert HTML/JS/CSS at fixed page positions without modifying theme templates. The theme inherited an injection system from NexT; layout/layout.ejs actually uses 3 view injection points:
<!-- layout.ejs -->
<%- inject_point('bodyBegin') %> <!-- start of <body> -->
<%- inject_point('header') %> <!-- header position -->
<%- inject_point('bodyEnd') %> <!-- before </body> -->How it works: injected content is not an HTML string loaded from theme config — hexo scripts register it into a runtime registry via the theme_inject filter. scripts/events/lib/injects.js runs hexo.execFilterSync('theme_inject', injects) before generation and writes the result into theme.config.injects; the inject_point helper (scripts/helpers/engine.js) reads that registry at render time and outputs it.
Using the theme’s real implementation (scripts/filters/default-injects.js) as the template, custom injections go in any new file under the theme’s scripts/ (e.g. scripts/filters/my-injects.js):
// themes/matery/scripts/filters/my-injects.js
'use strict';
const path = require('path');
hexo.extend.filter.register('theme_inject', function(injects) {
// file(name, file-path): register a template file (extension from the file when name has none; path relative to the hexo root)
injects.bodyEnd.file('my-custom', path.join(hexo.theme_dir, 'layout/_partial/common/my-custom.ejs'));
// raw(name, content-string): register HTML/JS content directly
injects.bodyEnd.raw('my-analytics', '<script src="/js/my-custom.js" defer></script>');
}, -99); // -99 = run first, consistent with default-injects.jsThe template file layout/_partial/common/my-custom.ejs is rendered as-is at the <%- inject_point('bodyEnd') %> position. injects.<point>.file(name, path, locals, options, order) — from the third argument on it’s locals/options/order; order sorts multiple injections at the same point.
bodyEndis the most common injection point: global scripts, analytics code, floating widgets- The theme defines 12 view injection points (
headEnd/header/bodyBegin/bodyEnd/footer/postMetaTop/postMarkdownBegin/postMarkdownEnd/postCopyright/postComments/pageComments/linksComments), and all 12 are called in templates:headEnd(head.ejs),footer(footer.ejs),postMetaTop/postMarkdownBegin/postMarkdownEnd/postCopyright/postComments(post-detail.ejs),bodyBegin/header/bodyEnd(layout.ejs),pageComments(bb/msg),linksComments(friends).postMetaBottom/postLeft/postRightwere removed from the injection-point definitions (not “defined but not called”; comment pages useinject_point('pageComments') || partial('_partial/comments')as a fallback) - Style injection points (
variable/mixin/style) are a separate mechanism: they register.stylfile paths, injected at Stylus compile time — don’t mix them with view injection points - Injected content is output as-is; use
deferon<script>tags (see the LCP layering principle in the Performance Optimization chapter)
3. Custom Tag Plugins
Purpose: use shortcodes in Markdown to generate complex HTML. The theme ships 14 tag plugins (note/tabs/timeline/mermaid/pdf/github-card etc.; usage in the Tag Plugins chapter) — write your own when they’re not enough.
Implementation: add a file under themes/matery/scripts/tags/ exporting a Hexo tag function:
// themes/matery/scripts/tags/mybox.js
'use strict';
function mybox(args, content) {
const title = args.join(' ') || 'Note';
return `<div class="my-box"><strong>${title}</strong><div class="my-box-body">${hexo.render.renderSync({ text: content, engine: 'markdown' })}</div></div>`;
}
hexo.extend.tag.register('mybox', mybox, { ends: true });Use it in posts:
{% mybox Notes %}
Write **any Markdown** here; it renders as the card content.
{% endmybox %}{ ends: true }means a closing{% endmybox %}tag is requiredcontentis the raw text by default; render Markdown withhexo.render.renderSyncwhen needed (note: a Hexo internal API, only runs at build time)- The filename is the plugin name (
mybox.js→{% mybox %}); after changes alwayshexo clean && hexo generate(see the cache issues in the FAQ chapter) - For global availability without touching theme source: putting the file in the theme
scripts/is the only way (the theme is a submodule; changes need dual-repo commits, see the Deployment chapter)
4. Theme Config Override (Template Injection Mechanism)
Purpose: theme config is generated dynamically at CI build time — userConfig/_config.tmp.yml is the template, copied to themes/matery/_config.yml at build time with placeholders replaced. Theme config changes must go in the template; editing themes/matery/_config.yml directly gets overwritten.
Template placeholders (replaced by tools/cicd.sh via sed):
| Placeholder | Replaced with | Example |
|---|---|---|
{cdnPathVersion} | jsDelivr versioned path | https://cdn.jsdelivr.net/gh/appotry/hexo@1.1 |
{cdnPathLatest} | latest CDN path | https://cdn.jsdelivr.net/gh/appotry/hexo@latest |
{urlVersion} | file version number | ?v={urlVersion} |
{cdnUrl} / {mediaUrl} / {resUrl} | CDN domains | https://cfblog.17lai.site |
Full chain for a custom config item (example: adding a “my switch”):
- Template
userConfig/_config.tmp.ymladd the key:myFeature: enable: true text: Hello - Read in Stylus (under
themes/matery/source/css/):$my-feature-color = theme-config("myFeature.text", "default value") - Read in templates (EJS under
layout/):<% if (theme.myFeature.enable) { %> <div><%= theme.myFeature.text %></div> <% } %> - Release: after config changes run
tools/cicd.sh -r "feature"to bump the version (config counts as code-side changes; see the release flow selection in the Deployment chapter)
Note: the tools/cicd.sh build pipeline injects config (sed-replaces placeholders in the userConfig/_config.tmp.yml template), processes image URLs (imgurl.sh --weserv2githubpage), and generates the PWA Service Worker (userConfig/sw.tmp.js → source/sw.js); grep before editing the template to confirm key names aren’t used by build scripts.
5. Custom File Config Keys (custom_file_path / custom_js / custom_css)
Purpose: specify custom JS/CSS file paths directly in the theme config, auto-loaded site-wide — simpler than §1 (in-page references) and §2 (inject points); ideal for “just add a script/style”.
Config (theme config):
# Specify custom .js file paths, list supported; paths are relative to the source directory
# e.g. /js/custom.js corresponds to source/js/custom.js
custom_js:
- /js/custom.js
# Specify custom .css file paths, same usage as custom_js
custom_css:
- /css/custom.css
# Custom file path (Next theme inject reference, empty in this project)
custom_file_path:Notes:
- Place files under
source/js/,source/css/etc.; they are copied as-is topublic/at build time custom_js/custom_cssare lists — configure multiple files, loaded in ordercustom_file_pathreferences Next theme default-injects.js; unused in this project, leave empty
6. Encryption Architecture (HBE v4)
Purpose: theme-built-in encryption (developed in-house 2026-08-09, based on hexo-blog-encrypt v4, wire format format: '4'), covering three encryption surfaces:
| # | Surface | Declaration | Implementation |
|---|---|---|---|
| a | Entire post | front-matter password, or post tag matching encrypt.tags | scripts/encrypt/index.js after_post_render filter (priority 1000) |
| b | Inline snippet | Markdown {% encrypt password "prompt title" "content summary" %}…{% endencrypt %} | scripts/tags/encrypt-tag.js stages + same filter (priority 2000) second pass |
| c | Wiki / gallery page | Wiki sidebar encrypt_password (see the Multilingual & Wiki System page §10); gallery uses encryptHtml helper | layout/wiki.ejs, gallery.ejs |
Encryption mechanism (server-side scripts/encrypt/crypto.js and client-side source/libs/hbe/hbe-bundle.js (WebCrypto) must use matching parameters):
- Key derivation: PBKDF2-SHA256, iteration count set by
encrypt.kdf.iterations - Symmetric encryption: AES-256-GCM; 32-byte random salt + 12-byte random nonce; ciphertext =
ciphertext || authTag(16B) - When
stableSalt: true, the salt is derived from the post permalink (stableSaltFromPermalink), so repeated builds produce the same salt and cached decryptions stay valid; the nonce remains random each time to avoid nonce reuse
Config keys (theme encrypt.*, authoritative in the template userConfig/_config.tmp.yml; post front-matter can override password/abstract/message/theme/wrong_pass_message/silent/autoSave/stableSalt/kdf):
| Key | Description |
|---|---|
encrypt.enable | Master encryption switch |
encrypt.abstract | Placeholder summary for encrypted post listings (also overrides excerpt to prevent content leakage) |
encrypt.message / encrypt.wrong_pass_message | Prompt / wrong-password message |
encrypt.tags | {name, password} list; posts whose tags match are encrypted automatically |
encrypt.autoSave | Writes the derived key to localStorage, skipping the password on refresh (off by default) |
encrypt.stableSalt | Stable PBKDF2 salt across clean builds (default false) |
encrypt.kdf.iterations | PBKDF2 iteration count; floor 100000, recommended ≥ 600000, default 250000 |
Multi-container storageKey: encrypted snippets carry a data-storage-key attribute; hbe-bundle.js uses storageKey || location.pathname as the localStorage cache key (prefix hbe.v4.), so multiple encrypted containers on the same page don’t collide — snippets use segment.<path>#<id>, wiki uses wiki.<name>, whole-post leaves it empty (falls back to the page path).
Search engine exclusion (all three engines skip encrypted content): the local search scripts/generators/local-search.js skips post.encrypt === true; Algolia scripts/generators/algolia-search.js returns null for record.encrypt === true; Pagefind is excluded by layout/_partial/post/post-detail.ejs which omits data-pagefind-body when page.encrypt is true.
Appendix: Customization Quick Reference
| Need | Method | File location |
|---|---|---|
| Per-page scripts/styles | <link>/<script> in Markdown | source/js/, source/css/ |
| Site-wide scripts/styles | inject_point injection | themes/matery/scripts/filters/ |
| New shortcodes | tag plugin | themes/matery/scripts/tags/ |
| Theme config items | template key + read in Stylus/EJS | userConfig/_config.tmp.yml |
| Global HTML fragments | injection points or _partial/ templates | config or themes/matery/layout/_partial/ |