Novel reader content plugin: column-paginated text with chapter
navigation, per-paragraph comments and typography settings. Reads
txt (auto-detected UTF-8 / GB18030 / UTF-16, plus explicit Shift_JIS /
Big5), markdown, html/xhtml, epub, docx and fb2 — including chapter
folders (every file a chapter) and .fb2.zip containers.
The plugin is split into a pure core layer (src/core/) and a
thin render layer (src/render/):
charset.ts— byte → text decoding (BOM, strict UTF-8, GB18030, explicit encodings)markup.ts— tolerant XML/HTML tokenizer (namespaces, entities, raw-text elements)text.ts— html/fb2/docx → prose units, plus metadata extractiondocument.ts— units →NovelDocument(paragraphs + chapter table)epub.ts— container.xml / OPF spine parsingformat.ts— source-shape classification (single file vs folder)
The server hooks (main.ts) classify the resource and list the text
units — archives surface as virtual container entries
(book.epub!OEBPS/text/ch1.xhtml) the client reads through container
addressing, so no zip parsing ever runs in the iframe. All decoding is
pure and Node-testable without a DOM; the render layer only reads bytes
and calls logic functions.
This plugin is developed entirely through the plugin toolchain — no hoardodile server, no web app:
pnpm build # bundle manifest + client + server hooks into dist/
pnpm dev # watch-build and serve the workbench (http://127.0.0.1:5199)
pnpm detect:smoke # run detect against testdata/text through the real sandbox
pnpm test # vitest (logic + render)
pnpm testdata # regenerate the committed fixture volumes
pnpm testdata:real # fetch real books, then verify hooks + logic against thempnpm dev captures the server-side hook results from the real worker
sandbox and feeds them to the workbench, so the iframe receives the same
context the app would push. Point --data at another testdata/
volume (or run hoardodile plugin run listFiles testdata/epub --plugin-dir dist) to work on a specific format.
Committed fixture volumes, generated rather than copied from anywhere;
pnpm testdata regenerates them. Every volume is a flat directory — a
resource holds exactly one book, so each format gets its own:
text/— the synthetic folio (.txtwith regex-detectable chapters- a
.mdsidecar)
- a
folder/— a multi-file chapter folder (卷一.txt… plus html)epub/— a two-chapter EPUB with real container/OPF/spine layoutdocx/— a Word document with core propertiesfb2/— nested sections + title-info metadatahtml/— a single chapter pagegbk/— the folio re-encoded as GB18030
Real-world samples live in testdata-real/ (git-ignored; not required
for CI — the committed fixtures cover the formats offline). pnpm testdata:real downloads public-domain books (Gutenberg, the Internet
Archive, Aozora Bunko), re-encodes the Chinese classic to GB18030 and
UTF-16LE, and verifies every sample through the built plugin sandbox
and the pure logic layer — format correctness is proven from bytes
alone, no screen required.
@hoardodile/* dependencies resolve from the npm registry (^0.1.9):
pnpm install pulls the published SDK directly — no tarballs, no
pnpm-workspace.yaml overrides.
- The plugin is MIT licensed (see
LICENSE). - Committed fixtures under
testdata/are synthetic (regenerated bypnpm testdata). Real-world samples fetched bypnpm testdata:realare public-domain works (Project Gutenberg, the Internet Archive, Aozora Bunko) and live only in the gitignoredtestdata-real/directory.
- hoardodile ≥ 0.1.9 — the built-in plugin marketplace
(Settings → Marketplace) and the batched asset-download API
(
download([…])) when the manifest declares"download": true. The marketplace's Readme and Release notes tabs and release readme assets are read by newer builds; older builds still list and install the plugin normally. "minAppVersion"inmanifest.jsondeclares the lowest hoardodile release this plugin runs on. Hosts below it refuse to install or update the plugin (the marketplace gates the install/update entries and zip uploads are blocked with an explanation), so bump it only when the plugin really needs a newer app. Omit it for plugins that support every release.- This plugin's version is independent of the hoardodile release version; bump it on user-visible changes.
- Dev loop: Node ≥ 24, pnpm 11.
Publish to the marketplace with one command:
# 1. Add the repository address to your registry repo's registry.json:
# { "version": 1, "plugins": ["https://github.com/<owner>/<repo>"] }
# 2. One-click release — release-it bumps the version in package.json AND
# manifest.json, writes CHANGELOG.md from Conventional Commits, commits,
# tags `v<version>` and pushes. The tag-triggered `.github/workflows/
# release.yml` then builds, packages (`release/<id>-<version>.zip` +
# `.sha256`) and publishes the GitHub release. No local `gh` CLI or token.
pnpm release <version>On main, with a clean working tree. The tag must match the manifest version
(v<manifest.version>) — the workflow fails otherwise. Then paste the registry
repo address once in Settings → Marketplace. The app reads the registry,
each plugin's manifest and its latest release — names, versions, permissions and
release notes come straight from GitHub, so the list never needs editing again.
The zip asset is <id>-<version>.zip (produced by hoardodile plugin package);
before the first release the plugin shows up with a "no release" state.
Prefer pushing a tag manually (no release-it)? It still works — the workflow does the same build/package/publish:
git tag v<version> && git push origin v<version>Local installs (zip upload in Settings → Plugins) still work for private packages.
The marketplace detail view shows a per-release Readme tab. Ship the
readme markdown in the readme/ folder as a bare README.md
fallback, plus one README.<locale>.md file per extra language (e.g.
readme/README.md, readme/README.zh.md) — release.yml uploads the whole
folder alongside the zip, so each release carries its own readme and
every version shows independent notes.
README.md is the fallback the app shows for any language without a
specific file, so English normally lives there and you do not need a
README.en.md. Use the app's supported language codes for the extra files
(zh, ja, de, es) — a region-coded name like README.zh-CN.md only
matches a UI language resolved to that exact code, so README.zh.md is what
Chinese users see.
pnpm readme:check (run by release.yml before publishing) gates this: it
fails the release if readme/ is absent-and-required, is not flat, ships no
README.md / README.<locale>.md, or references an image by a
nested/missing path.
The app resolves the readme for the user's UI language (exact locale → base
language → the README.md fallback); the release body always shows
in the Release notes tab.