Skip to content

1 - packagist themes ios spyware

ios间谍软件分析报告

执行摘要

可视化报告 Socket 发布的研究报告披露了一起针对 Packagist / Composer 软件供应链的恶意软件投递活动。

攻击者在多个 PHP Composer 主题包中植入恶意 JavaScript。这些主题主要用于基于 OphimCMSKKPhim 的电影、漫画流媒体网站。网站管理员安装受污染的主题后,恶意 JavaScript 会被部署到生产网站,并自动提供给访问网站的终端用户。

攻击活动主要包含两条链路:

  1. 移动端广告欺诈与赌博跳转

    • 针对 Android 和 iPhone 等移动设备;
    • 注入广告内容;
    • 将用户重定向至赌博网站;
    • 使用随机子域名、Cookie 和多级跳转进行流量追踪和规避。
  2. 针对存在漏洞 iPhone 的 iOS 间谍软件攻击链

    • 恶意主题加载远程 JavaScript;
    • 根据 iOS 版本选择对应的 WebKit 漏洞利用代码;
    • 获得 Safari/WebContent 进程中的内存操作能力;
    • 通过 IOSurface 等机制继续构建跨进程攻击能力;
    • 最终利用 IOKit 相关接口实现内核级攻击;
    • 部署 iOS 间谍软件;
    • 窃取 Keychain、短信、照片、浏览器数据及加密货币钱包助记词等敏感信息。

从攻击架构来看,这是一次典型的:

恶意 Composer Package
网站管理员安装
恶意 Theme 部署到生产网站
恶意 JavaScript 自动分发
网站访问者
        ├── 移动广告 / 赌博欺诈
        └── iPhone
          WebKit Exploit
         Sandbox / Process Pivot
          Kernel Escape
          iOS Spyware
     数据与钱包助记词窃取

这意味着攻击者实施的并非传统意义上的单层软件供应链攻击,而是:

攻击 Package → 感染网站 → 利用网站作为终端 Exploit 分发平台。

对于安全团队而言,最重要的风险在于:恶意 Composer 包的最终受害者不只是开发者或网站管理员,而可能是访问生产网站的大量普通用户。


已确认恶意 Package

Socket 确认了 13 个恶意 Packagist 主题包,涉及多个 Vendor Namespace。

VendorPackage
vsmovtheme-dy
vsmovtheme-rrdyw
vsmovtheme-motchill
vsmovtheme-vsmov
vsphimtheme-heovl
vsphimtheme-thempho
haiau009kkphim-legend
haiau009kkphim-motchill
chilltvcmstheme-legend
ophimcmstheme-dy
ophimcmstheme-motchill
ophimcmstheme-pcc
ophimcmstheme-rrdyw

对应完整 Package Name:

vsmov/theme-dy
vsmov/theme-rrdyw
vsmov/theme-motchill
vsmov/theme-vsmov

vsphim/theme-heovl
vsphim/theme-thempho

haiau009/kkphim-legend
haiau009/kkphim-motchill

chilltvcms/theme-legend

ophimcms/theme-dy
ophimcms/theme-motchill
ophimcms/theme-pcc
ophimcms/theme-rrdyw

建议对以下 Namespace 进行全面供应链审计:

vsmov
vsphim
haiau009
chilltvcms
ophimcms

需要注意的是,当前尚未发现活动 Payload 的其他 Package 也不能自动视为安全

如果这些 Package:

  • 由相同维护者控制;
  • 存在相似代码;
  • 共享 JavaScript 注入机制;
  • 可以通过 Composer Update 更新;

那么它们依然可能构成 Sleeper Supply-chain Risk(休眠型供应链风险)


攻击背景

OphimCMS 和 KKPhim 是用于搭建电影、电视剧和漫画流媒体网站的 PHP/Laravel 项目。

其部署模型通常为:

Laravel CMS Core
      +
Composer Theme Package
Streaming Website

Theme Package 除了提供页面模板外,通常还包含:

  • JavaScript;
  • jQuery;
  • 视频播放器;
  • Banner;
  • Slider;
  • 页面交互逻辑;
  • 第三方前端组件。

攻击者利用这一特点,在正常 Theme 的前端 JavaScript 中植入恶意 Loader。

管理员执行类似操作:

composer require vendor/theme

随后:

Composer 安装恶意 Theme
Theme 部署到生产服务器
恶意 JavaScript 进入网站静态资源
用户访问网页
浏览器自动执行恶意 JavaScript

因此,攻击链实现了从:

Developer / Website Operator

向:

Website Visitors

的攻击扩散。

这是本次事件最值得关注的供应链特征。


完整攻击链

整体攻击链可以概括为:

Packagist
Trojanized Composer Theme
Production Streaming Website
Malicious JavaScript Loader
Traffic Filtering
    ├── Mobile Advertising / Gambling
    └── iOS Exploit Chain
        iOS Version Detection
        WebKit Exploit
        Memory Primitive
        IOSurface / Process Pivot
        IOKit Kernel Escape
        Spyware Payload
        Sensitive Data Collection
        Crypto Wallet Seed Theft
        Encrypted C2 Exfiltration

攻击者首先利用 JavaScript 对目标进行筛选,包括:

  • 操作系统;
  • 浏览器;
  • User-Agent;
  • iOS Version;
  • Referrer;
  • 是否为移动设备。

这种筛选机制可以降低 Payload 被:

  • 自动扫描器;
  • Desktop Sandbox;
  • 普通爬虫;
  • 安全研究人员;

发现的概率。


移动广告与赌博跳转

攻击链的一部分并不针对 iOS Exploit,而是用于移动流量变现。

恶意 JavaScript 会检测以下平台:

iPhone
iPod
Android
iOS

匹配成功后可能执行:

Mobile Visitor
Malicious Banner
Traffic Redirector
Session Cookie
Randomized Subdomain
Gambling Landing Page

Socket 观察到攻击者使用:

  • Campaign ID;
  • Cookie;
  • 多级 HTTP Redirect;
  • 随机化 .vip 子域名;
  • 非标准端口;

实现流量分发和用户追踪。

这部分活动更接近:

Malvertising
+
Traffic Hijacking
+
Gambling Affiliate Fraud

因此,同一个供应链可能同时服务于低成本、大规模的流量变现活动。


iOS Exploit Chain

更严重的攻击链针对未安装相关安全更新的 iPhone。

恶意 JavaScript 在识别到符合条件的 iOS 设备后,会加载对应版本的 Exploit。

攻击流程为:

Malicious Website
Second-stage JavaScript
Hidden iframe
iOS Version Detection
Select Exploit
WebKit / JavaScriptCore Exploit
Arbitrary Read / Write
Further Privilege Escalation
Kernel-level Access
Spyware

Socket 识别到攻击链使用了针对不同 iOS 版本的漏洞,包括:

CVE相关攻击目标
CVE-2025-31277iOS 18.4 - 18.5
CVE-2025-43529iOS 18.6 及相关版本

攻击代码会根据目标系统版本选择不同 Exploit Worker。

这意味着攻击者并非简单嵌入一个公开 PoC,而是具备一定的:

Exploit Version Selection
+
Device Compatibility Management
+
Offset Management

能力。


WebKit 利用机制

Socket 对公开攻击代码的分析显示,攻击链通过 WebKit / JavaScriptCore 内存破坏漏洞建立进一步攻击能力。

技术过程可以抽象为:

Memory Corruption
Object Address Disclosure
Fake Object Construction
Corrupted Typed Array
Arbitrary Memory Read
Arbitrary Memory Write

攻击代码同时包含针对不同:

  • iOS Build;
  • Device Model;
  • SoC / Chipset;

的适配信息。

这表明攻击者维护的是一个具有工程化特征的:

Browser Exploit Delivery Framework

而不是一次性漏洞利用代码。


IOSurface 与跨进程攻击

获得 WebContent 进程中的能力后,攻击链继续尝试扩大权限范围。

Socket 描述攻击代码涉及:

IOSurface
mach Messaging
Pointer Authentication

攻击路径可以抽象为:

Safari / WebContent
WebKit Exploit
Renderer Memory Primitive
IOSurface Abuse
Cross-process Primitive
GPU-related Process

这一阶段的目标是突破单一浏览器渲染进程的安全边界。

从攻击模型来看:

Browser Exploitation

只是 Initial Foothold。

真正的攻击目标是继续构建:

Sandbox Escape
+
Privilege Escalation

能力。


IOKit Kernel Escape

攻击链最终涉及 Apple IOKit 相关接口。

Socket 的分析指出,恶意代码与以下组件存在关联:

AppleM2ScalerCSCDriver

攻击流程涉及:

mediaplaybackd XPC
IOServiceOpen
External Method
IOSurface Interaction
Kernel Memory Primitive

攻击代码公开特征包括:

IOServiceOpen Type: 0
Selector: 1
Input Struct: 432 bytes
IOSurface IDs: 2

Socket 指出,这一攻击表面与后来公开的:

CVE-2026-43655

存在关联,但实际攻击链与公开 PoC 并不完全相同。

公开信息显示,攻击者使用的 Kernel Escape 针对已经修复的漏洞状态,而不是直接针对当时最新系统的未知漏洞。

因此,这条攻击链更符合:

Operationalized N-day Exploitation

即攻击者快速将已经存在或已经修复前后的漏洞能力工程化,并持续用于真实攻击活动。


iOS 间谍软件能力

成功获得更高权限后,最终 Payload 可以访问和窃取大量设备敏感信息。

目标包括:

Keychain
Wi-Fi Passwords
SMS Database
Contacts
Photos
Browser Cookies
Browsing History
Call History
Location History
Account Databases
Notes
Calendar

攻击流程:

Sensitive iOS Data
Data Collection
Local Processing
AES Encryption
HTTPS POST
C2 Server

攻击者同时通过远程基础设施跟踪:

Exploit Status
Payload Execution

因此,该 Payload 不只是简单的信息窃取脚本,而具有完整的:

Collection
+
Encryption
+
Command and Control
+
Exfiltration

能力。


加密货币钱包助记词窃取

2026 年 8 月,攻击者重新部署攻击基础设施,并扩展了 Spyware 的数据窃取能力。

新版本增加了针对加密货币钱包数据的 Keychain 查询。

报告中涉及的钱包包括:

Bitget
BitKeep
Bitpie
Phantom
Tonkeeper
Trust Wallet
OKX

相关代码命名包括:

keychain_query_bitget
keychain_query_bitpie
keychain_query_phantom
keychain_query_tonkeeper
keychain_query_trust
mnemonics_vault_

这代表攻击目标从传统的:

Surveillance

进一步升级为:

Surveillance
      +
Credential Theft
      +
Crypto Wallet Seed Theft
Potential Direct Financial Theft

助记词或 Seed Phrase 一旦泄露,攻击者理论上可以绕过传统登录保护,直接恢复对应钱包。

因此,这是本次事件中最直接的金融风险。


基础设施

Socket 将多个阶段与 FUNNULL 基础设施关联。

观察到的关键基础设施包括:

union[.]macoms[.]la
cdn[.]data-2920[.]com
www[.]cloudfareintcdn[.]com
cloudfareintcdn[.]com

其中:

cloudfareintcdn[.]com

采用了明显类似 Cloudflare 的仿冒式命名。

整体基础设施关系可以理解为:

Theme Operators
      ├── Malicious Composer Packages
      └── Compromised / Weaponized Websites
            Shared Delivery Infrastructure
                  FUNNULL
       Exploit / C2 / Payload Delivery

需要注意的是:

共享基础设施不等于所有活动均由同一个攻击者直接执行。

更合理的 CTI 判断是:

Multiple Operators
        +
Shared Infrastructure
        +
Shared Service Provider / Ecosystem

而不是简单归因为单一 APT。


IOC

建议结合以下 IOC 进行威胁狩猎。

高风险 Package:

vsmov/theme-dy
vsmov/theme-rrdyw
vsmov/theme-motchill
vsmov/theme-vsmov

vsphim/theme-heovl
vsphim/theme-thempho

haiau009/kkphim-legend
haiau009/kkphim-motchill

chilltvcms/theme-legend

ophimcms/theme-dy
ophimcms/theme-motchill
ophimcms/theme-pcc
ophimcms/theme-rrdyw

高风险 Namespace:

vsmov
vsphim
haiau009
chilltvcms
ophimcms

恶意或可疑域名:

union[.]macoms[.]la
cdn[.]data-2920[.]com
www[.]cloudfareintcdn[.]com
cloudfareintcdn[.]com

广告与流量跳转基础设施:

23[.]225[.]52[.]67:4466
23[.]225[.]48[.]20:4466

im[.]ue8im[.]com
xl0ph4qz[.]vip:7740
cre-ads[.]com

部分公开文件 Hash:

start-view.html

SHA256:
60b6771958cb7e553994ba6752f108575ba70e02d24affb51d8936a17eb0bf5e
a4tt4g37f36gdd7q7kdc.js

SHA256:
d9530e8cd79ac7b3d02b04e05426653afca7075fcf7424eec4d59c6e95745933
a84snnb6pknt3aflt01r.js

SHA256:
92c7d246d2c163c076f783dcc19f87f5b9b9ac301b106b87a7aaea9346ce0052
921w48jmeqvt3ygn0wwx.js

SHA256:
f2fdfddbc436acc24a654092f5205b2c5bd3208b126b2c2754ac63e7aea22298
4ap5xpu18z70wwslqybu.js

SHA256:
9d6b58886189c0e23f706c32d3d8dda97b0b6d927ece6de07270813f070295b5
qljbd9a1h4a83gw8lxcj.js

SHA256:
de539a63cbe27bbd4a7db30fc796cd6dc5309c02ef5e60a3c5cf0835e5601283

攻击者重新部署后还观察到新的阶段文件名:

98jgbibyeep2qfkvcq.html
pf2zdl2b4i4cxggjg9s7.js
sejpbqlu090u7lz0z6ax.js

IOC 应被视为时间敏感指标。攻击者已经表现出基础设施和 Payload 文件名轮换能力,因此不能仅依赖静态 Domain Blocklist。


检测建议

对于使用 Composer 的服务器,可以首先检查:

grep -RniE "vsmov|vsphim|haiau009|chilltvcms|ophimcms" \
composer.json composer.lock vendor/ 2>/dev/null

重点检查:

composer.json
composer.lock
vendor/
CI/CD Dependency Cache
Deployment Artifact

同时检查 Theme JavaScript 是否存在:

Base64 Encoded URLs
Custom Decoder
eval(
document.createElement('script')
Hidden iframe
navigator.userAgent
document.referrer
IOSurface

特别关注以下模式:

正常 JavaScript Library
        +
文件末尾追加未知 Loader

例如:

正常 jQuery
Unexpected Appended JavaScript
Remote Script Loader

这是本次攻击中非常重要的文件完整性检测思路。


网络检测建议

建议在:

DNS
Proxy
NDR
SIEM
CDN Logs
Web Server Logs

中进行关联检测。

基础规则:

Client Platform = iOS
      AND
访问流媒体网站
      AND
随后访问未知随机命名 JavaScript
      AND
连接已知恶意基础设施

高风险关联:

iOS Device
Affected Website
union.macoms.la
data-2920.com
cloudfareintcdn.com
Large / Unusual POST Exfiltration

对于网站运营者,应特别统计:

恶意 Theme 存在期间
Unique Visitors
iPhone / iPad Visitors
对应访问时间

这是确定潜在受害范围的重要依据。


MITRE ATT&CK 映射

战术技术本事件表现
Initial AccessDrive-by Compromise用户访问被感染网站
ExecutionJavaScript Execution恶意 Theme 注入 JavaScript
Defense EvasionObfuscated Files or InformationBase64、编码和自定义 Decoder
DiscoverySystem Information Discovery检测 iOS Version、User-Agent、Platform
ExploitationExploitation for Client ExecutionWebKit 漏洞利用
Privilege EscalationExploitation for Privilege EscalationKernel Escape
Credential AccessCredentials from Password StoresKeychain 与钱包数据
CollectionData from Local SystemSMS、照片、Cookie、联系人
Command and ControlApplication Layer ProtocolHTTPS 通信
ExfiltrationExfiltration Over Web ServiceHTTPS POST 数据上传
ImpactFinancial TheftCrypto Wallet Seed / Mnemonic Theft

处置建议

如果发现服务器安装了相关 Package,应立即执行以下操作。

第一步:隔离和确认

确认 Package Name
确认安装时间
确认首次部署时间
确认恶意 JavaScript 是否进入生产环境
确认访问日志保存范围

第二步:移除恶意依赖

不要仅删除单个 JavaScript 文件。

应完整检查:

composer.json
composer.lock
vendor/
Theme Assets
Deployment Artifact
CDN Cache
CI/CD Cache
Backup

然后从可信来源重新构建部署环境。

第三步:凭据轮换

建议轮换:

SSH Credentials
CMS Administrator Passwords
Database Passwords
API Keys
OAuth Secrets
Cloud Credentials
CI/CD Tokens

第四步:用户影响调查

建立时间线:

Malicious Package Introduced
Production Deployment
Malicious JS Active
Malicious Domain Connection
iOS Visitors
Potential Exploitation Window

核心调查问题是:

在恶意 JavaScript 活跃期间,有多少 iOS 用户访问过受影响网站?


核心威胁判断

供应链攻击正在成为终端攻击分发渠道

传统供应链攻击通常被理解为:

Malicious Package
Developer
Developer Workstation

本次事件展示了新的传播模型:

Malicious Package
Website Operator
Production Website
Large Number of Visitors
Browser Exploitation
Endpoint Compromise

攻击面已经扩展为:

Package
Service Provider
Customer
Endpoint

因此,Package 风险评估不能只考虑:

“该依赖是否会影响开发者主机?”

还必须考虑:

“该依赖是否能够向生产用户分发主动攻击代码?”


恶意 JavaScript 是高价值 Exploit Delivery Layer

一旦攻击者控制网站 JavaScript:

No Malicious App Installation
No APK
No Traditional File Download
No Explicit Permission Prompt

用户只需要访问网站,就可能进入漏洞利用流程。

因此,受污染的 Web Asset 实际上可以成为:

Exploit Delivery Infrastructure

这也是为什么:

Third-party JavaScript
Theme
Analytics Script
Ad Script
CDN Asset

需要被视为软件供应链的一部分。


广告欺诈与高价值攻击可以使用同一基础设施

攻击者可以同时采用两种收益模型:

Mass-scale Monetization
    ├── Ad Fraud
    └── Gambling Redirect

以及:

High-value Targeting
    ├── Spyware
    ├── Credential Theft
    └── Crypto Asset Theft

因此,同一个恶意 Package 并不一定只有一个 Payload 目的。

攻击者可能根据:

Device
OS Version
User Value
Traffic Source

动态选择攻击方式。


需要关注休眠型恶意 Package

本次事件再次说明:

当前没有发现恶意 Payload,不代表 Package 是可信的。

如果攻击者仍然拥有:

Package Ownership
+
Update Permission
+
Code Injection Capability

那么 Package 本身就可能成为未来攻击的预置入口。

因此,SCA 和 SBOM 不应只依赖:

Known Malicious Hash

更应该建立:

Package Provenance
+
Publisher Reputation
+
Maintainer Relationship
+
Namespace Analysis
+
Code Provenance
+
Update Behavior
+
Runtime Network Behavior

的综合供应链风险模型。


最终结论

这起事件展示了一种非常值得关注的现代攻击模式:

Software Supply Chain Compromise
Production Website Compromise
Browser-based Payload Delivery
Traffic Filtering
N-day Exploitation
Privilege Escalation
Mobile Spyware
Financial Data Theft

攻击者首先利用 Packagist 和 Composer Theme 作为供应链入口,再利用合法网站的用户访问流量作为恶意 Payload 的分发渠道。

从 CTI 视角,本事件反映出三个重要趋势:

  1. 软件供应链正在成为浏览器和终端漏洞利用的规模化分发渠道;
  2. 恶意 Web JavaScript、广告欺诈和高端 Exploit Infrastructure 正在融合;
  3. 传统 Spyware 正逐渐加入加密钱包和数字资产窃取能力。

建议企业建立跨层防御体系:

Package Security
        +
SBOM / Dependency Inventory
        +
Publisher Reputation
        +
JavaScript Integrity Monitoring
        +
CDN / Web Asset Monitoring
        +
Network Detection
        +
Threat Intelligence
        +
Mobile Incident Response

对于使用 PHP、Laravel、Composer 和第三方 Theme 的组织,尤其应将:

第三方 Theme
=
Production Supply Chain Component

纳入正式的供应链安全管理范围。


参考来源

Socket Threat Research

《13 Malicious Packagist Themes Deliver iOS Spyware That Steals Crypto Wallet Seeds》

发布日期:2026-08-31

原文:

https://socket.dev/blog/packagist-themes-ios-spyware

1.1 -

AI驱动的情报系统

可视化报告

执行摘要

Socket 发布的研究报告披露了一起针对 Packagist / Composer 软件供应链的恶意软件投递活动。

攻击者在多个 PHP Composer 主题包中植入恶意 JavaScript。这些主题主要用于基于 OphimCMSKKPhim 的电影、漫画流媒体网站。网站管理员安装受污染的主题后,恶意 JavaScript 会被部署到生产网站,并自动提供给访问网站的终端用户。

攻击活动主要包含两条链路:

  1. 移动端广告欺诈与赌博跳转

    • 针对 Android 和 iPhone 等移动设备;
    • 注入广告内容;
    • 将用户重定向至赌博网站;
    • 使用随机子域名、Cookie 和多级跳转进行流量追踪和规避。
  2. 针对存在漏洞 iPhone 的 iOS 间谍软件攻击链

    • 恶意主题加载远程 JavaScript;
    • 根据 iOS 版本选择对应的 WebKit 漏洞利用代码;
    • 获得 Safari/WebContent 进程中的内存操作能力;
    • 通过 IOSurface 等机制继续构建跨进程攻击能力;
    • 最终利用 IOKit 相关接口实现内核级攻击;
    • 部署 iOS 间谍软件;
    • 窃取 Keychain、短信、照片、浏览器数据及加密货币钱包助记词等敏感信息。

从攻击架构来看,这是一次典型的:

恶意 Composer Package
网站管理员安装
恶意 Theme 部署到生产网站
恶意 JavaScript 自动分发
网站访问者
        ├── 移动广告 / 赌博欺诈
        └── iPhone
          WebKit Exploit
         Sandbox / Process Pivot
          Kernel Escape
          iOS Spyware
     数据与钱包助记词窃取

这意味着攻击者实施的并非传统意义上的单层软件供应链攻击,而是:

攻击 Package → 感染网站 → 利用网站作为终端 Exploit 分发平台。

对于安全团队而言,最重要的风险在于:恶意 Composer 包的最终受害者不只是开发者或网站管理员,而可能是访问生产网站的大量普通用户。


已确认恶意 Package

Socket 确认了 13 个恶意 Packagist 主题包,涉及多个 Vendor Namespace。

VendorPackage
vsmovtheme-dy
vsmovtheme-rrdyw
vsmovtheme-motchill
vsmovtheme-vsmov
vsphimtheme-heovl
vsphimtheme-thempho
haiau009kkphim-legend
haiau009kkphim-motchill
chilltvcmstheme-legend
ophimcmstheme-dy
ophimcmstheme-motchill
ophimcmstheme-pcc
ophimcmstheme-rrdyw

对应完整 Package Name:

vsmov/theme-dy
vsmov/theme-rrdyw
vsmov/theme-motchill
vsmov/theme-vsmov

vsphim/theme-heovl
vsphim/theme-thempho

haiau009/kkphim-legend
haiau009/kkphim-motchill

chilltvcms/theme-legend

ophimcms/theme-dy
ophimcms/theme-motchill
ophimcms/theme-pcc
ophimcms/theme-rrdyw

建议对以下 Namespace 进行全面供应链审计:

vsmov
vsphim
haiau009
chilltvcms
ophimcms

需要注意的是,当前尚未发现活动 Payload 的其他 Package 也不能自动视为安全

如果这些 Package:

  • 由相同维护者控制;
  • 存在相似代码;
  • 共享 JavaScript 注入机制;
  • 可以通过 Composer Update 更新;

那么它们依然可能构成 Sleeper Supply-chain Risk(休眠型供应链风险)


攻击背景

OphimCMS 和 KKPhim 是用于搭建电影、电视剧和漫画流媒体网站的 PHP/Laravel 项目。

其部署模型通常为:

Laravel CMS Core
      +
Composer Theme Package
Streaming Website

Theme Package 除了提供页面模板外,通常还包含:

  • JavaScript;
  • jQuery;
  • 视频播放器;
  • Banner;
  • Slider;
  • 页面交互逻辑;
  • 第三方前端组件。

攻击者利用这一特点,在正常 Theme 的前端 JavaScript 中植入恶意 Loader。

管理员执行类似操作:

composer require vendor/theme

随后:

Composer 安装恶意 Theme
Theme 部署到生产服务器
恶意 JavaScript 进入网站静态资源
用户访问网页
浏览器自动执行恶意 JavaScript

因此,攻击链实现了从:

Developer / Website Operator

向:

Website Visitors

的攻击扩散。

这是本次事件最值得关注的供应链特征。


完整攻击链

整体攻击链可以概括为:

Packagist
Trojanized Composer Theme
Production Streaming Website
Malicious JavaScript Loader
Traffic Filtering
    ├── Mobile Advertising / Gambling
    └── iOS Exploit Chain
        iOS Version Detection
        WebKit Exploit
        Memory Primitive
        IOSurface / Process Pivot
        IOKit Kernel Escape
        Spyware Payload
        Sensitive Data Collection
        Crypto Wallet Seed Theft
        Encrypted C2 Exfiltration

攻击者首先利用 JavaScript 对目标进行筛选,包括:

  • 操作系统;
  • 浏览器;
  • User-Agent;
  • iOS Version;
  • Referrer;
  • 是否为移动设备。

这种筛选机制可以降低 Payload 被:

  • 自动扫描器;
  • Desktop Sandbox;
  • 普通爬虫;
  • 安全研究人员;

发现的概率。


移动广告与赌博跳转

攻击链的一部分并不针对 iOS Exploit,而是用于移动流量变现。

恶意 JavaScript 会检测以下平台:

iPhone
iPod
Android
iOS

匹配成功后可能执行:

Mobile Visitor
Malicious Banner
Traffic Redirector
Session Cookie
Randomized Subdomain
Gambling Landing Page

Socket 观察到攻击者使用:

  • Campaign ID;
  • Cookie;
  • 多级 HTTP Redirect;
  • 随机化 .vip 子域名;
  • 非标准端口;

实现流量分发和用户追踪。

这部分活动更接近:

Malvertising
+
Traffic Hijacking
+
Gambling Affiliate Fraud

因此,同一个供应链可能同时服务于低成本、大规模的流量变现活动。


iOS Exploit Chain

更严重的攻击链针对未安装相关安全更新的 iPhone。

恶意 JavaScript 在识别到符合条件的 iOS 设备后,会加载对应版本的 Exploit。

攻击流程为:

Malicious Website
Second-stage JavaScript
Hidden iframe
iOS Version Detection
Select Exploit
WebKit / JavaScriptCore Exploit
Arbitrary Read / Write
Further Privilege Escalation
Kernel-level Access
Spyware

Socket 识别到攻击链使用了针对不同 iOS 版本的漏洞,包括:

CVE相关攻击目标
CVE-2025-31277iOS 18.4 - 18.5
CVE-2025-43529iOS 18.6 及相关版本

攻击代码会根据目标系统版本选择不同 Exploit Worker。

这意味着攻击者并非简单嵌入一个公开 PoC,而是具备一定的:

Exploit Version Selection
+
Device Compatibility Management
+
Offset Management

能力。


WebKit 利用机制

Socket 对公开攻击代码的分析显示,攻击链通过 WebKit / JavaScriptCore 内存破坏漏洞建立进一步攻击能力。

技术过程可以抽象为:

Memory Corruption
Object Address Disclosure
Fake Object Construction
Corrupted Typed Array
Arbitrary Memory Read
Arbitrary Memory Write

攻击代码同时包含针对不同:

  • iOS Build;
  • Device Model;
  • SoC / Chipset;

的适配信息。

这表明攻击者维护的是一个具有工程化特征的:

Browser Exploit Delivery Framework

而不是一次性漏洞利用代码。


IOSurface 与跨进程攻击

获得 WebContent 进程中的能力后,攻击链继续尝试扩大权限范围。

Socket 描述攻击代码涉及:

IOSurface
mach Messaging
Pointer Authentication

攻击路径可以抽象为:

Safari / WebContent
WebKit Exploit
Renderer Memory Primitive
IOSurface Abuse
Cross-process Primitive
GPU-related Process

这一阶段的目标是突破单一浏览器渲染进程的安全边界。

从攻击模型来看:

Browser Exploitation

只是 Initial Foothold。

真正的攻击目标是继续构建:

Sandbox Escape
+
Privilege Escalation

能力。


IOKit Kernel Escape

攻击链最终涉及 Apple IOKit 相关接口。

Socket 的分析指出,恶意代码与以下组件存在关联:

AppleM2ScalerCSCDriver

攻击流程涉及:

mediaplaybackd XPC
IOServiceOpen
External Method
IOSurface Interaction
Kernel Memory Primitive

攻击代码公开特征包括:

IOServiceOpen Type: 0
Selector: 1
Input Struct: 432 bytes
IOSurface IDs: 2

Socket 指出,这一攻击表面与后来公开的:

CVE-2026-43655

存在关联,但实际攻击链与公开 PoC 并不完全相同。

公开信息显示,攻击者使用的 Kernel Escape 针对已经修复的漏洞状态,而不是直接针对当时最新系统的未知漏洞。

因此,这条攻击链更符合:

Operationalized N-day Exploitation

即攻击者快速将已经存在或已经修复前后的漏洞能力工程化,并持续用于真实攻击活动。


iOS 间谍软件能力

成功获得更高权限后,最终 Payload 可以访问和窃取大量设备敏感信息。

目标包括:

Keychain
Wi-Fi Passwords
SMS Database
Contacts
Photos
Browser Cookies
Browsing History
Call History
Location History
Account Databases
Notes
Calendar

攻击流程:

Sensitive iOS Data
Data Collection
Local Processing
AES Encryption
HTTPS POST
C2 Server

攻击者同时通过远程基础设施跟踪:

Exploit Status
Payload Execution

因此,该 Payload 不只是简单的信息窃取脚本,而具有完整的:

Collection
+
Encryption
+
Command and Control
+
Exfiltration

能力。


加密货币钱包助记词窃取

2026 年 8 月,攻击者重新部署攻击基础设施,并扩展了 Spyware 的数据窃取能力。

新版本增加了针对加密货币钱包数据的 Keychain 查询。

报告中涉及的钱包包括:

Bitget
BitKeep
Bitpie
Phantom
Tonkeeper
Trust Wallet
OKX

相关代码命名包括:

keychain_query_bitget
keychain_query_bitpie
keychain_query_phantom
keychain_query_tonkeeper
keychain_query_trust
mnemonics_vault_

这代表攻击目标从传统的:

Surveillance

进一步升级为:

Surveillance
      +
Credential Theft
      +
Crypto Wallet Seed Theft
Potential Direct Financial Theft

助记词或 Seed Phrase 一旦泄露,攻击者理论上可以绕过传统登录保护,直接恢复对应钱包。

因此,这是本次事件中最直接的金融风险。


基础设施

Socket 将多个阶段与 FUNNULL 基础设施关联。

观察到的关键基础设施包括:

union[.]macoms[.]la
cdn[.]data-2920[.]com
www[.]cloudfareintcdn[.]com
cloudfareintcdn[.]com

其中:

cloudfareintcdn[.]com

采用了明显类似 Cloudflare 的仿冒式命名。

整体基础设施关系可以理解为:

Theme Operators
      ├── Malicious Composer Packages
      └── Compromised / Weaponized Websites
            Shared Delivery Infrastructure
                  FUNNULL
       Exploit / C2 / Payload Delivery

需要注意的是:

共享基础设施不等于所有活动均由同一个攻击者直接执行。

更合理的 CTI 判断是:

Multiple Operators
        +
Shared Infrastructure
        +
Shared Service Provider / Ecosystem

而不是简单归因为单一 APT。


IOC

建议结合以下 IOC 进行威胁狩猎。

高风险 Package:

vsmov/theme-dy
vsmov/theme-rrdyw
vsmov/theme-motchill
vsmov/theme-vsmov

vsphim/theme-heovl
vsphim/theme-thempho

haiau009/kkphim-legend
haiau009/kkphim-motchill

chilltvcms/theme-legend

ophimcms/theme-dy
ophimcms/theme-motchill
ophimcms/theme-pcc
ophimcms/theme-rrdyw

高风险 Namespace:

vsmov
vsphim
haiau009
chilltvcms
ophimcms

恶意或可疑域名:

union[.]macoms[.]la
cdn[.]data-2920[.]com
www[.]cloudfareintcdn[.]com
cloudfareintcdn[.]com

广告与流量跳转基础设施:

23[.]225[.]52[.]67:4466
23[.]225[.]48[.]20:4466

im[.]ue8im[.]com
xl0ph4qz[.]vip:7740
cre-ads[.]com

部分公开文件 Hash:

start-view.html

SHA256:
60b6771958cb7e553994ba6752f108575ba70e02d24affb51d8936a17eb0bf5e
a4tt4g37f36gdd7q7kdc.js

SHA256:
d9530e8cd79ac7b3d02b04e05426653afca7075fcf7424eec4d59c6e95745933
a84snnb6pknt3aflt01r.js

SHA256:
92c7d246d2c163c076f783dcc19f87f5b9b9ac301b106b87a7aaea9346ce0052
921w48jmeqvt3ygn0wwx.js

SHA256:
f2fdfddbc436acc24a654092f5205b2c5bd3208b126b2c2754ac63e7aea22298
4ap5xpu18z70wwslqybu.js

SHA256:
9d6b58886189c0e23f706c32d3d8dda97b0b6d927ece6de07270813f070295b5
qljbd9a1h4a83gw8lxcj.js

SHA256:
de539a63cbe27bbd4a7db30fc796cd6dc5309c02ef5e60a3c5cf0835e5601283

攻击者重新部署后还观察到新的阶段文件名:

98jgbibyeep2qfkvcq.html
pf2zdl2b4i4cxggjg9s7.js
sejpbqlu090u7lz0z6ax.js

IOC 应被视为时间敏感指标。攻击者已经表现出基础设施和 Payload 文件名轮换能力,因此不能仅依赖静态 Domain Blocklist。


检测建议

对于使用 Composer 的服务器,可以首先检查:

grep -RniE "vsmov|vsphim|haiau009|chilltvcms|ophimcms" \
composer.json composer.lock vendor/ 2>/dev/null

重点检查:

composer.json
composer.lock
vendor/
CI/CD Dependency Cache
Deployment Artifact

同时检查 Theme JavaScript 是否存在:

Base64 Encoded URLs
Custom Decoder
eval(
document.createElement('script')
Hidden iframe
navigator.userAgent
document.referrer
IOSurface

特别关注以下模式:

正常 JavaScript Library
        +
文件末尾追加未知 Loader

例如:

正常 jQuery
Unexpected Appended JavaScript
Remote Script Loader

这是本次攻击中非常重要的文件完整性检测思路。


网络检测建议

建议在:

DNS
Proxy
NDR
SIEM
CDN Logs
Web Server Logs

中进行关联检测。

基础规则:

Client Platform = iOS
      AND
访问流媒体网站
      AND
随后访问未知随机命名 JavaScript
      AND
连接已知恶意基础设施

高风险关联:

iOS Device
Affected Website
union.macoms.la
data-2920.com
cloudfareintcdn.com
Large / Unusual POST Exfiltration

对于网站运营者,应特别统计:

恶意 Theme 存在期间
Unique Visitors
iPhone / iPad Visitors
对应访问时间

这是确定潜在受害范围的重要依据。


MITRE ATT&CK 映射

战术技术本事件表现
Initial AccessDrive-by Compromise用户访问被感染网站
ExecutionJavaScript Execution恶意 Theme 注入 JavaScript
Defense EvasionObfuscated Files or InformationBase64、编码和自定义 Decoder
DiscoverySystem Information Discovery检测 iOS Version、User-Agent、Platform
ExploitationExploitation for Client ExecutionWebKit 漏洞利用
Privilege EscalationExploitation for Privilege EscalationKernel Escape
Credential AccessCredentials from Password StoresKeychain 与钱包数据
CollectionData from Local SystemSMS、照片、Cookie、联系人
Command and ControlApplication Layer ProtocolHTTPS 通信
ExfiltrationExfiltration Over Web ServiceHTTPS POST 数据上传
ImpactFinancial TheftCrypto Wallet Seed / Mnemonic Theft

处置建议

如果发现服务器安装了相关 Package,应立即执行以下操作。

第一步:隔离和确认

确认 Package Name
确认安装时间
确认首次部署时间
确认恶意 JavaScript 是否进入生产环境
确认访问日志保存范围

第二步:移除恶意依赖

不要仅删除单个 JavaScript 文件。

应完整检查:

composer.json
composer.lock
vendor/
Theme Assets
Deployment Artifact
CDN Cache
CI/CD Cache
Backup

然后从可信来源重新构建部署环境。

第三步:凭据轮换

建议轮换:

SSH Credentials
CMS Administrator Passwords
Database Passwords
API Keys
OAuth Secrets
Cloud Credentials
CI/CD Tokens

第四步:用户影响调查

建立时间线:

Malicious Package Introduced
Production Deployment
Malicious JS Active
Malicious Domain Connection
iOS Visitors
Potential Exploitation Window

核心调查问题是:

在恶意 JavaScript 活跃期间,有多少 iOS 用户访问过受影响网站?


核心威胁判断

供应链攻击正在成为终端攻击分发渠道

传统供应链攻击通常被理解为:

Malicious Package
Developer
Developer Workstation

本次事件展示了新的传播模型:

Malicious Package
Website Operator
Production Website
Large Number of Visitors
Browser Exploitation
Endpoint Compromise

攻击面已经扩展为:

Package
Service Provider
Customer
Endpoint

因此,Package 风险评估不能只考虑:

“该依赖是否会影响开发者主机?”

还必须考虑:

“该依赖是否能够向生产用户分发主动攻击代码?”


恶意 JavaScript 是高价值 Exploit Delivery Layer

一旦攻击者控制网站 JavaScript:

No Malicious App Installation
No APK
No Traditional File Download
No Explicit Permission Prompt

用户只需要访问网站,就可能进入漏洞利用流程。

因此,受污染的 Web Asset 实际上可以成为:

Exploit Delivery Infrastructure

这也是为什么:

Third-party JavaScript
Theme
Analytics Script
Ad Script
CDN Asset

需要被视为软件供应链的一部分。


广告欺诈与高价值攻击可以使用同一基础设施

攻击者可以同时采用两种收益模型:

Mass-scale Monetization
    ├── Ad Fraud
    └── Gambling Redirect

以及:

High-value Targeting
    ├── Spyware
    ├── Credential Theft
    └── Crypto Asset Theft

因此,同一个恶意 Package 并不一定只有一个 Payload 目的。

攻击者可能根据:

Device
OS Version
User Value
Traffic Source

动态选择攻击方式。


需要关注休眠型恶意 Package

本次事件再次说明:

当前没有发现恶意 Payload,不代表 Package 是可信的。

如果攻击者仍然拥有:

Package Ownership
+
Update Permission
+
Code Injection Capability

那么 Package 本身就可能成为未来攻击的预置入口。

因此,SCA 和 SBOM 不应只依赖:

Known Malicious Hash

更应该建立:

Package Provenance
+
Publisher Reputation
+
Maintainer Relationship
+
Namespace Analysis
+
Code Provenance
+
Update Behavior
+
Runtime Network Behavior

的综合供应链风险模型。


最终结论

这起事件展示了一种非常值得关注的现代攻击模式:

Software Supply Chain Compromise
Production Website Compromise
Browser-based Payload Delivery
Traffic Filtering
N-day Exploitation
Privilege Escalation
Mobile Spyware
Financial Data Theft

攻击者首先利用 Packagist 和 Composer Theme 作为供应链入口,再利用合法网站的用户访问流量作为恶意 Payload 的分发渠道。

从 CTI 视角,本事件反映出三个重要趋势:

  1. 软件供应链正在成为浏览器和终端漏洞利用的规模化分发渠道;
  2. 恶意 Web JavaScript、广告欺诈和高端 Exploit Infrastructure 正在融合;
  3. 传统 Spyware 正逐渐加入加密钱包和数字资产窃取能力。

建议企业建立跨层防御体系:

Package Security
        +
SBOM / Dependency Inventory
        +
Publisher Reputation
        +
JavaScript Integrity Monitoring
        +
CDN / Web Asset Monitoring
        +
Network Detection
        +
Threat Intelligence
        +
Mobile Incident Response

对于使用 PHP、Laravel、Composer 和第三方 Theme 的组织,尤其应将:

第三方 Theme
=
Production Supply Chain Component

纳入正式的供应链安全管理范围。


参考来源

Socket Threat Research

《13 Malicious Packagist Themes Deliver iOS Spyware That Steals Crypto Wallet Seeds》

发布日期:2026-08-31

原文:

https://socket.dev/blog/packagist-themes-ios-spyware

2 - Oink Release Notes

Versioned OINK release notes, upgrade guidance, and compatibility notices

2.1 - Oink 0.8.0 — A whole section in one fetch, the sidebar as data, and who links here

Oink 0.8.0 adds two opt-in output formats for readers that arrive as programs — a full-text bundle holding an entire section in one file, and a navigation tree published as JSON — plus static backlinks, which list the pages that link to a page in its right rail. All three stay off until you ask.

Oink 0.8.0 changes no component API and needs no content edits. Two of its three additions serve the reader that arrives as a program. Every page already publishes a .md twin, which serves an agent that knows the page it wants; an agent that wants the manual still had to crawl it a page at a time, discovering links as it went. Two new output formats answer the other half: give me the whole section, and tell me what is in the site before I fetch anything. The third addition is for the reader who is a person — a page’s right rail can now list the pages that link to it.

At a glance

  • LLMSFULL publishes llms-full.txt per top-level section: every page of the section, in sidebar reading order, in one file.
  • NAVJSON publishes navigation.json per language: the sidebar tree as data, versioned by a JSON Schema.
  • params.ui.backlinks lists the pages that link to a page in its right rail, derived at build time from the links already in your Markdown.
  • All three are opt-in and none is ever turned on for you. A site that asks for none of them builds byte-for-byte what it built before.
  • llms.txt lists whichever ones you enabled, so discovery stays in the file an agent already fetches.
  • A data/docs_nav.json node without a children key no longer crashes the build.

The full-text bundle

LLMSFULL collapses a whole section into one file: llms-full.txt at the section root, holding every page of that section concatenated in the order the sidebar and pager present them, each page introduced by a separator carrying its source URL. For an agent, /docs/llms-full.txt is one fetch where the alternative was one fetch per page plus a link graph to follow — and the result is ordered, so the section reads as a manual rather than as a bag of pages.

A section asks for it in its own front matter; the theme never adds it to a site’s output set:

content/docs/_index.md
---
title: Docs
outputs: [HTML, print, RSS, markdown, LLMSFULL]
---

Front matter outputs replaces the site-level list wholesale, so write back the formats the section already had. It is per language, so _index.zh.md repeats the line to get the Chinese bundle.

What a page contributes is the same semantic Markdown its own .md carries — not a second rendering of it. The per-page Markdown body moved into a shared partial that both outputs call, so a bundle entry is byte-identical to that page’s .md and the two cannot drift apart. Order comes from the same authority the sidebar reads: the explicit data/docs_nav.json tree where a docs or book section declares one, the weighted content tree otherwise. Pages held out of the sidebar stay out of the bundle.

A bundle belongs to a top-level section, and there is no whole-site variant: an agent that wants everything reads one bundle per section. Listing LLMSFULL further down the tree warns and emits nothing, so hugo server keeps working while a publishing build with --panicOnWarning stops there.

This site’s docs section has it enabled: https://oink.pgsty.com/docs/llms-full.txt is the entire documentation in one fetch. Details, including the file’s exact shape: Full-text bundle.

Navigation JSON

The sidebar is the site’s table of contents, and an agent that can read it plans a route before spending a single fetch on content. NAVJSON publishes it as data — navigation.json, one file per language at the language root. The site turns it on for the home page:

hugo.yml
outputs:
  home: [HTML, LLMS, NAVJSON]

The tree is not a second description of the site’s structure. It serializes the same authority the sidebar and the pager already read, through the same partial: the explicit data/docs_nav.json tree where one is declared, the weighted content tree everywhere else. A check asserts that the docs subtree flattens to exactly the page sequence the full-text bundle emits — two template paths, one authority.

Each node carries an id (the path with the language prefix removed, so the same page has the same id in every language), the absolute url, the markdown URL where the page publishes one, title, description, a kind, and its ordered children. Two properties are worth reading as promises rather than as implementation details:

  • Array order is the contract. The ordering has already been applied, and weight is never serialized — a consumer re-sorting the array would disagree with the sidebar the array came from.
  • The format is versioned. schemaVersion is 1, and the contract ships in the theme repository as schema/nav.v1.schema.json. Validate against it if you consume the file.

This site’s https://oink.pgsty.com/navigation.json is a live instance. Details, including the placeholder rows and the full key table: Navigation JSON.

A reader who lands on a page from search can see where it points and nothing about where it sits. Backlinks close that half: the pages that link to this one appear as a “Backlinks” group in the right rail below the table of contents, expanded by default; more than eight entries fold behind a disclosure. One key turns it on:

hugo.yml
params:
  ui:
    backlinks: true

A page overrides it with the front matter key backlinks, and a section cascades it to everything below.

The index is derived at build time from what you already wrote: ordinary Markdown links and ref / relref in the page source. There is no [[wikilink]] syntax to adopt, nothing to migrate, and no JavaScript — the links are in the HTML, they are in the page’s Markdown output, and they are there for a reader with scripts off. Code fences and inline code are stripped before scanning; repeated links to one target merge into one entry; self links, external links and same-page anchors never count; each language has its own graph. Order is the stable page path, so the same content always builds the same list, and when nothing links in there is no block at all.

One honest limit: reading the source misses a URL buried in a custom shortcode’s parameters or in a raw <a href>, and a destination that will not resolve is dropped quietly. This is navigation, not link checking — a link checker is still the tool for finding broken links.

This site enables it site-wide: look at the right rail of any docs page, and the most-referenced page — Configuration — lists more than forty inbound links. Details: Backlinks.

Discovery stays in llms.txt

Neither file is an alternate representation of a page, so neither appears in <head> or gains a page action. Instead llms.txt — the file an agent fetches first anyway — grows a ## Full-text bundles list of this language’s bundles, and lists this language’s navigation.json in its site index. Both entries appear only where the site actually publishes the file: the theme never points at something it did not emit.

A childless nav node no longer crashes the build

A node in data/docs_nav.json with no children key ended the build with a reflection error from inside the sidebar walker. The walker assumed every node had the key, which holds for generated JSON and does not hold for JSON someone writes by hand — where a leaf is naturally written as a node without children. Authored data now degrades instead of erroring: the childless node renders as the leaf it is.

Upgrading

hugo mod get github.com/pgsty/oink@v0.8.0
hugo mod tidy

Nothing changes until you ask for it. No component API changed and no content edits are required — the two formats are declared in outputs, backlinks are one boolean under params.ui, and a site that asks for none of the three publishes what it published under 0.7.1. The two output formats, and the shape of what they produce, are on AI-agent support; the backlink switch is on Navigation and menus.

The complete list is in CHANGELOG.md.

2.2 - Oink 0.7.1 — Nothing leaves the page, and bad input stops breaking builds

Oink 0.7.1 is a security and validation patch. Swagger UI stops sending your spec URL to a third party, misconfigured values warn instead of killing an ordinary build, and the OpenAPI and terminal components finally behave in print, Markdown and RSS the way every other component already did.

Oink 0.7.1 changes no component API and needs no content edits. It closes the code findings from an external review of the 0.7.0 line: one real privacy leak, a class of configuration values that could stop a build outright, and three components that had never been taught what a non-HTML output is.

At a glance

  • Swagger UI no longer contacts the online validator. A published API page made a third-party request on every view; it no longer makes any.
  • A URL written into site configuration now passes the same safety check as one written by an author.
  • A mistyped number or boolean in params warns and falls back instead of ending an ordinary hugo server session.
  • swagger, redoc and asciinema emit a plain link in print, Markdown and RSS, and load their runtime only in interactive HTML.

Swagger stopped phoning home

Swagger UI ships with an online validator enabled by default, pointed at validator.swagger.io. It skips that request for localhost, which is why no local preview and no browser test ever showed it — and why every deployed API page was quietly handing your spec URL to a third party. On an intranet that URL is an internal hostname.

The initializer now pins validatorUrl: null, and it moved out of an inline <script> into a cacheable js/chunks/swagger-init.js. A normal build still downloads nothing, and now a normal view uploads nothing.

Configured URLs go through the same gate as authored ones

Two settings reached an href unchecked: the custom links in params.ui.page_context_menu.links and the archived-site banner’s params.url_latest_version. A javascript: URL in either one rendered as a working, clickable script.

Both now run through the theme’s shared URL policy: an unsupported scheme warns and the link is dropped rather than repaired. The archived-version banner additionally escapes its URL where it is written into the page, because clearing the scheme is not the same as being safe inside an HTML attribute.

Custom links also skip entries with a missing or non-text name, and the separator above them only appears when a link actually survives.

Bad configuration warns; it no longer kills the preview

The theme’s rule has always been that invalid author or config input warns, falls back to a documented default, and keeps hugo server usable — with --panicOnWarning turning that warning into a failure where you publish. A group of numeric and boolean keys had never been wired into it.

Before 0.7.1, a value like blog_index_size: nope ended the build with a Go template error. Others were worse for being quiet: sidebar_width_min: -50 emitted a negative pixel width without a word, and blog_index_columns: 2.5 fed a fraction into a CSS grid.

Every numeric and boolean key now resolves through the shared validator:

InputBeforeNow
blog_index_size: nopebuild failswarns, uses 12
blog_index_size: 0silently becomes 12warns, uses 12
sidebar_width_min: -50emits -50pxwarns, uses 220
sidebar_width_min: 300 with max: 200inverted layoutwarns, uses 220/480
blog_index_columns: 2.5fraction into CSSwarns, uses 3
sidebar_item_overflow: clipsilently ellipsiswarns, uses ellipsis
print.toc: nopesilently truewarns, uses true

The same treatment reaches the Landing sections: the hero’s media.ratio and media.max_width, the capabilities board’s columns and rules, and a marquee’s rows. The hero’s two style inputs were the notable ones — they were written straight into a style attribute, so a page’s own front matter could put arbitrary CSS on the page. ratio now takes exactly two track sizes ('1fr 240px'), max_width a plain CSS length.

If you have been running with a value the theme silently corrected, you will see a new warning. That is the point — build once with --panicOnWarning after upgrading to find them.

OpenAPI and terminal recordings respect the other outputs

Every Oink component renders once and then adapts to the output it lands in: interactive HTML, static print, plain Markdown for agents, and RSS. Sixteen components already did this. swagger, redoc and asciinema did not — they rendered their interactive markup into all four.

The result was a Markdown output carrying <div class="td-asciinema"> and a JSON config block, a printed page holding an empty container where a player should be, and a single-page print that actually downloaded the player runtime to show one frozen frame.

All three now read the output format:

OutputWhat you get
HTMLthe full interactive component
Printa labelled static link showing the URL
Markdown / LLMSa plain Markdown link, nothing else
RSSthe same plain link

Only interactive HTML registers the runtime, so print and machine outputs load no player, no Swagger bundle and no ReDoc bundle. A cast or spec URL now also goes through the shared URL policy, and a mistyped speed, cols, rows or marker time warns and is ignored instead of ending the build.

Smaller repairs

  • The capabilities board’s rule bars render at their authored widths. The template had always emitted them; the stylesheet never read them.
  • The generated configuration schemas match what Hugo actually parses. Trailing comments in hugo.yaml were leaking into eleven default values — print.toc shipped as the string "true # section print views…" — and four comment blocks documented the wrong key. Keys that exist only to warn about a rename no longer appear in editor completion.
  • A hero whose media is not a map warns and drops the media instead of ending the build.

Upgrading

hugo mod get github.com/pgsty/oink@v0.7.1
hugo mod tidy

No content, configuration or template changes are required. One thing to do after upgrading: run a build with --panicOnWarning once. Configuration that used to be corrected silently now says so, and that build is where you will see it.

The complete list is in CHANGELOG.md.

2.3 - Oink 0.7.0 — A theme color, one typographic voice, and diagrams you can read

Oink 0.7.0 gives a section its own accent through the shell’s grounds, hands the site control of all seven typography roles, and turns a Mermaid fence into a figure that is centred, redrawn in place on a colour-scheme change, and openable at its own size.

Oink 0.7.0 changes no component API. It works on the two surfaces a reader actually spends time in — the shell around the page and the type on it — and finishes a fence that had never been designed, only inherited.

At a glance

  • params.ui.theme_color gives a section its own accent, applied to the shell’s grounds rather than to the prose.
  • params.ui.fonts reaches all seven typography roles; a Book no longer carries a face of its own.
  • A mermaid fence is a figure: centred, unframed, redrawn in place when the colour scheme changes, and openable at full size in a pan/zoom dialog.
  • Inline code is crimson ink on a hairline wash instead of a grey pill.
  • First-party browser behaviour publishes as stable capability chunks under js/chunks/, so a page selects scripts instead of minting its own bundle.
  • Configuration schemas are generated from the resolvers, not maintained by hand.

A theme color

params.ui.theme_color takes a #rgb or #rrggbb hex and tints the shell’s accent grounds: the selected sidebar row and the ground its neighbours take under the pointer, hover washes, the outline pill with its travelling rail and dot, tag and chip hovers, a card’s hovered edge, a share button’s hover fill, text selection, and focus rings.

hugo.yaml
params:
  ui:
    theme_color: "#2f6f4f"

A section can set its own, and a page opts out of an inherited one with theme_color: false. What it deliberately leaves alone is the reading surface — prose links, external URLs and inline code keep the brand palette in every section — so a colored section is a quiet signal of place rather than a recolor of the page.

One typographic voice

params.ui.fonts reaches the theme’s seven typography roles from configuration, so a site can change its voice without shipping a stylesheet.

A Book no longer carries typography of its own. Its numbers and captions used to render in a bundled monospace with a Latin subset only, which split a Chinese caption across two faces mid-sentence — the digits in one face and the characters in whatever fallback the reader happened to have. They now inherit the surrounding face, with tabular-nums holding the sidebar column aligned.

Diagrams you can actually read

A mermaid fence used to be five lines of passthrough: it handed Mermaid the <pre> of a code block and let startOnLoad decide the rest. Three defects followed from that one decision, and all three needed the same fix — keeping the source readable after Mermaid has run.

The fence now emits a figure holding an empty stage and its source as JSON, the shape echarts and infographic already use, and a runtime owns when each diagram is drawn.

Centred, and unframed. Mermaid emits width="100%" with a max-width at the diagram’s own size, so anything narrower than the column sat against the start edge with up to 300px of empty bordered box beside it — the border being the code block’s. There is deliberately no alignment attribute: a diagram is a figure, and no reader wanted one flush right.

Openable at its own size. Mermaid does not overflow a narrow column, it shrinks to fit one, so overflow-x never offered a way back: on a 390px phone the sequence diagram on this site’s own Mermaid page rendered at 35% of its natural width, turning 14px labels into five. Hovering a diagram — or reaching it with the keyboard — now reveals a control in its corner that renders the diagram a second time into a dialog at full size. Drag to pan, zoom with the wheel, a two-finger pinch or +/-, reset with 0, close with Esc. A diagram that would have to shrink past half size to fit opens at 1:1 at its starting corner instead of as a thumbnail, and zooming back out always reaches the whole diagram however large it is.

No more page reload on a colour-scheme change. The old runtime reloaded the whole page on every theme change on any page holding a diagram, citing a Mermaid limitation from the 8.x era. Mermaid 11 re-initializes cleanly, so the diagrams are redrawn in place, and each stage holds its height across the swap so nothing moves under the reader.

A diagram inside a tab that is not the open one now renders at its proper size. Inside display: none every text measurement returns zero, and Mermaid wrote the resulting max-width: 16px into the SVG for good; revealing the tab never recovered it.

Markdown, RSS and Print carry the fence source. Print had been carrying a <pre class="mermaid"> that no runtime ever reached, at font-size: 0, so a printed diagram was a blank gap.

The reading surface

Inline code is crimson ink on a hairline wash rather than a grey pill. The old tint made every token a lozenge; the much fainter wash now marks token boundaries while the monospace face, weight and hue do the identifying, which keeps a token-dense paragraph readable instead of turning it into a field of grey controls.

The series strip is a panel the width of the bar rather than a stack of links, taxonomy chips are quiet at rest and light up under the pointer, the navbar’s dropdown panels breathe in and out instead of popping, and link hover leaves the muted navy for a vivid azure.

Build and infrastructure

  • First-party browser behaviour publishes as stable capability chunks under js/chunks/. A page selects script tags by capability instead of minting a bundle of its own, so the chunks cache across pages.
  • bin/generate-config-schema.py projects the schemas from the resolvers, and CI fails when a new params key lands without one.
  • An opt-in BookManifest output records the Book sequence with stable ids.
  • One media-result contract sits behind every resolved image.
  • Google Analytics is limited to interactive HTML output; Print and machine outputs no longer carry it.
  • The Book publication job renders its PDF. It never had: chrome-headless-shell needs unprivileged user namespaces, which Ubuntu 24.04 restricts through AppArmor, and the job had failed on every run it ever had.

Upgrading

hugo mod get github.com/pgsty/oink@v0.7.0
hugo mod tidy

Nothing in the component API changed, so no content edits are required. Two things are worth knowing:

  • A mermaid fence no longer renders <pre class="mermaid">. Site CSS targeting that selector matches nothing now; the diagram is a figure.td-diagram holding .td-diagram__stage.
  • If a site pins the theme version in a check of its own, that assertion moves with the pin.

The complete list is in CHANGELOG.md.

2.4 - Oink 0.6.0 — Immersive blogs, safer builds, smaller internals

Oink 0.6.0 adds an immersive presentation to the existing Blog shell, completes Blog publishing with images, authors, series, three index forms and sharing, and replaces build-stopping template errors with safe warnings.

Oink 0.6.0 keeps the component API established in 0.5 and improves the systems around it: long-form reading, Blog discovery, attribution, release publishing, build resilience, and maintainability.

There is no new article type or second shell. Immersive reading is a configuration of the existing Blog shell, so articles remain in the same lists, feeds, taxonomies, series, and pager sequence.

At a glance

  • The Blog shell gains a full-bleed hero image and an in-flow outline rail.
  • Blog publishing gains profiles and bylines, series order, list/cards/table indexes, and a local-first share bar.
  • Vendored and translated pages gain optional, validated attribution.
  • The theme no longer calls errorf: ordinary previews warn and safely degrade; publishing builds remain strict through --panicOnWarning.
  • Release metadata is now one release_url, not a duplicated fact map.
  • Repeated template work, page bundles, and checker builds have been reduced without pruning Font Awesome or other public authoring assets.

Immersive Blog presentation

An immersive page uses four independent front matter keys:

featured_image: hero
toc_style: flow
toc_taxonomies: false
sidebar_enabled: false

Put the same keys in a section cascade to apply them to its posts. Hugo resolves cascade values on the section index that declares them as well as on its descendants, so a section that should share the presentation writes the keys once.

hero paints the resolved featured image behind the opening and masks it out before the article text. The ordinary navbar remains available and uses a fading scrim over the artwork. toc_style: flow gives the outline a wider in-flow rail that begins with the article and becomes sticky on scroll. toc_taxonomies: false removes term clouds from that rail. The Blog shell omits breadcrumbs by default; breadcrumb: true restores one for a page or cascade that wants it.

Each switch is independent. A missing image produces the normal opening; an empty TOC and disabled clouds produce no empty rail. Nothing changes the page’s Blog membership or output formats.

The Blog, completed

params.ui.featured_image and the page key featured_image support:

ModePresentation
noneNo article image; the default
bannerA framed 16:9 image above the title
washA low-opacity image behind the article header
heroA full-bleed Blog backdrop

All modes reuse the representative-image resolver also used by list thumbnails and social metadata. Missing images are valid, and non-HTML outputs keep their static source-shaped forms.

Authors

Declare taxonomies: {author: authors}. The author term page is the profile: its title is the name, its description and body are the biography, and its representative image is the portrait. Posts list authors with authors: [vonng, oink]; the order is preserved. The legacy author: string remains the fallback when the taxonomy is not used.

Series

Declare taxonomies: {series: series}. Posts name one or more terms with series and may set series_weight. Weighted members come first in ascending order; unweighted members follow by ascending date. The article and term page share that resolver, so the position strip and archive cannot drift.

Three index forms

KeyDefaultMeaning
ui.blog_indexlistlist, cards, or table
ui.blog_index_columns3Card columns
ui.blog_index_size12Posts per list/cards page
ui.blog_index_togglefalseReader-side cycle among all three forms

List and cards share year grouping and pagination. A standalone table is a complete unpaginated archive. With the reader toggle enabled, all three forms share the current paginator slice; the full archive is not repeated on every generated page. The configured form controls first paint, and a local preference may override it.

Sharing

params.ui.share is an ordered list drawn from x, bluesky, mastodon, facebook, linkedin, reddit, hackernews, telegram, whatsapp, line, pinterest, weibo, chatgpt, claude, email, and copy. Empty is the default; share: false opts out one page.

The bar uses plain intent links and the local copy action. It loads no platform SDK, iframe, counter, or third-party stylesheet.

Page annotation

upstream_link is the per-page source URL. Its companion facts are upstream_name, upstream_copyright, upstream_license, upstream_notice, upstream_ref, and upstream_modified. Constants may come from site parameters, a data/upstreams entry, or front matter.

Incomplete facts, unknown licences, unsafe URLs, and invalid types warn and omit the whole attribution line. Strict builds reject the warning. upstream_link: "" explicitly opts a page out of an inherited attribution.

params.ui.translation_notice optionally names the authoritative language. It is never imposed as page front matter; a page may opt out with translation_notice: false.

Warnings instead of preview outages

The theme contains no errorf calls. Simple scalar values share validate.html; components retain the checks that understand their own records and markup.

Invalid input follows one rule:

  1. warn with the bad value and the safe fallback or omission;
  2. do not emit unsafe or misleading output;
  3. let an ordinary hugo server continue;
  4. let --panicOnWarning stop CI and publication.

This preserves strict gates without letting one typo take every preview URL down.

Outline rail

The outline exposes a visible range and a current cursor on one SVG path. The cursor carries aria-current="location"; reduced-motion and unsupported registered-property engines fall back without detaching the cursor from the line.

Fixed and simplified

  • Mounted content no longer publishes build-machine paths in Edit, History, or Create Child URLs.
  • Generic data-* and aria-* values use one HTML-escaping emitter.
  • Incomplete Algolia credentials emit no container, CSS, or JavaScript.
  • Draw.io loads only on pages with PNG/SVG candidates and inspects each distinct URL once.
  • Page actions, pager state, language targets, and section-index children reuse page- or site-scoped results instead of repeating full-site work.
  • Language-neutral feature bundles are shared across translations.
  • Field anchors are derived from field names and remain unique within a page.
  • Print aggregates namespace headings and footnotes without changing regular page IDs.
  • The maintained invalid-input checker batches equivalent cases: the content primitive stage now starts Hugo six times instead of 160 while preserving every warning and fallback assertion.
  • Obsolete CSS, i18n keys, rejected Article-shell artifacts, duplicate checker blocks, and narrative code comments have been removed. The complete supported Font Awesome distribution remains intact.

Configuration

KeyDefaultNotes
ui.featured_imagenonenone / banner / wash / hero
ui.toc_stylefixedfixed / flow
ui.toc_taxonomiestrueShow taxonomy clouds in the right rail
ui.blog_indexlistlist / cards / table
ui.blog_index_columns3Card columns
ui.blog_index_size12List/cards page size
ui.blog_index_togglefalseReader-side three-form cycle
ui.share[]Ordered share targets
ui.translation_noticefalseOptional authoritative language
time_format_blog2006-01-02Changed default
time_format_default2006-01-02Changed default

The default shell and pager type lists remain docs, book, blog, and swagger where applicable. No article type is added.

Migration

From 0.5:

  1. Keep explicit prose date formats if ISO dates are not desired.
  2. Ensure publishing commands use --panicOnWarning.
  3. Replace the old release map with release_url: https://github.com/<owner>/<repo>/releases/tag/<tag>.
  4. Replace upstream_attribution with upstream_link, and downstream_modified with upstream_modified.
  5. Do not migrate content to type: article; use the Blog presentation keys shown above.

The migration tool operates on content Markdown and supported YAML front matter. Configuration-file mappings remain an explicit maintainer task. From 0.4, keep the established sequence: report, migrate --write, then check.

Verification

The 0.6.0 release is verified with:

  • Hugo Extended 0.160.1 and 0.164.0;
  • 40 HTML/print/Markdown/RSS/LLMS golden surfaces;
  • 85 migration tests and 38 browser-runtime tests;
  • strict example, Hugo Module, system-font, legacy-font, and invalid-config builds;
  • the bilingual project-site build and its non-browser regression suite;
  • representative large-site performance measurement and real EN/ZH browser checks.

Local validation, a commit, a tag, a push, a consumer pin, and deployment remain separate release states.

Full change set

v0.5.0 to v0.6.0

2.5 - Oink 0.5.0 — Component API v5 and the converged configuration

Oink 0.5.0 replaces most shortcodes with native Markdown forms, converges every configuration and front matter key on three rules, removes the 0.x compatibility layer, and ships the migration toolkit that rewrites a 0.4 site. Every old key, shape, and shortcode fails the build with its replacement instead of being silently ignored.

Oink 0.5.0 is the API-freeze release. It contains the change set that the 1.0 line will freeze: the component API v5 (native Markdown forms first, 29 shortcodes as full forms), configuration and front matter keys converged on three rules, one naming namespace for everything the theme emits, the removal of the 0.x compatibility layer and of the Docsy leftovers no site used, and a migration toolkit that rewrites a 0.4 site. Every retired key, shape, or shortcode fails the build with a message that names its replacement.

This is a breaking release for every 0.4 site. Read At a glance first, then the migration guide; the reference sections in between list every change with its old and new form.

At a glance

  • Content: most components are written as Markdown — > [!TYPE] callouts, {.steps} and {.cards} lists, {.fields} / {.matrix} / {caption=} / {#id num=} / {tab=} tables, ```filetree / ```gallery / ```echarts / ```infographic / ```checksums data fences, adjacent code fences as tabs, and the Markdown image with an attribute line. Of the 53 shortcodes in 0.4.2, 32 are gone or renamed and 8 are new; 29 remain as full forms. scripts/migrations/oink06.py rewrites content.
  • Configuration: three rules — a switch is the bare feature name, single-key maps are flattened, a front matter key is the site key without ui.. Some forty keys are renamed or reshaped; every old one fails the build with its replacement. Every theme default is declared in the theme’s hugo.yaml.
  • Front matter: no ui: block any more; page overrides are bare keys (section_index: cards), page_context_menu mirrors the site map, manualLink* are manual_link*, hide_*/exclude_search are gone.
  • Namespace: theme classes are td-*, data attributes data-td-*, custom properties --td-*, JS globals Oink*; the oink-* set and Docsy leftovers (leaf, has-child, nav-*, …) are gone. Callout labels are callout_* i18n keys.
  • Removed: the home/** adapter partials, outputformat.html, td/render-heading.html, the Docsy community page and params.links, the td/code-dark / td/color-adjustments-dark / td/gcs-search-dark / td/extra Sass files, .td-box* and -bg-* palette classes, Prism, Open Sans, click-to-copy.js, swaggerui (now swagger).
  • Behaviour: headings carry a self-link, print content is rendered once per build (a real race fixed), three cacheable JS bundles, print pages load 8 KB of JS instead of 100 KB, shell motion honours reduced motion by construction, giscus palettes ship with the theme and load only where comments render.
  • Migration: oink06.py report → migrate --write → check for content and front matter, then a build whose errors are the configuration checklist.
  • Release hardening: a two-round adversarial review repaired the client-side namespace migration, action-registry load order, fail-closed migration input, multi-instance OpenAPI embeds, shared attribute and image-URL policies, and consuming-site configuration preflight before the API freeze.

Component API v5

Native forms first

The v5 principle: a component that a Markdown block can express is written as Markdown; a shortcode exists only for what a block cannot carry. Render hooks recognise the native forms, and one attribute policy governs every hook.

Components: native form and full form

Callout , native

> [!NOTE] Title blockquote; [!TYPE]- folded / [!TYPE]+ open; optional {icon="fa-solid fa-x"}; types note tip important warning caution success danger question example quote details. No shortcode.

Tabs , native + shortcode

Native: adjacent fences (or tables) with {tab= group= value=}.

Shortcode: tabs group= default= label= tab label= value=/tab /tabs.

Steps , native + shortcode

Native: 1. list + {.steps}.

Shortcode: steps with headings — the only shortcode written with the % delimiters (its body is page-level Markdown); headings inside steps enter the TOC.

Cards , native + shortcode

Native: link list + {.cards}.

Shortcode: cards card title= link= icon= badge= image= image_alt=|decorative= body /card /cards.

Fields , native + shortcode

Native: table + {.fields [caption=] [id=] [meta="type required default -"]} — first column name, last column description, middle columns metadata chips.

Shortcode: fields label= id= class= field name= type= required= default= body /field /fields — for block-level descriptions (this list is one). Both forms render the same chips; every entry gets a #field-<name> anchor.

FileTree , native

```filetree {title=} fence, one - name[/] # comment {icon= tone= open= type=} line per entry; 2/4-space, tab, or tree indentation. CSS + native <details>; the comment column is aligned at build time. No shortcode.

Gallery , native

```gallery fence, one ![alt](src) # description {link= class=} line per image; alt is required, items are Zoom-eligible. No shortcode.

Image , native

![alt](src "title") plus an attribute line {#id num= caption= width= height= link= command= options=} for figures, numbering, links, and Hugo image processing. imgproc is retired; there is no image shortcode.

Table family , native

{.full-width} {.fields} {.matrix} {caption=} {#id} {#id num= caption=} {tab= group= value=}; site classes pass through. Exclusivity: fields ⟂ matrix / full-width / num; num ⟂ tab.

Fig / Tbl / Eq / Eg , native + shortcode

Native: image / table / $$ block / fence + {#id num= caption=} (default ids fig-, tbl-, eq-, eg-<num>).

Shortcode: fig tbl eq eg (eg caption required).

Xref , native + shortcode

Native: plain Markdown links (kind-less).

Shortcode: xref fig|tbl|eq|eg="…" [page=] [anchor=].

Book indexes , shortcode

book-toc book-figures book-tables book-equations book-examples — no kind= parameter.

Code fences , native

Fence attributes {title copy wrap collapse label id tab group value num caption lineNos hl_lines lineNoStart anchorLineNos tabWidth}; Chroma only.

Data fences , native

mermaid plantuml markmap math chem echarts infographic checksums filetree gallery; echarts is declarative, $fn:<name> callbacks come from window.OinkEchartsFunctions.

Leaves , shortcode

kbd badge param include comment contributors asciinema (raw <kbd> also works); badge has no outline, param is scalar only.

Release / OpenAPI , shortcode

release-card release-assets download / swagger redoc; the checksums fence is the native release form.

The 29 shortcodes: core 14 (tabs tab steps cards card fields field include kbd badge param comment contributors asciinema), Book 10 (fig tbl eq eg xref book-toc book-figures book-tables book-equations book-examples), Release 3, OpenAPI 2. Nested names (tab, card, field) are valid only inside their parent; every shortcode validates its parameters, and an unknown parameter fails the build (asciinema, redoc, swagger, param, comment, and steps accepted anything silently in 0.4).

Removed shortcodes and their replacements

The chip on each entry is the toolkit key (scripts/migrations/oink06.py migrate --only <key>); manual means the report lists it and a human edits.

Removed shortcodes and their replacements

alert · details · td-page-notice , callout

0.4: alert color=… title=…, details, td-page-notice (all % shortcodes), raw <details><summary>.

0.5.0: > [!TYPE] title callouts, > [!DETAILS]- for a folded block.

tabpane · tab · code-group · code-tab , tabs

0.4: tabpane with tab header=… (both % shortcodes), code-group with code-tab.

0.5.0: adjacent fences with {tab= group= value=} (code-only panes), or tabs with tab for mixed content.

filetree · filetree/folder · filetree/file , filetree

0.4: filetree with filetree/folder and filetree/file; the interim {.filetree} list marker.

0.5.0: the ```filetree fence — label becomes title; open, icon, color, comment, link are kept.

gallery · gallery/image , gallery

0.4: gallery with gallery/image; an image list + {.gallery}.

0.5.0: the ```gallery fence.

echarts · infographic , datafence

0.4: echarts, infographic shortcodes.

0.5.0: same-named data fences; $fn: callbacks are unchanged, js sub-fences move to window.OinkEchartsFunctions.

doc-cards · doc-card · nav-cards · nav-card · card · cardpane · doc-carousel , cards

0.4: the Docsy card family and the OINK doc-cards / nav-cards wrappers.

0.5.0: cards with card, or a link list + {.cards}. card keeps its name as the child of cards, with a different contract.

imgproc , image

0.4: imgproc … (and the pre-release image …).

0.5.0: ![alt](src) + {command= options= caption=} on the attribute line.

readfile , include

0.4: readfile file=….

0.5.0: include file=… [code=true lang=…] — page resources, then assets, then content-relative paths.

fence filename= , fencetitle

0.4: {filename="x"} on a fence.

0.5.0: {title="x"}.

badge outline= , badge

0.4: badge … outline=….

0.5.0: drop outline — there is one badge appearance.

example · book-figures kind= , eg

0.4: self-closing example … + fence; book-figures kind="tbl".

0.5.0: eg/eg; book-tables, book-equations, book-examples.

fields · field (percent form) , fieldsdelim

0.4: fields / field written with % delimiters (never shipped).

0.5.0: fields / field.

_param · iframe · conditional-text · netlify · kind-less xref , reportonly

Reported with file:line for manual review; _param placeholders are handled by the param_placeholders transform.

blocks/cover · blocks/feature · blocks/lead · blocks/link-down · blocks/section , reportonly

0.5.0: layout: landing with sections (data file or inline front matter). Reported, not rewritten.

swaggerui , manual

Renamed to swagger; change the call.

pageinfo , manual

Write a > [!NOTE] callout instead.

td/site-build-info/netlify.md , manual

Removed without replacement.

New in 0.5.0 relative to 0.4.2: tabs, cards, include, eg, book-tables, book-equations, book-examples, and swagger (renamed). card and tab keep their names but are now children of cards / tabs with a different contract.

There is no image shortcode: the render hook resolves page resources, section resources, global assets, and static or remote paths for Markdown images, fig, and configuration image sources alike, and carries captions, numbering, links, and Hugo image processing (command, options) on the attribute line — everything imgproc did.

The block-attribute policy

Every render hook (table, image, code block, passthrough, blockquote, heading) shares one policy: allowlisted keys are consumed by the hook, class is token-validated and passed through, data-* and aria-* pass through, and style, on*, and any unknown key fail the build. Site CSS classes on content are legitimate and keep working; inline styles and handlers never reach the output.

Code fences

  • {filename="x"} is {title="x"}. title and filename are mutually exclusive on the same fence.
  • The Prism path is gone. params.prism_syntax_highlighting, static/js/prism.js, and static/css/prism.css no longer exist; Chroma with params.highlight_classes (default true) is the only highlighter. Prism could not coexist with tab, group, value, num, and caption, so any 0.4 site using tabs or numbered examples already failed with it enabled.
  • The Copy control follows copy=all|command|true|false on the fence, then the session-lexer default (console, shell-sessioncommand), then all. params.ui.code_copy: false changes the site-wide default only; a fence that names copy still gets what it asks for. The old disable_click2copy_chroma silently overrode an explicit author value.
  • Docsy’s click-to-copy.js (never loaded since 0.3) and its .td-click-to-copy styles are removed.

Configuration

The three rules

  1. A boolean switch is the bare feature name: ui.annotation: true, not ui.annotation.enable and not ui.annotation_enabled. The only _enabled suffixes left are ui.navbar_enabled, ui.sidebar_enabled, and ui.sidebar_root_enabled, whose bare names would collide with sibling families.
  2. A single-key map is flattened to a scalar. A map survives only for a feature with several settings — comments, ui.feedback, ui.page_context_menu, ui.dark_mode, ui.command_palette, ui.alt_site, taxonomy, print, search, plantuml, drawio, mermaid, copyright, ui.taxonomy_icons — and the on/off ones among them also accept a bare boolean (comments: false, plantuml: false, dark_mode: true, feedback: true, page_context_menu: false).
  3. A front matter key is the site key with its ui. prefix dropped, without exception (see Front matter).

Keys are snake_case, positive, and named for what they do. camelCase survives only where a value is passed straight through to an external runtime (comments.giscus.* carries giscus’s own attribute names, mermaid.* is handed to mermaid.initialize()).

Every old key or shape fails the build with a message that names the replacement — layouts/_partials/config-legacy.html for site configuration, layouts/_partials/front-matter-legacy.html for pages — so an upgrade is a matter of following the errors one by one. Nothing is silently ignored.

Renamed and reshaped site keys

0.40.5.0Note
offlineSearch, offlineSearchIndex, offlineSearchMaxResults, offlineSearchOnServe, offlineSearchSummaryLengthoffline_search, offline_search_index, offline_search_max_results, offline_search_on_serve, offline_search_summary_lengthenvironment override: HUGOxPARAMSxOFFLINE_SEARCH_ON_SERVE=true (Hugo’s alternate x delimiter; _ cannot address snake_case keys)
ui.showLightDarkModeMenu (true / false / "enable-only (experimental)")ui.dark_modetrue, or { enable, show_menu }show_menu: true implies enable
ui.scrollSpy.disableui.scroll_spyinverted; default false
ui.no_left_sidebarui.sidebar_enabledinverted
ui.breadcrumb_disableui.breadcrumbinverted; default true
print.disable_tocprint.tocinverted; default true
disable_click2copy_chromaui.code_copyinverted; sets the default only
ui.readingtime.enableui.reading_timebare boolean
ui.ul_showui.sidebar_expand_levelsdefault 2
Taxonomy.taxonomyCloud, .taxonomyCloudTitle, .taxonomyPageHeadertaxonomy.cloud, .cloud_title, .page_headerone lowercase map
ui.annotation.enable, ui.image_zoom.enable, ui.keyboard_nav.enableui.annotation, ui.image_zoom, ui.keyboard_navbare booleans
ui.typography.presetui.typographytechnical | system; environment override HUGO_PARAMS_UI_TYPOGRAPHY=system
ui.pager.typesui.pager_types[docs, book, blog]
markmap.enablemarkmapbare boolean
content_width (slim | norm | wide)reading_width (slim | normal | wide)Book reading measure; body class td-book-content--normal, token --td-book-content-normal
ui.docs_rootui.docs_sidebar_rootsection | home
github_urlgithub_repoedit, history, and issue links derive from the repo
algolia_docsearchsearch.algolia with appId, apiKey, indexNamefails the build
rss_sectionsremovedit was never read
params.links.user[] / .developer[]removedthe Docsy community page is gone
plantuml.enable, drawio.enableunchanged, and the maps accept plantuml: false / drawio: false
comments.enableunchanged, and comments: false is accepted
comments.giscus.lightTheme / darkThemeunset by defaultthe theme’s own palettes are the default (see Styles and assets)

Every theme default is now declared in the theme’s hugo.yaml with its value range in a comment. Previously template-only fallbacks that are now declared: offline_search: false, offline_search_summary_length: 70, ui.breadcrumb: true, ui.reading_time: false, ui.dark_mode: false, ui.docs_sidebar_root: section, ui.sidebar_icon_policy: all, ui.section_index_columns: 2, ui.code_copy: true, print.toc: true, print.section_break_wordcount: 50, markmap: false, plantuml.enable: false, drawio.enable: false, github_branch: main. Two defaults stay derived and are documented as such: ui.quick_links (from docs_section and blog_section) and ui.taxonomy_icons (built-in categories/tags glyphs). The template fallbacks for ui.sidebar_expand_levels (2) and ui.sidebar_menu_truncate (2000) match the declared values.

Unchanged Docsy keys that keep working as they are: github_repo, github_project_repo, github_branch, github_subdir, path_base_for_github_subdir, time_format_blog, time_format_default, version, versions, version_menu, version_menu_pagelinks, archived_version, url_latest_version, copyright, description, author, gcs_engine_id, search.algolia.*, mermaid, plantuml.*, drawio.*, ui.sidebar_menu_compact, ui.sidebar_menu_foldable, ui.sidebar_menu_truncate, ui.sidebar_cache_limit, ui.sidebar_root_enabled, ui.feedback.{enable,reasons}.

Fail-loud, not silent

Configuring more than one search backend (offline_search, gcs_engine_id, search.algolia) now fails the build (it warned before). PlantUML without plantuml.svg_image_url, Diagrams.net without drawio.drawio_server, and Algolia without all three credentials still fail the build, as in 0.4. Build messages follow one shape — <component>: <subject> <expectation>; got <value> at <position> — lower case, one preposition for the location, configuration errors naming the full params. path; they no longer point at documentation URLs.

Front matter

The page key is the site key without its ui. prefix, and front matter never carries a ui: block. A section cascade works the same way (cascade: { params: { section_index: cards } } or the bare key). One resolver (ui-param.html) reads the page value, else the site value, for every params.ui.* setting a page may override: sidebar_menu_compact, sidebar_menu_foldable, sidebar_expand_levels, sidebar_width_min, sidebar_width_max, sidebar_item_overflow, sidebar_headings, sidebar_enabled, section_index, section_index_columns, lastmod_commit, breadcrumb, scroll_spy, code_copy, keyboard_nav, book_draft_banner, plus the explicit page keys navbar_enabled, navbar_autohide, footer_style, annotation, feedback, image_zoom, reading_time, page_context_menu, comments, page_width, reading_width.

0.4 front matter0.5.0
params: { ui: { <key>: … } } (any key)<key>: … at the top level (or under params:)
params.ui.image_zoom.enableimage_zoom: true | false
params.ui.keyboard_nav.enable, params.ui.annotation.enablekeyboard_nav, annotation (bare booleans)
annotation: { enable: … }annotation: true | false
context_menupage_context_menu (true | false, or { enable, assistant_links })
assistant_links (top level)page_context_menu: { assistant_links: false } — a page can only narrow the site policy
hide_readingtime: truereading_time: false
hide_feedback: truefeedback: false
exclude_search, excludeSearchsearch_exclude
content_width: normreading_width: normal
manualLink, manualLinkTitle, manualLinkTarget, manualLinkRelrefmanual_link, manual_link_title, manual_link_target, manual_link_relref
body_class: td-no-left-sidebarsidebar_enabled: false
contributingUrlremoved with the community page
Iconicon (Hugo is case-insensitive; the theme reads the lowercase form)

Unchanged page keys: toc_hide, toc_root, notoc, no_print, no_list, simple_list, hide_summary, sidebar_root_for, sidebar_divider, sidebar_expanded, sidebar_root_menu, sidebar_root_link_self, search_keywords, search_boost, pager, landing, sections, book_number, book_status, release, release_products, release_group_by_product, upstream_attribution, downstream_modified, byline, author, body_class.

scripts/migrations/oink06.py migrate --only frontmatter rewrites all of the renamed page keys, including inside cascade: maps and lists.

Templates, partials, and layouts

Removed, with what a site that copied or called them should use instead:

0.40.5.0
_partials/home/** (18 adapters), _partials/home-data.html_partials/landing/**, landing/home-data.html
_partials/outputformat.html.Store.Get "tdOutputFormat" (html | print | markdown | rss, set by every base template)
_partials/td/render-heading.html and a site-side _markup/render-heading.html that calls itthe theme’s own _markup/render-heading.html — delete the site override
layouts/community/list.html, layouts/docs/community.html, _partials/community_links.htmlnone — the Docsy community page is gone
_partials/taxonomy_terms_article.html, taxonomy_terms_article_wrapper.html, taxonomy_terms_cloud.htmltaxonomy-terms-article.html, taxonomy-terms-article-wrapper.html, taxonomy-terms-cloud.html
_partials/taxonomy_terms_clouds.html, code/markdown-escape.htmldead in 0.4 already; shell/taxonomy-terms-clouds.html, content/markdown-escape.html
_shortcodes/swaggerui.html_shortcodes/swagger.html
layouts/_default/_markup/render-* copied from 0.4compare against 0.5.0 before keeping any override — every hook changed

Other template-level changes a site with overrides should know about:

  • Both sidebar sources — the content tree and an explicit data/docs_nav.json — render every row through shell/sidebar-node.html. shell/config.html remains the single resolver for brand, logo, and section configuration.
  • Every content-rendering layout calls content/render.html instead of .Content (that is where the Image Zoom candidate scan runs).
  • Print: print/page-content.html renders each page’s print content exactly once per build through partialCached; print/render.html, print/content.html, book/print.html, and the single.print.html layouts read that. Sites that copied a 0.4 print template should drop the copy — the 0.4 pipeline raced on the page store when a section that is itself a section was aggregated by its parent.
  • The theme owns the heading render hook. Every heading carries its id and a hover-revealed self-link (.td-heading-self-link, label ui_heading_self_link); print and RSS strip the link.
  • The DocSearch container is one #td-docsearch element; the two hard-coded #docsearch-0/1 ids are gone.

Styles and assets

One namespace

Everything the theme emits is namespaced, and scripts/check-namespace.py keeps it that way. Site CSS or JS that hooked the old names must move:

Kind0.40.5.0
Classesoink-* (landing subsystem), leaf, has-child, active-path, is-open, is-active, is-hidden, is-disabled, landing-header, landing-nav, landing-container, article-meta, pageinfo, nav-*, taxonomy-*, ul-Ntd-* throughout; the site header and nav are td-site-header, td-site-nav, td-site-container
Data attributesdata-oink-*data-td-*
Custom properties--oink-*, --term-*--td-*
JS globalsoink* / echartsFunctionswindow.OinkActions, OinkEchartsFunctions, OinkLanding, OinkSearchEngine, OinkSurfaceCoordinator
Author markers (unprefixed, unchanged){.steps} {.cards} {.fields} {.matrix} {.full-width}

Sass and tokens

Removed Sass files (a site’s _styles_project.scss that imports them fails to compile): td/code-dark, td/color-adjustments-dark, td/gcs-search-dark, td/extra, td/extra/bs-defaults, td/extra/buttons, td/extra/main-container, td/extra/navbar, td/boxes (.td-box, .td-box--<color>, .td-box--height-*), td/colors (.-bg-<name>, .-text-<name>). Removed variables: $td-box-colors, $td-print-font-name, $td-enable-webfonts.

Renamed or new tokens: --td-book-content-norm--td-book-content-normal (and .td-book-content--norm--normal); --td-print-font-family keeps its role but follows --td-body-font-family in both presets; new --td-motion-duration-fast (100 ms), --td-motion-duration (150 ms), --td-motion-duration-slow (250 ms), which every shell transition uses and which prefers-reduced-motion: reduce sets to 0.

Typography: Inter for UI and prose (variable weight, Latin/Latin-ext/Cyrillic/ Greek/Vietnamese subsets served by unicode-range; CJK and emoji fall through to the platform stack), borderless inline code, quiet code cards with a hover-revealed Copy control, Mintlify-style field rows, a page-end pager of two text links, and a rule above card section indexes. Open Sans (18 woff2 subsets, 652 KB, published to every site for a print-only face) is gone; a site that wants a different face on paper sets --td-print-font-family in its own stylesheet. The system preset still requests no brand fonts.

Shell chrome icons are Font Awesome class pairs dispensed by shell/icon.html (<i class="td-shell-icon td-shell-icon--<name> fa-solid fa-…">) instead of inline SVG; --td-shell-icon-size sets the box.

Published assets

  • Three JavaScript bundles instead of one per feature combination: js/actions.js and js/core.js are byte-identical on every page and stay cached; only a small js/page-<hash>.js varies. ECharts is its own <script>. Print output loads 7.9 KB instead of 100 KB.
  • static/css/giscus-oink-{light,dark}.css are gone. The palettes ship as assets/css/giscus-{light,dark}.css, are published only on pages that render comments, and are the default comments.giscus.lightTheme / darkTheme; a site that pointed at the old paths removes those two lines (or names a giscus built-in theme / its own stylesheet URL).
  • Gone: static/js/prism.js, static/css/prism.css, static/webfonts/open-sans/, assets/js/click-to-copy.js. VENDOR.json and the vendor tree hashes are regenerated.

i18n

  • Callout labels are namespaced keys: callout_note, callout_tip, callout_important, callout_warning, callout_caution, callout_success, callout_danger, callout_question, callout_example, callout_quote, callout_details. The theme no longer claims bare top-level keys such as note, example, or quote; a site that overrode those in its own i18n/ renames them.
  • Removed: community_join, community_introduce, community_learn, community_using, community_develop, community_contribute, community_how_to, community_guideline.
  • Added: ui_heading_self_link and ui_field_self_link (English fallback in every locale; reviewed Chinese variants).
  • All 32 locale files keep exact key parity (174 keys).

Data files

  • data/home/<lang>.yaml (or data/home.yaml) must list sections; the implicit hero → metrics → capabilities → principles → cta order is gone and its absence fails the build.
  • The fat footer reads data/footer/<lang>.yaml (or data/footer.yaml) only. A footer key inside data/home fails the build naming the new location.
  • data/landing/<key>/<lang>.yaml, data/docs_nav.json, data/download/<key>.yaml, and data/brand.yaml are unchanged.

Behaviour and output changes

  • Headings carry a self-link revealed on hover; the anchor is stripped from print and RSS output, and Markdown output (RenderShortcodes) is unaffected.
  • Print aggregates render each page’s content exactly once per build. In 0.4 a chapter that was itself a section was rendered by its own print output and by its parent’s, concurrently, and the two renders raced on the page store — the visible symptom was intermittent duplicate td-code-… ids in _print/.
  • <main> no longer carries role="main", and the sidebar <aside> no longer duplicates the inner <nav>’s “Section navigation” label.
  • ui.dark_mode: true turns on both the dark palette and the System / Light / Dark menu; show_menu: true alone implies enable.
  • ui.code_copy: false sets the default only (see Code fences).
  • The navbar renders on the home page; callout titles meet contrast; Gallery items are Zoom-eligible on the same terms as other images; the tabs runtime keeps its run boundaries, unique peer ids, and print titles; FileTree and the whole shell honour prefers-reduced-motion.
  • The table render hook runs in print and RSS output, so tables keep caption, number, and scroll container outside interactive HTML; fields from either form produce one rendering, and every entry gets a #field-<name> anchor.
  • llms.txt reads params.ui.docs_section and lists documentation pages with their descriptions.
  • Image resolver errors are labelled by the caller (image: for a Markdown image, the shortcode name for fig), and configuration image sources are held to the same URL policy as content.

Release-candidate hardening

The final review found one systemic migration gap: templates emitted the new data-td-* contract while several runtimes and test mocks still read the old dataset names. It also found that the action manifest followed the synchronous action-registry bundle, so the registry could initialize empty. Both are fixed, with structural checks that reject either regression. Page actions, Command Palette search, code copying and collapse, feedback identity, disclosure labels, Giscus themes, Image Zoom labels, and Asciinema timers now exercise the same attributes in tests and in the rendered DOM.

The same hardening pass also:

  • makes migration report, migrate, and check reject missing, empty, unreadable, or non-UTF-8 targets instead of reporting a misleading clean result, and parses JSON front matter with a JSON decoder;
  • runs legacy front-matter guards in Markdown, RSS, and aggregate print output, and validates boolean/map shapes for page comments and other overrides;
  • preserves accepted data-* / aria-* attributes through data fences and callouts, while keeping chart booleans strict;
  • gives every Swagger and ReDoc embed a unique instance without replacing window.onload or publishing window.ui;
  • applies the shared URL policy to shell logos, wordmarks, and configured featured images; and
  • ships scripts/check-site-markup.py, which checks the resolved consuming-site configuration for the three Goldmark settings required by native forms.

Migration guide

The order matters: content first (the toolkit is dry-run by default and idempotent), then the build errors drive the configuration and layout edits.

Before rewriting content, verify that the consuming site can render the native forms:

python3 path/to/oink/scripts/check-site-markup.py --site ~/pgsty/example.com

1. Inventory

python3 scripts/migrations/oink06.py report --sites ~/pgsty/example.com --md report.md --json report.json

The report lists, per site, every 0.4 construct the toolkit will rewrite, what it will not touch (with file:line and a reason), and what would still be flagged afterwards.

2. Content and front matter

python3 scripts/migrations/oink06.py migrate --site ~/pgsty/example.com          # dry run: diffs + counts
python3 scripts/migrations/oink06.py migrate --site ~/pgsty/example.com --write  # atomic rewrite
python3 scripts/migrations/oink06.py migrate --site ~/pgsty/example.com --write  # second run: changed 0
python3 scripts/migrations/oink06.py check   --site ~/pgsty/example.com          # residual legacy syntax → exit 1

Transforms, in apply order: frontmatter (page keys, including cascade:), callout, param_placeholders, tabs, filetree, gallery, datafence, cards, fieldsdelim, image, include, fencetitle, badge, eg, reportonly. --only <key> selects a subset. Text inside fences is never rewritten; TOML/JSON front matter is reported, not rewritten. Across the eleven in-house sites the front matter transform touched 628 files with zero findings.

Manual follow-ups the report lists: swaggeruiswagger, pageinfo → callout, _param placeholders, iframe/conditional-text/blocks/*, kind-less xref, and js sub-fences of echarts that must become window.OinkEchartsFunctions entries.

3. Configuration

Build the site. Each old key fails with its replacement:

ERROR params.offlineSearch was renamed: use params.offline_search
ERROR params.ui.typography.preset was flattened: use params.ui.typography: technical | system
ERROR params.ui.showLightDarkModeMenu was renamed: use params.ui.dark_mode.show_menu
ERROR params.print.disable_toc was renamed: use params.print.toc (inverted)
ERROR params.rss_sections was removed: the key was never read; delete it

A typical 0.4 hugo.yaml becomes:

params:
  offline_search: true
  offline_search_on_serve: true
  offline_search_index: summary
  offline_search_summary_length: 70
  offline_search_max_results: 10
  reading_width: normal            # was content_width: norm
  markmap: true                    # was markmap: { enable: true }
  print:
    toc: true                      # was disable_toc: false
  comments:
    enable: true
    type: giscus
    giscus:
      repo:  # lightTheme / darkTheme lines removed
  ui:
    typography: technical          # was typography: { preset: technical }
    dark_mode: true                # was showLightDarkModeMenu: true
    sidebar_expand_levels: 2       # was ul_show: 2
    scroll_spy: false              # was scrollSpy: { disable: true }
    reading_time: false            # was readingtime: { enable: false }
    image_zoom: true               # was image_zoom: { enable: true }
    keyboard_nav: true             # was keyboard_nav: { enable: true }
    annotation: true               # was annotation: { enable: true }
    pager_types: [docs, book, blog] # was pager: { types: [...] }
    docs_sidebar_root: section     # was docs_root
    breadcrumb: true               # was breadcrumb_disable: false
    sidebar_enabled: true          # was no_left_sidebar: false
    code_copy: true                # was disable_click2copy_chroma: false (top level)

Delete params.links, prism_syntax_highlighting, rss_sections, github_url (use github_repo), algolia_docsearch, and the giscus lightTheme / darkTheme URLs.

4. Cascades and section indexes

A cascade that set params.ui.* becomes bare keys — the transform handles _index.md files, but check hand-written cascades in hugo.yaml:

cascade:
  type: blog
  params:
    sidebar_menu_compact: false    # was params.ui.sidebar_menu_compact
    sidebar_expand_levels: 3       # was params.ui.ul_show

5. Sass, layouts, and site scripts

  • assets/scss/_styles_project.scss: remove @import 'td/color-adjustments-dark', 'td/code-dark', 'td/extra', 'td/extra/bs-defaults', 'td/gcs-search-dark'; drop rules that targeted .td-navbar-cover, .td-navbar-transparent, .td-box*, -bg-*, oink-*, --oink-*.
  • assets/scss/_variables_project.scss: drop $td-print-font-name, $td-enable-webfonts, $td-box-colors.
  • layouts/: replace partial "home-data.html" / "home/section.html" with landing/…; replace partial "outputformat.html" with .Store.Get "tdOutputFormat"; delete a _markup/render-heading.html that called td/render-heading.html; rename taxonomy_terms_* calls; compare every other copied partial or hook with 0.5.0 before keeping it.
  • Site JS and tests: oink-* ids and data-oink-* attributes are td-* / data-td-*; the action manifest is #td-action-manifest; the per-page bundle is js/page-<hash>.js, with js/actions.js and js/core.js beside it.
  • Site i18n/ overrides: rename note, tip, … to callout_note, callout_tip, ….

6. Data

Move the footer: map out of data/home/<lang>.yaml into data/footer/<lang>.yaml; make sure data/home/<lang>.yaml lists sections.

7. Verify

hugo --printPathWarnings --panicOnWarning
python3 scripts/check-output-security.py --public public --base-url https://example.com/

Then check the surfaces that changed most: a docs page with code tabs and a callout, a page with images (zoom on, zoom off), a Book chapter and its _print/ aggregate, index.md Markdown output, an RSS feed, the home landing page, and the dark palette. Once the v0.5.0 tag is pushed, pin it:

hugo mod get github.com/pgsty/oink@v0.5.0
hugo mod tidy

Compatibility

  • Hugo Extended 0.160.1 remains the minimum; CI runs 0.160.1 and 0.164.0, and now also builds a consumer site in Hugo Module mode.
  • The module path remains github.com/pgsty/oink; consumers still need no Node.js, no CDN, and no build-time download.
  • There is no compatibility layer for 0.4: renamed keys, shapes, shortcodes, partials, and classes fail the build or vanish, by design. The old-key errors are the migration guide; the Docsy-origin entries among them also serve sites coming from Docsy.
  • Docsy keys that were reasonable stay unchanged (see the list under Configuration); the sidebar_* family keeps its names.
  • Interactive features remain opt-in: offline_search, ui.image_zoom, comments, ui.feedback, ui.dark_mode, page_context_menu.assistant_links are off unless a site turns them on.

Verification

Theme CI: 34 check scripts (i18n parity, taxonomy, font tokens, the navigation / component / content-primitive / Book contracts, runtime isolation, sidebar icons, search, actions, palette, reading, release assets, downloads, landing, Book migrations, shared scenarios, keyboard, shell, namespace, parameters, vendor inventory, output structure and security, four-state goldens over 30 surfaces, code blocks, content and media primitives, Image Zoom, Gallery, components), the browser runtime unit tests, the migration toolkit tests (85), the warning-strict fixture site on Hugo 0.160.1 and 0.164.0, the system typography preset, legacy Sass overrides, an invalid preset failing the build, and the new Module-mode consumer build. scripts/check-params.py builds one site per retired key (32 site keys, 14 page keys) and asserts that each fails naming its replacement.

This project site builds warning-free on 0.5.0 after the migration described above. The final gate ran the complete matrix on Hugo 0.160.1 and 0.164.0; its media assertions accept each supported Hugo release’s opaque derivative cache hash while still checking the rendered URL shape, dimensions, alt semantics, and Zoom exclusion. Source validation, the local annotated tag, remote tag publication, consumer pins, and deployment remain separately auditable gates.

Full change set

See the complete source diff from v0.4.2 to v0.5.0 and the theme’s CHANGELOG.md.

2.6 - Oink 0.4.0 — Scenario components for complete publishing workflows

Oink 0.4.0 adds sequential reading and release surfaces, reusable landing pages, Book publishing with stable references, and a keyboard-first site shell in one consolidated Scenario Components release.

Oink 0.4.0 delivers the complete Scenario Components system. The original design separated Reading & Release, Landing, and Book work into 0.4, 0.5, and 0.6 milestones. The public release consolidates those tracks into one signed v0.4.0 tag so consumers can adopt one coherent contract instead of a chain of interdependent previews.

The release remains local-first: consumer sites still build with Hugo Extended and Go, without Node.js, a browser-time API, or a CDN. Interactive behavior is progressive enhancement; HTML, print, Markdown, and RSS output retain the content needed to understand each surface.

Release highlights

Reading and release

Documentation, Book, and blog pages now have a sequential pager derived from the same flattened navigation tree readers see in the sidebar. Previous and next links also appear as same-origin rel metadata in the document head. Explicit navigation data, link-only entries, sidebar dividers, and blog time order keep their own semantics instead of becoming accidental destinations.

Math can use Goldmark passthrough with the theme’s local KaTeX renderer. Sites that cannot enable passthrough yet have a strict, parameter-free eq escape hatch for display math. Numbered Book equations use the same shortcode only when an explicit num is supplied.

Release pages can render local front-matter facts, release cards, checksums, and asset lists without querying GitHub from the browser. A validated data/download/<key>.yaml model feeds both the download shortcode and Landing download sections, with separate rolling and pinned channels and an explicit pending-release state.

See Sequential reading and mathematics and Releases and downloads for the complete contracts.

Landing pages

The data-driven homepage renderer is now a reusable layout: landing shell for ordinary pages. A page may load inline data or language-aware records from data/landing/<key>/, then compose 21 built-in sections including pricing, comparison tables, command boxes, steps, timelines, code plates, case studies, downloads, and bar charts.

All facts are local at build time. Optional reveal, count-up, copy, theme-image, and compact-menu behavior loads only when a Landing page needs it. Without JavaScript, the same page remains complete; marquees pause on focus or request, respect reduced motion, and hide duplicate tracks from assistive technology.

See Landing pages for data resolution, all 21 section types, local-fact rules, and the output matrix.

Book publishing

Long-form manuals can declare Book metadata on the existing documentation shell. Chapters gain draft labels, active-page sidebar headings, and semantic fig, tbl, numbered eq, and language-aware xref targets. Whole-Book figure lists and tables of contents use those same registries.

An opt-in aggregate print document rewrites cross-chapter component links into document-local references and namespaces repeated heading IDs. The accompanying migration tool is dry-run first and idempotent, with reproducible TPME, DDIA, and pg-internal recipes, machine-readable reports, ambiguity skips, and a second-run zero-change check.

See Book publishing for the authoring and migration contract.

Keyboard and shell

The shell now supports single-key reading navigation. w and s move through the sidebar, a and d fold or unfold groups, j and k move through the page outline, and q and e follow the sequential pager. h toggles a session reading mode; l, t, f, and c switch language, theme, search, and command surfaces. Every binding yields to editable controls, composition, held modifiers, and dialogs.

The navbar now spans documentation, blog, taxonomy, and Swagger layouts with one compact state instead of a second mobile menu. Page actions move into the breadcrumb row as a Copy Markdown split button. Footers support validated fat, slim, and none styles, while readers can collapse the fat footer’s link grid and retain that preference.

See Keyboard navigation and Navigation and menus.

Compatibility and behavior changes

  • Hugo Extended 0.160.1 remains the minimum supported version.
  • The module path remains github.com/pgsty/oink; consumers still need no frontend toolchain.
  • Pagers default to the docs, book, and blog content types. Set an explicit type list or pager: false when a page should opt out.
  • / now opens full search; \ opens command-only mode. The > prefix still selects command mode inside the Palette.
  • params.footer_icp and params.footer_icp_url are replaced by one inline Markdown value, params.footer_center_info. An explicit empty string hides the center region.
  • params.ui.navbar_enabled defaults to true. Sites can override it globally, by section cascade, or on one page.
  • Legacy homepage data and Docsy block shortcodes remain compatible, but new Landing work should use the canonical section registry.

Upgrade to 0.4.0

  1. Pin the signed tag and tidy the module graph.
  2. Replace ICP-specific footer fields with footer_center_info if the site used them.
  3. Review pager defaults, / and \ shortcuts, and any local navbar or footer overrides.
  4. Remove copied theme partials only after comparing their local differences with the 0.4 implementation.
  5. Build representative docs, blog, Landing, Book, print, Markdown, mobile, and color-mode surfaces.
hugo mod get github.com/pgsty/oink@v0.4.0
hugo mod tidy
hugo --gc --minify

Follow the project-site 0.4.0 upgrade guide for the consumer checklist. The theme repository retains the frozen PRD 5 migration reference.

Verification

The signed tag resolves to the same commit as the released theme source. Theme CI exercises Hugo Extended 0.160.1 and 0.164.0, 32-locale parity, vendored assets, runtime unit tests, every PRD 4/5/6 contract, and the warning-strict example site. The project site pins the public tag and covers bilingual source, rendered Markdown, internal links, alternate builds, browser behavior, and the full multilingual WCAG AA matrix.

Representative documentation, portal, Book, and archive sites also build from the public v0.4.0 module with workspaces disabled.

Source validation, a public tag, a consumer pin, and hosted deployment are separate evidence gates. Publishing this note does not replace a smoke test of the deployed URL after the site pipeline finishes.

Full change set

See the complete source diff from v0.3.0 to v0.4.0.

2.7 - Oink 0.3.0 — Authoring, navigation, and a lighter page

Oink 0.3.0 adds enhanced code blocks and code groups, everyday content primitives, nested navigation with a Command Palette, semantic typography presets, and removes jQuery from every page.

Publication gate: the linked tag must resolve, the project site must pin that exact tag, and the hosted checks must pass. Until then, treat this source page as release-candidate material.

Oink 0.3.0 is the authoring and navigation release. Writing a page gets a modern code-block presentation and a set of small, everyday components; reading one gets nested navigation and a Command Palette; and every page gets measurably lighter because jQuery is gone.

The module path, the minimum Hugo version, and the Hugo-only consumer build are unchanged. Three changes can affect an existing site and are documented in Breaking changes.

Release highlights

Code blocks and code groups

Ordinary fenced code blocks now render a real code surface: an optional filename, a language label, a copy button rendered by the server rather than injected by script, opt-in wrapping, and collapsing for long listings. Hugo’s own highlighting options — line numbers, line anchors, hl_lines, tab width — keep working exactly as before.

Copy behaviour is deterministic rather than guessed. Session lexers such as console and shell-session default to copying commands without prompts and output; every other language copies the whole block. copy=command is rejected on lexers that cannot distinguish the two, because silently copying the wrong thing is worse than a build error.

The code-group shortcode groups alternatives — package managers, languages, platforms — into synchronized tabs with a stable URL hash, so a link can open the exact variant a reader needs. Legacy tabpane content keeps working and keeps its storage key.

See Code blocks for the full attribute contract.

Everyday content primitives

Alongside the existing large components, 0.3.0 adds the small ones authors actually reach for daily: badge, kbd, fields, filetree, gallery, and an opt-in image_zoom. All of them render semantic HTML, none of the non-interactive ones load JavaScript, and each has a defined presentation in print and Markdown output as well as HTML.

A standalone public icon shortcode is deliberately still deferred; components use a private, allowlisted registry for their own decoration until that API is designed properly.

See Components for each contract.

Navigation and Command Palette

Top-level menus support one level of dropdowns on desktop and matching accordions on mobile, with the parent link and the disclosure control operated independently so a parent stays navigable. Flat menus are untouched.

Local search becomes a Command Palette with three modes: an empty query offering quick links and page actions, a text query returning grouped page results, and a > prefix that searches commands only. Pages can contribute search_keywords, a positive search_boost, and canonical exclusion; ranking applies the same boost on both the Lunr and CJK substring paths.

Page actions and palette commands now run through one shared registry, so Copy text, Open in ChatGPT, Open in Claude, View source, View edit history, printing, and switching theme, language, or version behave identically wherever they are invoked. Assistant prompts resolve the browser URL at activation time, preserving the deployed host, query string, and fragment; history links derive from the same repository path as Edit this page. Assistant handoff links are disabled by default; sites must opt in with params.ui.page_context_menu.assistant_links: true. On activation, the full URL leaves the site, so do not place secrets in its query or fragment.

Press / outside an editable control to open the Palette directly in command mode. Cmd/Ctrl-K remains the general entry point, and the single-character shortcut yields to inputs, textareas, selects, and contenteditable regions.

Sidebars gain an icon-density policy — all, groups, or none. The compatibility default stays all; the starter example opts into groups.

See the migration reference for the complete configuration surface.

Typography presets

Font choices move behind seven semantic --td-*-font-family roles covering UI, body, headings, code, display text, metadata, and print. Two validated presets ship: technical, which preserves the current Oink appearance, and system, which uses the platform stack and requests no Oink brand fonts at all. Existing Docsy and Bootstrap Sass font variables seed the roles, so prior overrides keep working.

This is the typography slice of a larger design-token effort. Colour, surface, radius, density, and appearance presets are not part of this release.

See Typography tokens.

A lighter page

jQuery is gone. It was previously fetched render-blocking in <head> on every page — 87.5 KB before any content — while the theme’s own architecture loads feature runtimes only on pages that use them. Nothing in the shell needed it, and the superseded offline-search.js runtime it powered was already replaced by the Command Palette.

Two other costs were removed rather than accepted. The active output format is now read from the page store instead of being re-derived thousands of times per build, and the shell configuration is cached per language; on a 576-page build that is 357ms of template time reduced to 72ms, with byte-identical output. CJK search folds its index fields once instead of re-lowercasing the entire corpus on every keystroke, taking an 800-document query from 3.44ms to 0.34ms per character typed.

On the measured project-site snapshot, removing jQuery and the superseded search runtime saved about 88 KB from a typical documentation page’s combined CSS and JavaScript. Exact totals vary as later candidate assets change.

Correctness and localization

This release also closes several less visible correctness gaps. Markdown pages link to llms.txt only when the active language actually publishes one, and the index no longer treats off-site menu chrome as content. Internal configured commands stay under a subpath deployment, while shared content types resolve to the right product root. The archived-version banner and Giscus fallback are now localized, and print or Markdown output strips interaction-only Image Zoom attributes regardless of quoting style. Legacy search links also percent-encode their query text instead of truncating a query at &.

Browser runtime tests now run in theme CI instead of relying on Hugo bundling as their only signal. Terminal recordings also wait for their configured font before fitting the player, avoiding geometry based on a fallback font.

Breaking changes

jQuery is no longer loaded. The third-party inventory previously listed it as part of the UI foundation, so a consuming site’s own scripts may rely on the global $. No theme feature requires it. Sites that need it must now bundle it through project JavaScript:

<!-- layouts/_partials/hooks/head-end.html -->
<script src="{{ (resources.Get "js/jquery.min.js").RelPermalink }}"></script>

static/js/tabpane-persist.js is removed. assets/js/code-tabs.js took over the legacy persistence contract, keeping the td-tp-persist storage key and data attribute, so authored tab content is unaffected. Only a site that referenced the published file path directly needs to drop that reference.

Body and heading typography roles apply directly to content. A site that previously restyled raw body or heading selectors should move to the matching --td-*-font-family role or the established Sass variable:

// Before
body {
  font-family: 'My Sans', sans-serif;
}

// Oink 0.3.0
:root {
  --td-body-font-family: 'My Sans', sans-serif;
}

Upgrade to 0.3.0

  1. Check whether any project JavaScript depends on the global $, and bundle jQuery yourself if so.
  2. Remove any direct reference to static/js/tabpane-persist.js; authored tabpane content itself does not change.
  3. Move raw body or heading font overrides to the typography roles.
  4. Decide whether to opt into assistant handoff links. If enabled, review URLs for sensitive query or fragment data and disclose the third-party boundary.
  5. Update the Hugo Module and tidy the module graph.
  6. Build and inspect representative documentation, blog, mobile, print, and color-mode pages.
hugo mod get github.com/pgsty/oink@v0.3.0
hugo mod tidy
hugo --gc --minify

No Markdown content rewrite is required. Existing fenced code blocks, tabpane content, flat menus, shortcodes, and ordinary Docsy-compatible pages continue to work unchanged.

Compatibility

ContractOink 0.3.0
HugoExtended 0.160.1 or newer; unchanged
Module pathgithub.com/pgsty/oink; unchanged
Consumer frontend toolchainNone; unchanged
Required content migrationNone
Required configuration migrationNone; assistant links are opt-in
Required project-JS migrationOnly if it depends on global jQuery

Verification

The 0.3.0 candidate is exercised through the sibling Oink project site, so the site builds against the candidate theme rather than its last pinned release. Before publication, the theme gate must pass the complete contract suite, a warning-free example-site build on the minimum and current Hugo versions, both typography presets, and the browser runtime unit tests. The site gate must pass formatting, bilingual page pairs and stable heading IDs, rendered Markdown and internal links, Hugo Module fixtures, alternate-configuration builds, Markdown and favicon goldens, responsive and component browser behaviour, and axe accessibility checks. The tag, public-module resolution, site version pin, and hosted smoke tests remain separate post-approval gates.

Full change set

See the complete source diff from v0.2.1 to v0.3.0.

2.8 - Oink 0.2.0 — Richer content and sharper presentation

Oink 0.2.0 adds composable homepage sections, theme-aware media, wordmarks, navigable component boards, a steps shortcode, polished terminal recordings, and a clearer release-publishing experience.

Oink 0.2.0 focuses on the parts readers and authors touch most: the homepage, brand presentation, blog discovery, section indexes, and instructional content. It also turns the Oink project site into a clearer bilingual reference for the theme’s current contracts.

The module path, minimum Hugo version, and Hugo-only consumer build remain unchanged. The one configuration rename that can affect an existing site is documented in Breaking change.

Release highlights

Homepage and brand

The homepage now composes 12 built-in section types from an ordered sections list. A string selects data with the same name; a map can reuse a presentation through a different key, disable a block without deleting its data, or carry a small one-off block inline. Sites without sections retain the 0.1.x homepage order, so explicit composition is additive rather than a required migration.

The data-driven homepage can now place responsive artwork beside the Hero. Authors may configure one shared image or separate Light and Dark sources, plus meaningful alternative text when the artwork carries information. The layout adapts from a two-column desktop Hero to a compact mobile presentation without requiring a site-level template override.

Oink also adds params.wordmark. A configured wordmark is used consistently in the landing navigation, documentation header, drawer, and footer; sites that only configure params.logo keep the existing mark-plus-title presentation.

Component boards on the homepage can become real navigation. Items accept links, optional external-link behavior, compact styling, and one to four columns. Decorative boards remain non-interactive, preserving the 0.1.0 contract.

See Homepage and footer for the complete data shape.

Blog and release publishing

Blog rows now treat images and summaries as one responsive layout. Featured images no longer force the text outside tablet-width containers, summaries can break long machine-generated tokens, and posts without images use the full text width. The byline’s section name is now a link, and RSS moves into the same action rail used by the rest of the page.

Categories and tags use the same collapsible group grammar as the TOC and page actions. Terms render as scan-friendly rows with count badges in both the wide rail and the mobile drawer. Section indexes are quieter, descriptions have more room, and last-modified metadata follows the child-page index instead of interrupting the page introduction.

The Oink project site now separates upstream Docsy history, Oink engineering articles, and versioned Oink release notes into distinct bilingual sections. That makes release reports discoverable without presenting inherited Docsy posts as Oink releases.

Content components

0.2.0 adds a Markdown-first steps shortcode. Direct child headings become automatically numbered steps connected by a guide line; moving, adding, or removing a step updates the visible sequence without maintaining numbers by hand. Authors can mark a supporting heading with class="no-step-marker" so it does not consume a number.

Asciinema recordings gain a polished terminal frame, title bar, compact control bar, color-mode-aware styling, and a font contract passed directly into the player. This avoids the player falling back to a different terminal font while keeping recordings responsive and readable in both themes.

ECharts callback blocks keep the established trusted-author model: callback code is executable content that must be reviewed like inline HTML or another custom integration. The renderer no longer emits a redundant warning for every reviewed callback block.

See Shortcodes for the new steps contract and Oink components for the broader component model.

Documentation and tests

The independent project site receives a matching documentation pass:

  • Expands English and Chinese homepage and component examples.
  • Documents all 12 composable homepage sections and uses the relevant ones on the project landing page.
  • Adds a real Asciinema installation recording and a dedicated giscus guide.
  • Moves examples under the documentation tree and removes obsolete community and maintainer-only pages.
  • Consolidates Hugo configuration into the root hugo.yml and retires the old Netlify-specific tooling.
  • Isolates browser tests from live reload and keeps responsive, accessibility, translation, rendered-Markdown, and link checks in the release gate.

These are project-site changes, not new runtime dependencies for theme consumers.

Breaking change

0.2.0 renames the inherited featured-image setting from default_featured_image to default_featured. Update page, section cascade, and site-level configuration where the old key appears:

# Oink 0.1.x
default_featured_image: /images/blog-card.webp

# Oink 0.2.0
default_featured: /images/blog-card.webp

The implicit theme placeholder is also removed. If no post image, matching page resource, or explicit default_featured exists, Oink now renders a clean text-only list entry. Set default_featured to a site-owned image when a whole section should keep a visual identity; set it to false to make the opt-out explicit.

There is no compatibility alias for the old key. This is the only required configuration migration in 0.2.0.

Upgrade to 0.2.0

  1. Replace every default_featured_image setting with default_featured.
  2. Update the Hugo Module and tidy the module graph.
  3. Build the site and inspect representative homepage, blog, documentation, mobile, and color-mode pages.
hugo mod get github.com/pgsty/oink@v0.2.0
hugo mod tidy
hugo --gc --minify

No Markdown content rewrite is required. Existing homepage sections, logo-only branding, shortcodes, and ordinary Docsy-compatible pages continue to work.

Compatibility

ContractOink 0.2.0
HugoExtended 0.160.1 or newer; unchanged
Module pathgithub.com/pgsty/oink; unchanged
Consumer frontend toolchainNone; unchanged
Required content migrationNone
Required configuration migrationRename default_featured_image

Verification

The 0.2.0 candidate is exercised through the sibling Oink project site so the site builds against the candidate theme rather than only its last pinned release. The release gate covers formatting, bilingual page pairs and stable heading IDs, rendered Markdown and internal links, Hugo Module fixtures, responsive browser behavior, and axe accessibility checks.

Full change set

See the complete source diff from v0.1.0 to v0.2.0.

2.9 - Oink 0.1.0 — A stable local-first foundation

The first stable Oink release turns the implementation preview into a polished Hugo module with a responsive shell, multilingual infrastructure, local-first components, and a stronger accessibility baseline.

Oink 0.1.0 is the first stable release of the Oink theme. It incorporates the 0.0.1 implementation preview and the stabilization work that followed: one coherent documentation shell, a Hugo-only consumer build, local-first browser assets, multilingual behavior derived from Hugo, and reusable content components.

This release keeps the module path github.com/pgsty/oink and requires Hugo Extended 0.160.1 or newer. A consuming site does not need Node.js, npm, PostCSS, Autoprefixer, or a CDN to build and serve theme-owned functionality.

Release highlights

Local-first theme foundation

Oink ships the styles, fonts, icons, local search, diagrams, API documentation runtimes, and content-component runtimes that it owns. Optional assets load only on pages that use them, and the distributable repository is a root Hugo Module rather than an embedded project site or frontend workspace.

The release also establishes the core product contracts:

  • Hugo languages and translation objects drive language routes, switching, hreflang, writing direction, and locale metadata.
  • The theme supports single-language, multilingual, and RTL sites without PGSTY-specific domain assumptions.
  • Asciinema, ECharts, Infographic, diagrams, API references, tabs, cards, and other reusable components share local, page-scoped runtimes.
  • GitHub Discussions comments are available through an opt-in giscus integration. No external comments script loads unless a site enables it.
  • Docsy-compatible content organization, menus, taxonomies, print outputs, and extension hooks remain available.

Responsive shell

The documentation, blog, and API-reference layouts now use one responsive shell. Desktop navigation, the resizable sidebar, the table of contents (TOC), page actions, taxonomies, version selection, and the footer follow the same visual and interaction grammar.

On tablets and phones, Oink moves the TOC, page actions, categories, and tags into the navigation drawer instead of rendering a second copy. This preserves unique IDs and keeps scroll tracking, disclosures, and copy actions working across live viewport changes. Language and color controls remain reachable at every width, and the color selector exposes Auto, Light, and Dark as explicit preferences.

Navigation entries gain consistent icons, the mobile menu traps keyboard focus, footer columns fill the available width, and the compact page-action menu no longer duplicates the right rail. Copy Markdown, view Markdown, edit, issue, and print actions now come from one implementation.

Publishing and content

Syntax highlighting now uses class-based Chroma output with coordinated light and dark palettes. Code remains readable before JavaScript initializes a color mode, and sites can still opt out with params.highlight_classes: false.

Blog lists gain a deterministic featured-image resolver. In 0.1.0, it checks front matter images, a matching page resource, inherited default_featured_image, site parameters, and finally the theme placeholder. The same resolver serves both modern blog rows and the compatible legacy partial.

The new ink-mark logo and the placeholder artwork respond correctly to all four system-theme and selected-theme combinations. Oink now declares the used color-scheme in both directions, so an explicit site preference wins over the operating-system preference.

Accessibility and correctness

0.1.0 fixes a set of issues found during desktop, mobile, print, and assistive technology review:

  • Corrects heading order, landmark names, task-list labels, and printable list semantics.
  • Keeps blog rows inside the viewport at tablet widths and lets long URLs or identifiers wrap safely.
  • Builds GitHub issue links with correctly encoded titles and URLs.
  • Localizes the 404 page and removes hard-coded punctuation from translated accessible names.
  • Gives iframe embeds titles and lazy loading, registers their resize helper once, and safely handles cross-origin frames.
  • Emits one contentinfo landmark per page and keeps theme extension partials and opt-in SCSS entry points available to consumer sites.

The compatibility audit also removes genuinely unreachable legacy shell code while restoring files that downstream sites can import directly. Reachability is evaluated from consumer layouts and _styles_project.scss, not only from the theme’s own entry points.

Upgrade to 0.1.0

Update the Hugo Module and rebuild the site:

hugo mod get github.com/pgsty/oink@v0.1.0
hugo mod tidy
hugo --gc --minify

No content migration is required. If a site imports Oink partials or SCSS directly, build that site as part of the upgrade so its customization surface is checked alongside the theme.

Compatibility

ContractOink 0.1.0
HugoExtended 0.160.1 or newer
Module pathgithub.com/pgsty/oink
Consumer frontend toolchainNone
Default browser dependenciesLocal-first
Primary content modelDocsy-compatible Markdown and front matter

Verification

The final 0.1.0 candidate was swept against the theme fixture and the Oink project site across seven viewport widths. The recorded run reported no console errors, failed requests, horizontal overflow, or axe violations. Separate fixtures cover minimum and current Hugo versions, LTR and RTL languages, subpaths, print output, repeated component instances, and network-isolated consumer builds.

Full change set

See the v0.1.0 source snapshot.

3 - Oink Blog

OINK announcements, engineering stories, and implementation notes

3.1 - Immersive reading on the Blog shell

Four front matter keys turn an ordinary Blog page into a reading-first layout with a full-bleed hero and an in-flow outline rail.

This page is rendered by the ordinary Blog shell. There is no special content type behind it. Four front matter keys change the presentation, and a section can set the same recipe once in a cascade:

featured_image: hero      # the image becomes a full-bleed opening
toc_style: flow           # a wider outline starts with the article
toc_taxonomies: false     # the rail carries the outline alone
sidebar_enabled: false

The hero

A page with a featured image can open with it. hero turns that image into a full-bleed backdrop across the top of the viewport, moves the title down to give it room, and masks the artwork away before the body begins. Because the shell paints it, the same presentation also works on a section index.

The page card, social preview, and Hero all use the same representative-image resolver. featured_image: banner keeps the framed alternative, while a page without a suitable image simply falls back to the normal opening.

The outline rail

toc_style: flow replaces the viewport-pinned outline with a wider rail in the content flow. It starts beside the article below the Hero and becomes sticky only after scrolling. The switch is independent of the image, so one section can keep a consistent outline even when some pages have no artwork.

toc_taxonomies: false removes the taxonomy clouds. If a page has neither an outline nor clouds, the empty rail is omitted completely.

What remains available

Everything below the opening is still a normal Blog article: date and reading time, tag badges, authors and profiles, the series strip, the description lead, sharing, annotation, sequential navigation, and comments. The Blog shell omits breadcrumbs by default; breadcrumb: true restores one for a page that needs its position in the tree to remain visible.

3.2 - Introducing the OINK implementation preview

OINK turns a directly customized Docsy codebase into a local-first, Hugo-only documentation theme with multilingual infrastructure and reusable content components.

Today we are publishing the OINK implementation preview: a directly evolved Docsy theme with one canonical product shell, a Hugo-only consumer build, local-first browser dependencies, a general multilingual framework, and a set of reusable content components drawn from PGSTY documentation sites.

This is an implementation and documentation milestone, not a public versioned release. The final public brand, module and package identities, first version, and production Cloudflare Pages deployment remain explicit release gates.

Why OINK exists

Several mature documentation sites had independently copied the same Docsy layouts, navigation, search code, SCSS, JavaScript, and shortcodes. A common fix had to be repeated across repositories, while each site also carried a frontend toolchain and implicit network dependencies that made isolated builds harder than they needed to be.

OINK consolidates the genuinely reusable layer. Product matrices, portals, pricing pages, and other business-specific behavior stay in their own sites. The shared theme owns the documentation shell, browser runtimes, multilingual routing, accessibility behavior, and content-component contracts.

What changes

One product instead of a mode

OINK is not an optional skin. There is no oink.enabled flag, params.oink.* namespace, or parallel upstream-versus-brand template tree. The implementation in theme/ is the product.

That decision avoids two visual systems and two test matrices. Native Hugo settings and compatible Docsy parameters keep their established meanings.

Hugo-only consumer builds

A complete consuming site builds with:

hugo --gc --minify

Bootstrap, Font Awesome, fonts, search, diagrams, API documentation runtimes, and OINK components are committed with the theme. Node.js, npm, PostCSS, Autoprefixer, and CDN downloads are not consumer requirements.

Repository maintainers still use Node-based tools for tests and vendor refreshes. That maintenance toolchain is deliberately outside the public site-build contract.

Local-first browser behavior

The default starter serves its shell, fonts, icons, search, Mermaid, KaTeX, Markmap, Swagger UI, Redoc, Asciinema, ECharts, and Infographic dependencies from the generated site. Optional runtimes are selected per page and loaded at most once.

PlantUML and Diagrams.net do not receive public service defaults. A site must configure a controlled endpoint, use a pre-rendered result, or make an explicit remote-service choice.

Multilingual infrastructure

Language routing comes from Hugo’s language and translation objects. One configured language hides the selector. With two or more languages, a click advances by configured weight, while a short hover or keyboard focus opens the complete menu. If the current page lacks a translation, the selector goes to the target-language home page instead of a dead path.

The starter and documentation site use English as the primary language and Simplified Chinese as the second. Every page in the core docs and blog scope has a colocated .zh.md translation with stable explicit heading IDs.

Reusable components

OINK adds theme-owned Asciinema, ECharts, Infographic, document carousel, details, tabs, cards, navigation cards, document cards, and parameter components. They generate unique instance IDs and load their local assets only when used.

ECharts accepts structured JSON or YAML plus optional JavaScript callbacks referenced as $fn:name. Callback code runs only on pages that declare it.

What stays familiar

OINK retains Docsy’s content organization, front matter, documentation and blog sections, menus, taxonomies, print output, repository links, common shortcodes, diagrams, API reference features, and extension hooks. Existing sites can remove duplicated common implementations without rewriting ordinary Markdown.

The project also preserves Docsy’s Apache-2.0 history and attribution. A vendor manifest records pinned third-party sources, licenses, artifacts, and checksums.

Try the starter

Install Hugo Extended 0.160.1 or newer, then run from this checkout:

hugo --source starter --gc --minify

The current validation baseline is Hugo Extended 0.164.0. Open the generated English and Chinese pages, switch languages, search locally, change color mode, and visit the component examples.

For a network-isolated transfer, maintainers can create a complete archive:

scripts/package-offline.sh /absolute/path/oink-preview.tar.gz preview

The archive includes the theme, starter, licenses, upstream record, migration guide, vendor manifest, and a sidecar checksum.

Current validation

The implementation includes automated coverage for:

  • minimum-version and current Hugo Extended builds;
  • forbidden consumer Node/npm/PostCSS/Autoprefixer paths;
  • LTR, RTL, subpath, print, color-mode, and production assets;
  • hidden, click-to-cycle, hover-menu, translation-fallback, and RTL language behavior;
  • local per-page runtimes and repeated component instances;
  • ECharts structured options and callback integration;
  • an offline bilingual starter and offline release archive;
  • vendor licenses and checksums;
  • non-mutating migration rehearsals for SILO, PGSTY, SOW, and Pigsty.

The latest four-site rehearsal built temporary copies successfully. It did not modify or deploy those production repositories.

What remains before release

The public identity and first version must be approved and applied consistently to the module, package, source tags, nested theme tag, archive, and documentation. The target Cloudflare Pages project must then be connected to the source branch, built with the pinned Hugo version, published, and verified at its hosted URL.

Until those gates close, use the preview for evaluation and migration rehearsal, not as an unversioned production dependency.

3.3 - OINK implementation diary: from copied shells to one theme

A technical diary of the decisions, migrations, safety boundaries, tests, and documentation work behind the OINK implementation preview.

OINK began with an awkward observation: several production documentation sites looked related because they were related, but their common implementation lived as copied files. The visible result was consistent enough; the maintenance model was not.

This diary records how the project moved from repeated site overrides to one directly evolved theme. It focuses on decisions and evidence rather than a commit-by-commit transcript.

Locking the contract

The first useful work was subtraction. We wrote down what the product must be before choosing how to implement it:

  • an independent theme derived directly from Docsy;
  • one canonical shell, not a switchable skin;
  • Hugo Extended as the only consumer build dependency;
  • local-first delivery for every theme-owned browser asset;
  • multilingual behavior derived from Hugo rather than PGSTY domains;
  • reusable components in the theme, business semantics in the site;
  • preserved Docsy history, licenses, and upstream traceability.

This ruled out an attractive but costly shortcut: adding params.oink.enabled and leaving the old shell in place. A mode switch would have made every layout change, accessibility fix, and test support two products. Direct evolution made the intended design the only design.

Replacing the shell

The documentation, blog, and API-reference layouts were rebuilt around shared, small partials. The resulting shell includes:

  • global navigation and responsive sub-navigation;
  • a resizable, foldable sidebar;
  • local search and quick links;
  • language and color-mode controls;
  • breadcrumbs, table of contents, page metadata, and feedback;
  • a consistent footer and print layout.

The hard part was not drawing a navbar. It was preserving existing Docsy extension points while removing copied baseof.html files. Narrow hooks remain; site-wide shell duplication no longer has to be the normal customization path.

Removing the consumer toolchain

The original dependency chain assumed npm-provided Bootstrap and Font Awesome, and some paths invoked PostCSS. OINK moved the required sources and compiled artifacts into the theme and kept SCSS inside Hugo’s own asset pipeline.

Tests do more than check that hugo succeeds. Fixture traps fail if a consumer build tries to run Node.js, npm, PostCSS, or Autoprefixer, or if a template uses resources.GetRemote. LTR and RTL pages go through the same constraint.

This distinction matters: the repository still uses Node for its maintainer test harness. “Hugo-only” describes what a consuming site needs after it has a complete theme, not a ban on development tooling inside the theme repository.

Vendoring browser runtimes

The next layer was every dependency the browser might otherwise fetch: Bootstrap, Font Awesome, fonts, jQuery, Lunr, Mermaid, KaTeX, Markmap, Swagger UI, Redoc, Asciinema, ECharts, Infographic, and their supporting libraries.

Each selected artifact received a source, fixed version, license path, checksum, and update procedure in theme/VENDOR.json. Licenses live beside the vendored material. The manifest is validated against the actual files rather than treated as an aspirational inventory.

PlantUML and Diagrams.net forced a useful distinction. They are service-backed features, not merely JavaScript libraries. OINK refuses to invent a public endpoint: enabling one without a configured service fails the build.

Building the multilingual core

The previous language behavior was scattered across navigation and site-specific assumptions. The new core starts with Hugo’s configured languages, .Translations, and .AllTranslations.

The presentation is deliberately stable: one language hides the selector; two or more use the same icon button. Clicking advances by configured weight, while a short hover or keyboard focus opens the complete language menu.

A missing translation falls back to the target-language home page. Language labels use each language’s own name. The same objects drive lang, direction, canonical, hreflang, and Open Graph locale metadata, so the visible selector cannot drift from SEO output.

The tests construct every state with an RTL current language as well as normal LTR cases. Native links and disclosure controls keep keyboard behavior predictable.

Promoting components

Asciinema, ECharts, Infographic, document carousel, details, tabs, cards, and parameter rendering had already proved valuable in PGSTY sites. The work was to turn them from copies into product APIs:

  • normalize parameter names and defaults;
  • generate unique IDs from page identity and shortcode ordinal;
  • load each runtime once per page and omit it from unused pages;
  • preserve subpath URLs;
  • support repeated identical instances;
  • provide print, dark-mode, mobile, keyboard, and reduced-motion behavior;
  • retain compatible aliases for imported content.

Product matrices and other business widgets did not move. Reuse is not measured by the number of repositories containing a copy; it requires a stable, business-neutral contract.

Supporting ECharts callbacks

ECharts callbacks are legitimate chart options that JSON and YAML cannot express. Existing pages use them for tooltip formatting, label formatting, and data-dependent colors. Treating those callbacks as a separate migration exception added configuration without creating a sandbox.

The shortcode therefore keeps one direct contract:

  1. content supplies JSON or YAML, which Hugo parses and safely serializes;
  2. an optional fenced JavaScript block declares callbacks;
  3. $fn:name values reconnect those callbacks after the options are parsed;
  4. authors review executable code under the same trust model as inline HTML and other custom integrations.

Tests cover repeated identical charts, invalid CSS lengths, structured options, and callback registration.

Creating the starter and archive

A contract is easier to trust when the smallest example demonstrates it. The starter contains bilingual home, docs, blog, and component pages; local search; dark mode; diagrams; API documentation; and the new components. It has no package.json and no site workflow.

The offline packager combines theme/, starter/, licenses, the upstream record, and migration guidance, excluding generated output and dependency caches. It writes a sidecar SHA-256 file and refuses to overwrite an existing artifact.

The acceptance test copies the starter and theme to a temporary directory, empties caches, blocks HTTP/HTTPS and Go proxies, builds with Hugo, and examines HTML and CSS for third-party subresources.

Rehearsing four migrations

SILO, PGSTY, SOW, and Pigsty provided the reality check. The rehearsal tool copies each workspace instead of modifying it, removes only classified common overrides, applies a local theme replacement, forbids network and frontend tools, and runs the production build.

The latest rehearsal removed 20 common overrides from SILO, PGSTY, and SOW and 24 from Pigsty. Pigsty retained its three business matrix shortcodes and existing ECharts callbacks. All temporary copies built, producing 1,095, 16, 128, and 2,473 HTML files respectively.

Those numbers prove the rehearsal at the recorded commits. They do not prove that any production repository was changed or any hosted site was deployed.

Turning the sample into OINK docs

The inherited docsy.dev site was valuable as a regression corpus, but it described only Docsy. The documentation phase did four things:

  1. made English primary and Simplified Chinese second; a later shell review removed French from the demonstration site;
  2. translated every core documentation and blog source into a colocated .zh.md page;
  3. preserved English heading IDs explicitly in every Chinese heading;
  4. added the OINK product guide, announcement, and this implementation diary.

Before translation, we established a terminology and typography guide. A checker then verifies source/translation pairs, heading counts, explicit Chinese IDs, and rendered English/Chinese heading-ID equality.

Historical Docsy release posts remain faithful translations. Their npm-era instructions are historical context, while the OINK architecture and migration guides state the current Hugo-only product contract.

What testing changed

Several tests changed the design rather than merely blessing it:

  • subpath fixtures forced every local component URL through Hugo URL handling;
  • repeated-instance tests replaced content hashes with page-and-ordinal IDs;
  • offline browser checks exposed implicit runtime requests;
  • RTL language matrices prevented a selector implementation that only worked for the starter’s two LTR languages;
  • ECharts callback fixtures kept callback registration and structured options interoperable;
  • migration rehearsals preserved site-specific partials that a blanket layouts/ deletion would have removed.

The strongest test suite is one that constrains the product boundary, not just the current HTML snapshot.

Remaining work

Two release gates remain intentionally open. The public brand, repository, module and package identities, and first version need approval. A real Cloudflare Pages project then needs to build from the source branch and pass hosted verification.

Production migrations should proceed one site at a time, with dedicated branches, preview deployments, visual regression, and rollback artifacts. The temporary four-site rehearsal is a foundation for that work, not a substitute.

Lessons

  • Write the product boundary before moving files.
  • A local-first promise needs both build-time and browser-time evidence.
  • Configuration is healthier when it expresses user choices, not internal implementation branches.
  • Translation quality includes stable links, code fidelity, typography, and rendered structure—not prose alone.
  • Reuse should remove maintenance copies without absorbing business semantics.
  • “Built,” “packaged,” “published,” “deployed,” and “migrated” are different claims and need different evidence.

The result is less dramatic than a rewrite and more useful: one theme that can be understood, built, tested, translated, and migrated as a coherent product.