High-fidelity, server-side code syntax highlighting for WordPress, powered by Shiki.
NodeCode highlights your code once, on the server, and serves cached static HTML to visitors. There is no client-side highlighting cost — visitors download pre-rendered, dual-theme HTML, and only a tiny progressive-enhancement script for the copy button, soft-wrap toggle and collapse.
It reuses the exact, high-fidelity TextMate-grammar highlighting that VS Code uses, by running Shiki as a small localhost Node.js service and caching its output in WordPress.
- Server-side rendering + caching — code is highlighted on
save_post, the HTML is cached in post meta, and the frontend just reads it back. - VS Code–quality highlighting via Shiki / TextMate grammars.
- Dual light & dark themes in a single payload, using CSS variables
(
--nodecode-light/--nodecode-dark). - Flexible dark-mode strategy — follow a site selector, follow the visitor's OS, or stay always-light, with safeguards against "double darkening" from stacked dark-mode tools (theme + plugin + Dark Reader).
- Line annotations — highlight, diff (
++/--), focus, and word-highlight via Shiki notation comments. - Optional UI — line numbers, language / title bar, copy button, soft-wrap toggle, and collapsible long blocks.
- Multiple authoring styles — Gutenberg code blocks, classic
<pre><code>, and[nodecode]/[code]shortcodes. - Graceful degradation — if the service is unreachable, falls back to a plain
escaped
<pre><code>block; the page never breaks. - WP-CLI commands to re-render and flush caches.
This repository contains two independent components under different licenses:
| Component | Path | License | What it does |
|---|---|---|---|
| WordPress plugin | nodecode-plugin/ |
MIT | Pre-renders code on save, caches HTML, serves dual light/dark themes. |
| Node.js service | nodecode-service/ |
Apache 2.0 | Fastify + Shiki microservice that turns code into themed static HTML. |
flowchart LR
editor[Author saves post] --> wp[NodeCode plugin save_post]
wp -->|extract code/lang/meta| api["NodeCode service POST /highlight/batch (localhost)"]
api -->|themed HTML| wp
wp -->|cache by hash| meta[(post_meta)]
visitor[Visitor] --> render[the_content filter]
render -->|read cached HTML| meta
render --> page[Static themed HTML + CSS variables]
- On save, the plugin extracts every code block and sends them to the service in one batch request.
- The service returns themed HTML; the plugin caches it in post meta keyed by a content+config hash.
- On view,
the_contentswaps in the cached HTML. No external request is made at render time.
- Node.js ≥ 18 (for
nodecode-service) - WordPress ≥ 5.8, PHP ≥ 7.4
- The service API key in
.envmust match the plugin setting in Settings → NodeCode
cd nodecode-service
cp .env.example .env # set NODECODE_API_KEY (a long random string)
npm install
npm run build
npm start # listens on 127.0.0.1:9527
curl http://127.0.0.1:9527/healthFor production on Ubuntu, use systemd (recommended) — see Production deployment (Ubuntu + systemd) below.
- Copy
nodecode-plugin/intowp-content/plugins/. - Activate NodeCode in the WordPress admin.
- Go to Settings → NodeCode and:
- Set the Service URL (default
http://127.0.0.1:9527) and API Key (must matchNODECODE_API_KEYin the service.env). - Click Test service connection.
- Pick Light theme / Dark theme (must be preloaded by the service).
- Choose your Dark mode strategy and, if using the selector strategy, set the Dark mode CSS selector to match your theme.
- Enable or disable feature toggles (line numbers, copy button, language label, soft-wrap, collapse).
- Set the Service URL (default
Dark mode & advanced plugin topics: nodecode-plugin/README.md.
After changing themes or plugin settings, regenerate cached HTML for posts that were saved earlier:
wp nodecode rerender --allNodeCode is two components — nodecode-plugin/ and
nodecode-service/ — usually updated from the same git repository.
Release tags look like nodecode-plugin/v0.1.0 and nodecode-service/v0.1.0 (see the
version badges at the top of this README).
Recommended order: upgrade the service first, then the plugin, then re-render cached posts. That way PHP always talks to a running service with a compatible API.
| Component | Where to look |
|---|---|
| Plugin | Plugins → Installed Plugins (version under NodeCode), or grep Version nodecode-plugin/nodecode.php in the repo |
| Service | curl -s http://127.0.0.1:9527/health (service must be up), or version in nodecode-service/package.json |
Local / manual process
cd nodecode-service
git pull # or: git checkout nodecode-service/v0.2.0 (from repo root)
npm ci # or: npm install
npm run build
# restart however you run it (npm start, PM2, etc.)
curl http://127.0.0.1:9527/healthIf you use a .env file, keep it — new releases may add variables; compare with
.env.example. Production should use /etc/nodecode.env
(see Configuration); after editing env vars,
sudo systemctl restart nodecode-service.
Production (systemd at /var/www/nodecode)
cd /var/www/nodecode
sudo git fetch --tags
# Option A — latest on master:
sudo git pull
# Option B — pin a service release:
# sudo git checkout nodecode-service/v0.2.0 -- nodecode-service
cd nodecode-service
sudo -u www-data npm ci
sudo -u www-data npm run build
sudo systemctl restart nodecode-service
curl http://127.0.0.1:9527/healthThe plugin is plain PHP — no build step. Replace the folder under wp-content/plugins/.
From a git checkout (typical)
cd /path/to/nodecode # your clone of this repository
git pull # or: git checkout nodecode-plugin/v0.2.0 -- nodecode-plugin
cp -a nodecode-plugin /path/to/wordpress/wp-content/plugins/nodecode-plugin
# production example:
# sudo cp -a /var/www/nodecode/nodecode-plugin /var/www/html/wp-content/plugins/nodecode-plugin
# sudo chown -R www-data:www-data /var/www/html/wp-content/plugins/nodecode-pluginIn wp-admin → Plugins, confirm NodeCode is still active (no re-activation needed
if the folder name stays nodecode-plugin). Open Settings → NodeCode and click Test
service connection.
ZIP / copy-only installs: unpack the new nodecode-plugin directory over the old one,
keeping wp-content/plugins/nodecode-plugin/ as the target path.
Theme names, plugin version, and service output can change what gets cached. After upgrading either component (especially the plugin or Shiki/themes), regenerate HTML:
wp nodecode rerender --all
# production, from WordPress root:
# sudo -u www-data wp nodecode rerender --all --path=/var/www/htmlOptional checks:
wp nodecode health
wp nodecode rerender --post=123 # single postIf you deploy the full repository at /var/www/nodecode (as in
Production deployment):
cd /var/www/nodecode
sudo git pull
cd nodecode-service && sudo -u www-data npm ci && sudo -u www-data npm run build
sudo systemctl restart nodecode-service
sudo cp -a /var/www/nodecode/nodecode-plugin /var/www/html/wp-content/plugins/nodecode-plugin
sudo chown -R www-data:www-data /var/www/html/wp-content/plugins/nodecode-plugin
sudo -u www-data wp nodecode rerender --all --path=/var/www/htmlMore service notes: nodecode-service/README.md. Plugin
details: nodecode-plugin/README.md.
This guide installs NodeCode on an Ubuntu server that already runs WordPress. The Node
service listens on 127.0.0.1:9527 so only local PHP can reach it. systemd keeps the
service running after reboots and restarts it if the process crashes.
For local development on your laptop, use dev/README.md instead.
flowchart LR
visitor[Visitor] --> nginx[Nginx_or_Apache]
nginx --> php[WordPress_PHP]
php -->|save_post HTTP POST| node[NodeCode_127.0.0.1_9527]
node -->|themed_HTML| php
php --> meta[(post_meta_cache)]
visitor -->|view_post reads cache| php
Visitors never call port 9527 directly. Highlighting runs when an author saves a post.
- Ubuntu 20.04 or newer (22.04 LTS recommended)
- WordPress 5.8+, PHP 7.4+ (same machine as the Node service)
sudoaccess,git,curl- WordPress web root (examples use
/var/www/html— adjust to your site)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v # must be v18 or newer
npm -vcd /var/www
sudo git clone https://github.com/flygoly/nodecode.git
cd nodecode/nodecode-service
sudo npm ci
sudo npm run buildConfirm dist/server.js exists. Set ownership so the WordPress user can read the app
(often www-data):
sudo chown -R www-data:www-data /var/www/nodecode/nodecode-serviceDo not rely on a .env file inside the web tree. Use a root-owned env file for systemd:
sudo cp deploy/nodecode.env.example /etc/nodecode.env
sudo nano /etc/nodecode.envSet at minimum:
# Generate a key:
openssl rand -hex 32NODECODE_HOST=127.0.0.1
NODECODE_PORT=9527
NODECODE_API_KEY=<paste-the-random-key-here>
NODECODE_LIGHT_THEME=github-light
NODECODE_DARK_THEME=github-darkLock down permissions:
sudo chmod 600 /etc/nodecode.env
sudo chown root:root /etc/nodecode.envSecurity: keep NODECODE_HOST=127.0.0.1. Do not expose port 9527 on a public
firewall; only WordPress on the same host should connect.
Edit paths if your clone is not under /var/www/nodecode/nodecode-service:
sudo nano /var/www/nodecode/nodecode-service/deploy/nodecode-service.service
# Check WorkingDirectory and ExecStart (/usr/bin/node — run `which node` if unsure)Install the unit:
sudo cp /var/www/nodecode/nodecode-service/deploy/nodecode-service.service \
/etc/systemd/system/nodecode-service.service
sudo systemctl daemon-reload
sudo systemctl enable nodecode-service # start on boot — required
sudo systemctl start nodecode-service
sudo systemctl status nodecode-serviceVerify:
curl http://127.0.0.1:9527/health
# {"status":"ok","loaded":...}View logs:
sudo journalctl -u nodecode-service -f| Command | Purpose |
|---|---|
sudo systemctl enable nodecode-service |
Register for boot (without this, a reboot stops NodeCode) |
sudo systemctl restart nodecode-service |
Apply config or code changes |
sudo systemctl is-enabled nodecode-service |
Should print enabled |
The unit uses Restart=always so the process is restarted after crashes.
sudo cp -a /var/www/nodecode/nodecode-plugin /var/www/html/wp-content/plugins/nodecode-plugin
sudo chown -R www-data:www-data /var/www/html/wp-content/plugins/nodecode-pluginIn wp-admin:
- Plugins → Installed Plugins — activate NodeCode.
- Settings → NodeCode:
- Service URL:
http://127.0.0.1:9527 - API Key: same value as
NODECODE_API_KEYin/etc/nodecode.env - Click Test service connection
- Choose Light theme / Dark theme (must match preloaded service themes)
- Set Dark mode strategy and CSS selector for your theme
- Service URL:
See nodecode-plugin/README.md for dark-mode details.
- Create or edit a post with a Code block or
[nodecode]shortcode; Update the post. - View the post on the front end — syntax colors and UI should appear (the block editor still shows raw source; that is expected).
- Optional — from the WordPress root:
cd /var/www/html
sudo -u www-data wp nodecode health
sudo -u www-data wp nodecode rerender --allConfirm the unit is enabled, then reboot the server:
sudo systemctl is-enabled nodecode-service
sudo rebootAfter login:
sudo systemctl status nodecode-service
curl http://127.0.0.1:9527/healthIn wp-admin, run Test service connection again.
NodeCode does not replace your web stack. Ensure these are also enabled for boot:
sudo systemctl enable nginx # or apache2
sudo systemctl enable php8.2-fpm # adjust PHP version
sudo systemctl enable mysql # or mariadb| Symptom | What to check |
|---|---|
| Test connection fails | sudo systemctl status nodecode-service; curl http://127.0.0.1:9527/health; journalctl -u nodecode-service -n 50 |
| Works until reboot | Run sudo systemctl enable nodecode-service (not only start) |
| API key / 401 errors | NODECODE_API_KEY in /etc/nodecode.env must match Settings → NodeCode; restart after edits: sudo systemctl restart nodecode-service |
dist/server.js missing |
cd /var/www/nodecode/nodecode-service && sudo -u www-data npm run build |
| Permission errors | www-data must read dist/ and node_modules/ under nodecode-service |
| No highlight after save | PHP must reach localhost (wp_remote_post); check wp-content/debug.log |
Full component-by-component steps: Upgrading. Quick path when the whole
repo lives at /var/www/nodecode:
cd /var/www/nodecode
sudo git pull
cd nodecode-service
sudo -u www-data npm ci
sudo -u www-data npm run build
sudo systemctl restart nodecode-service
sudo cp -a /var/www/nodecode/nodecode-plugin /var/www/html/wp-content/plugins/nodecode-plugin
sudo chown -R www-data:www-data /var/www/html/wp-content/plugins/nodecode-plugin
sudo -u www-data wp nodecode rerender --all --path=/var/www/html| Variable | Default | Description |
|---|---|---|
NODECODE_HOST |
127.0.0.1 |
Bind address. Keep on localhost. |
NODECODE_PORT |
9527 |
Port. |
NODECODE_API_KEY |
(empty) | Shared secret; must match the plugin setting. |
NODECODE_LIGHT_THEME |
github-light |
Light theme name. |
NODECODE_DARK_THEME |
github-dark |
Dark theme name. |
NODECODE_EXTRA_LANGS |
(empty) | Extra languages to preload, comma separated. |
NODECODE_MAX_CODE_LENGTH |
200000 |
Max characters accepted per code block. |
Service URL, API key, light/dark themes, dark-mode strategy + selector, and feature toggles (line numbers, copy, language label, soft-wrap, collapse).
NodeCode detects code blocks automatically from post content. All three styles get the same Shiki highlighting on the frontend; they differ in how you write and what you can configure per block.
| Style | Where to write | Per-block title |
Per-block linenumbers / wrap |
Line markers [!code ...] |
|---|---|---|---|---|
| A. Gutenberg Code | Code block (WordPress core) | Only via data-title in code view |
Follow Settings → NodeCode | Yes |
B. <pre><code> |
Custom HTML block | data-title on <pre> or <code> |
Global defaults; wrap off per block |
Yes |
C. [nodecode] |
Shortcode or Custom HTML | title / file attribute |
linenumbers / wrap attributes |
Yes |
Summary: Highlighting power is the same. Use [nodecode] when you need per-block
title, line numbers, or wrap. Use the Code block for everyday posts with global settings.
Do not paste [nodecode]...[/nodecode] inside a Code block — WordPress will treat
it as plain text. Use a Shortcode block instead.
WordPress core block (core/code). NodeCode does not register a separate block.
- Add a Code block, pick the language in the toolbar, paste your snippet (including
// [!code ...]markers). - After save, stored content looks like:
<!-- wp:code {"language":"javascript"} -->
<pre class="wp-block-code"><code class="language-javascript">const legacy = false // [!code --]
const migrated = true // [!code ++]
console.log(migrated) // [!code highlight]</code></pre>
<!-- /wp:code -->Important:
class="language-xxx"must match thelanguagein the<!-- wp:code -->comment, or Gutenberg may show an invalid block warning.- Optional title bar: add
data-title="demo.js"on<code>in the block’s code view (not exposed in the visual UI). - Line numbers and wrap use global plugin settings unless you switch to
[nodecode].
<pre><code class="language-php" data-title="functions.php"><?php
add_action( 'init', function () {
do_action( 'demo_hook' ); // [!code highlight]
} );</code></pre>Legacy SyntaxHighlighter-style classes are also supported, e.g. class="brush: js".
Minimal example with all shortcode attributes:
[nodecode lang="js" title="example.js" linenumbers="true" wrap="false"]
const x = 1
[/nodecode]
Aliases (closing tag must match the opening tag):
[code lang="php"] ... [/code][shiki lang="typescript"] ... [/shiki](legacy)
| Attribute | Description |
|---|---|
lang / language / lng |
Language ID (defaults to text if omitted) |
title / file |
Text shown in the block title bar |
linenumbers |
true, false, 1, yes, on, etc. |
wrap |
Enable soft line wrap for this block |
Copy into a Shortcode block (or Custom HTML). After Update, view the post on the frontend.
[nodecode lang="typescript" title="full-demo.ts" linenumbers="true" wrap="true"]
// [!code highlight:2]
import { readFile } from 'node:fs/promises'
const theme = 'github-light'
const dark = 'github-dark'
async function loadConfig(path: string) {
const raw = await readFile(path, 'utf8') // [!code highlight]
const legacy = { version: 1 } // [!code --]
const config = { version: 2, theme, dark } // [!code ++]
applyTheme(config.theme) // [!code focus]
log(config.theme) // [!code word:theme]
return config
}
export { loadConfig }
[/nodecode]
| Part | Meaning |
|---|---|
lang="typescript" |
Shiki language |
title="full-demo.ts" |
Title bar label |
linenumbers="true" |
Show line numbers for this block |
wrap="true" |
Soft-wrap + wrap toggle for this block |
// [!code highlight:2] |
Highlight the next 2 lines (Shiki v3) |
// [!code highlight] |
Highlight this line |
// [!code --] / ++ |
Diff removed / added line |
// [!code focus] |
Focus line (blur others until hover) |
// [!code word:theme] |
Highlight occurrences of theme |
Equivalent JavaScript (one highlight, one ++). Pick one authoring style.
1. Code block — paste in the editor:
const legacy = false // [!code --]
const migrated = true // [!code ++]
console.log(migrated) // [!code highlight]2. Shortcode:
[nodecode lang="javascript" title="demo.js" linenumbers="true"]
const legacy = false // [!code --]
const migrated = true // [!code ++]
console.log(migrated) // [!code highlight]
[/nodecode]
3. Custom HTML:
<pre><code class="language-javascript" data-title="demo.js">const legacy = false // [!code --]
const migrated = true // [!code ++]
console.log(migrated) // [!code highlight]</code></pre>For a single block that uses every marker type together, see Full shortcode example (all attributes and markers).
Add special comments inside your code to mark lines. Comment syntax depends on the language
(// for JS/TS, # for Python, etc.):
| Marker | Effect |
|---|---|
// [!code highlight] |
Highlight the line |
// [!code ++] |
Diff: added line |
// [!code --] |
Diff: removed line |
// [!code focus] |
Focus this line (others blur until hover) |
// [!code word:token] |
Highlight occurrences of token |
Example (JavaScript):
console.log('a') // [!code highlight]
const added = 1 // [!code ++]
const removed = 0 // [!code --]
doThing() // [!code focus]
const token = 1 // [!code word:token]Annotations appear as plain comments in the editor and in stored post content. The highlighted result is produced when the post is saved (server render) and shown on the frontend from cached HTML.
sequenceDiagram
participant Author
participant WP as WordPress_plugin
participant Svc as NodeCode_service
participant Meta as post_meta
Author->>WP: save_post
WP->>WP: Parser_collect_blocks
WP->>Svc: POST_highlight_batch
Svc-->>WP: themed_HTML
WP->>Meta: _nodecode_cache_by_hash
Note over Author,Meta: Frontend_the_content_reads_cache_no_Shiki_call
- Each block is cached under a hash of code + language + title + flags + config version.
Changing themes or plugin version changes
config version— runwp nodecode rerenderto refresh old posts. - If the service is down when you save, that block is not written to meta. On the
frontend the plugin may try a one-off live render (transient cache), then fall back to a
plain escaped
<pre><code>. - Already published posts with cached HTML keep working when the service is offline. Only newly saved or uncached blocks may show the plain fallback.
| Where | What you see |
|---|---|
| Block / classic editor | Raw source code, including // [!code ...] comments — no Shiki colors |
| Published frontend | Cached themed HTML, line numbers, copy button, annotations, light/dark switch |
This is expected: highlighting runs on the server at save time, not inside the WordPress editor.
| Problem | What to check |
|---|---|
| Gutenberg invalid block on a code block | <!-- wp:code {"language":"..."} --> matches class="language-..." on <code> |
| No highlighting on the frontend | Service curl .../health, plugin Test service connection, re-save the post or wp nodecode rerender |
| Stale colors after theme change | wp nodecode rerender --all |
| Service down, old post | Cached blocks still render; uncached blocks may fall back to plain <pre><code> |
CLI helpers:
wp nodecode health
wp nodecode rerender --post=123
wp nodecode flushwp nodecode rerender --all
wp nodecode rerender --post=123
wp nodecode rerender --all --post_type=post,page
wp nodecode flush # clear all cached HTML + transients
wp nodecode health # check the service connectionTo try NodeCode end-to-end without committing WordPress core, use the portable scripts in
dev/:
bash dev/bootstrap.sh
cd nodecode-service && npm install && npm run build && cd ..
bash dev/setup.sh
bash dev/start.shDownloads land in gitignored local-wp/; demo admin is admin / admin123 on
http://localhost:8765.
dev/ Local WP bootstrap / setup / start (tracked)
nodecode-service/ Node.js highlight microservice (Apache 2.0)
src/
config.ts env config loader
highlighter.ts Shiki highlighter (dual theme + transformers)
transformers.ts line-number + metadata transformers
server.ts Fastify routes (/highlight, /batch, /health)
deploy/nodecode-service.service systemd unit (boot + restart)
deploy/nodecode.env.example template for /etc/nodecode.env
ecosystem.config.cjs PM2 config
LICENSE Apache License 2.0
nodecode-plugin/ WordPress plugin (MIT)
nodecode.php entry + autoloader
includes/ Settings, Client, Parser, Renderer, Assets, Admin, CLI
assets/css/nodecode.css frontend styles (line numbers, diff, collapse, ...)
assets/js/nodecode.js copy / wrap toggle / collapse / dark-fade sync
uninstall.php
LICENSE MIT License
LICENSE NodeCode Multi-License Agreement (overview)
NodeCode is distributed under a multi-license agreement — see LICENSE:
- The WordPress plugin (
nodecode-plugin/) is licensed under the MIT License. - The Node.js service (
nodecode-service/) is licensed under the Apache License, Version 2.0.
Copyright (c) 2026 flygoly.
Syntax highlighting is powered by Shiki and
@shikijs/transformers, distributed under
the MIT License by their respective authors.