- Pick the language folder your source belongs to under
plugins/<language>/(full language name, e.g.plugins/english/), and create a.tsfile there — e.g.plugins/english/myNovelSite.ts. - Copy
docs/plugin-template.tsinto that file as a starting point. It already imports the pieces most plugins need (fetchApi/fetchText,Pluginnamespace,Filters,cheerio,defaultCover,NovelStatus) and stubs out the required methods. - Add a 96x96px icon at
public/static/src/<short-lang-code>/<plugin-id>/icon.png(note: this folder uses the short language code, e.g.en, not the full folder name from step 1), then seticon = 'src/<short-lang-code>/<plugin-id>/icon.png'on your class — see PluginBase::icon and Repository manifest & install metadata for what happens to that path at publish time. - Fill in
popularNovels,parseNovel,parseChapter, andsearchNovelsagainst the target site — the reference sections below cover the shape each one returns. Using Cheerio and Custom fetching functions cover the two building blocks most plugins need for that. - Test locally with
npm run dev:start, which launches a browser playground athttp://localhost:3000where you can run your plugin's functions against the real site. Before opening a PR, runnpm run check:plugin -- plugins/<lang>/yourPlugin.ts(seedocs/testing.md) — this is the same live-site check CI runs.
For CMS-templated sites (WordPress themes, Madara, etc.) and any other repo-specific workflow
detail (multi-source generators, icon conventions, live-check tooling), see
docs/quickstart.md.
This is the contract your plugin file itself implements: a default-exported instance of a class
satisfying Plugin.PluginBase, imported via
import { Plugin } from '@/types/plugin';PluginBase is a base class for all plugins.
class ExamplePlugin implements Plugin.PluginBase {}| Field | Required | Description |
|---|---|---|
| id | yes | Plugin ID |
| name | yes | Plugin Name |
| icon | yes | Path to the plugin's icon, converted to iconUrl at publish time — see Repository manifest |
| site | yes | Plugin site link |
| version | yes | Plugin version |
| imageRequestInit | no | Plugin Image Request Init |
| filters | no | Filter definition object |
| pluginSettings | no | Plugin settings object |
| webStorageUtilized | no | Flag for plugins that need localStorage/sessionStorage |
| customJS | no | Path to a custom JS file, converted to a manifest URL — see Repository manifest |
| customCSS | no | Path to a custom CSS file, converted to a manifest URL — see Repository manifest |
| popularNovels(page, options) | yes | Novel list getter |
| parseNovel(path) | yes | Novel info and chapter list getter |
| parseChapter(path) | yes | Chapter text getter |
| searchNovels(searchTerm, page) | yes | Novel searching getter |
| resolveUrl(path, isNovel) | no | Helper that turns a novel/chapter path into a full URL |
| parsePage(novelPath, page) | no | Chapter-list-by-page getter, for novels too large to list in one parseNovel call — see Pagination |
Unique ID of your plugin
class ExamplePlugin implements Plugin.PluginBase {
...
id = 'templateID';
...
}The name of your plugin that is shown in-app
class ExamplePlugin implements Plugin.PluginBase {
...
name = 'template Plugin';
...
}The path to your plugin's icon, relative to public/static (do not include the
public/static prefix itself). The file must actually live at public/static/<icon> in this
repo.
This is an authoring-time path only — the app itself never reads icon or public/static
directly. At publish time, scripts/build-plugin-manifest.js reads this field off your compiled
plugin and turns it into an absolute iconUrl in the published manifest, which is what the app
actually fetches and displays. See Repository manifest & install metadata.
class ExamplePlugin implements Plugin.PluginBase {
...
icon = 'src/en/templateplugin/icon.png';
...
}Warning
Icons should be 96x96px
The url to the plugin's site
class ExamplePlugin implements Plugin.PluginBase {
...
site = 'https://example.com';
...
}Version of your plugin formatted according to semver2.0 spec i.e. <major>.<minor>.<patch>
Where
patchincrements on small fixes that fix the plugin (like site changed a selector, filter had a typo etc.)minorincrements on fixes that improve the plugin (like adding/removing filters, adding search options etc.)majorincrements on fixes that fix the major issues with the plugin (like changing site link)
class ExamplePlugin implements Plugin.PluginBase {
...
version = '1.0.0';
...
}The init for request to obtain images
Used if images failed to load due to site's protection
class ExamplePlugin implements Plugin.PluginBase {
...
imageRequestInit: Plugin.ImageRequestInit = {
headers: {
Referer: 'https://example.com',
},
};
...
}Optional flag that tells the app your plugin needs access to localStorage/sessionStorage
(see Other libraries). Leave it unset if your plugin only uses storage for
plugin settings.
class ExamplePlugin implements Plugin.PluginBase {
...
webStorageUtilized = true;
...
}Path to a custom JavaScript file, relative to public/static (same convention as
icon). Used by some multi-source templates to run extra JS against the parsed
page (e.g. stripping a site's injected copyright notice).
Like icon, this is an authoring-time path: scripts/build-plugin-manifest.js turns it into an
absolute URL in the published manifest, and the app downloads that URL into the plugin's private
storage the first time the plugin is installed or updated — see
Repository manifest & install metadata.
class ExamplePlugin implements Plugin.PluginBase {
...
customJS = 'src/en/templateplugin/customJS.js';
...
}Path to a custom CSS file, relative to public/static (same convention as
icon), applied when rendering the chapter/novel page in-app.
Same publish-time/install-time handling as customJS — see Repository manifest & install metadata.
class ExamplePlugin implements Plugin.PluginBase {
...
customCSS = 'src/en/templateplugin/customCSS.css';
...
}A Filter definition object that holds filters used in the
popularNovels function. Filters and FilterTypes come from
@libs/filterInputs:
import { FilterTypes, Filters } from '@libs/filterInputs';See Filters for the full type reference.
class ExamplePlugin implements Plugin.PluginBase {
...
filters = {
order: {
label: 'Order',
options: [
{ label: 'Popular', value: '' },
{ label: 'Newest', value: 'newest' },
],
type: FilterTypes.Picker,
value: '',
},
status: {
label: 'Status',
options: [
{ label: 'All', value: '' },
{ label: 'Ongoing', value: 'ongoing' },
{ label: 'Hiatus', value: 'hiatus' },
{ label: 'Completed', value: 'completed' },
],
type: FilterTypes.Picker,
value: '',
},
} satisfies Filters;
...
}Function that is used to get the (filtered) list of novels from the front page of the site
async popularNovels(
page: number,
options: Plugin.PopularNovelsOptions<typeof this.filters>
): Promise<Plugin.NovelItem[]>See Using cheerio for more information on how to parse HTML documents
pagecurrent page to fetchoptionsPopularNovelsOptions
NovelItem[] An array of filtered main-page NovelItems
class ExamplePlugin implements Plugin.PluginBase {
...
async popularNovels(
page: number,
options: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
if (options.filters.status.value === 'ongoing') {
novels.push({
name: 'Novel1',
path: '/novel1',
cover: defaultCover,
});
}
return novels;
}
}This type is used for getting the options of the popularNovels function
-
showLatestNovels: booleanflag set when opened with theLatestbutton -
filters: FilterToValues<typeof filters>object containing all selected filter values. More about Filters
Function that is used to get the information about a particular novel and the list of its chapters
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel>See Using cheerio for more information on how to parse HTML documents
novelPathvalue from NovelItem::path
SourceNovel Novel information and chapter list as SourceNovel object
Caution
SourceNovel::path should be the same value as NovelItem::path provided as parameter!
class ExamplePlugin implements Plugin.PluginBase {
...
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novel: Plugin.SourceNovel = {
path: novelPath,
name: 'test',
artist: 'none',
author: 'none',
cover: defaultCover,
genres: 'Isekai, Neverland',
status: NovelStatus.Completed,
summary: '',
chapters: [],
};
const chapter: Plugin.ChapterItem = {
name: '',
path: '',
releaseTime: '',
chapterNumber: 0,
};
novel.chapters.push(chapter);
return novel;
}
...
}Function that is used to get the text content of a particular chapter
async parseChapter(chapterPath: string): Promise<string>See Using cheerio for more information on how to parse HTML documents
chapterPathvalue from ChapterItem::path
string HTML content of the chapter
class ExamplePlugin implements Plugin.PluginBase {
...
async parseChapter(chapterPath: string): Promise<string> {
return '<h1>No chapter here</h1>';
}
...
}Function that is used to find novels in the source
async searchNovels(searchTerm: string, pageNo: number): Promise<Plugin.NovelItem[]>See Using cheerio for more information on how to parse HTML documents
searchTermthe search termpageNosearch page number
NovelItem[] An array of found NovelItems
class ExamplePlugin implements Plugin.PluginBase {
...
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
return novels;
}
}Optional helper that turns a novel or chapter path into a full, requestable URL. It isn't
required by the interface, but most plugins define one to avoid repeating
this.site + '/...' string concatenation in every function.
resolveUrl?(path: string, isNovel?: boolean): string;class ExamplePlugin implements Plugin.PluginBase {
...
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + (isNovel ? '/novel/' : '/chapter/') + path;
}It is an object representing information on how to store/access the novel
| Field | Type | Required | Description |
|---|---|---|---|
id |
undefined |
yes | Reserved for the app's internal use — always assign the literal value undefined, never a real id, from plugin code |
path |
string |
yes | The relative path to the novel |
name |
string |
yes | The name of the novel shown in the library |
cover |
string |
no | URL to novel's cover |
You can use the default Cover not available cover by importing
import { defaultCover } from '@libs/defaultCover';SourceNovel extends NovelItem, so id, path, name, and cover behave the same
way here as they do there.
| Field | Type | Required | Description |
|---|---|---|---|
path |
string |
yes | Must match the NovelItem::path passed into parseNovel |
| name | string |
yes | The novel's title |
| cover | string |
no | URL to the novel's cover |
| genres | string |
no | Comma-separated genre list, e.g. "Action,Fantasy,Romance" |
| summary | string |
no | The novel's synopsis/description |
| author | string |
no | |
| artist | string |
no | |
| status | NovelStatus | no | See NovelStatus for the standard values |
| rating | number |
no | Rating out of 5, as a float |
| chapters | ChapterItem[] |
yes | The novel's chapter list. If the novel is paginated, return the first page's chapters here and see Pagination |
| totalPages | number |
no | Total number of chapter-list pages, for paginated novels — see Pagination |
| Field | Type | Required | Description |
|---|---|---|---|
| name | string |
yes | |
| path | string |
yes | |
| releaseTime | string |
no | "YYYY-MM-DD" or an ISO date string |
| chapterNumber | number |
no | |
| page | string |
no | Only used for novels without pages (see Pagination) |
| scanlator | string or string[] |
no | Name(s) of the scanlation/translation group(s) |
Filters and FilterTypes are not in the Plugin namespace and are from @libs/filterInputs file:
import { FilterTypes, Filters } from '@libs/filterInputs';There are 2 main objects when using filters:
- Filter definition object
- FilterValues object
This is the user-defined object that defines strictly what filters are available in the "filter" menu in app. Every property of this object is a different filter. The key of the object is the name that will be used to reference this filter's value in the FilterValues object
filters = {
order: {<FilterProperties>},
} satisfies Filters;
// accessible in popularNovels as
options.filters.order;Caution
Do not forget to add satisfies Filters after the Filter definition object!
| Name | Type | Required | Description |
|---|---|---|---|
| label | string |
yes | in-app label |
| type | FilterTypes |
yes | type of the filter |
| value | check types | yes | Default value for this filter and the starting filter state in-app |
| options | check types | in some types | The options available in the given type |
filters = {
genre: {
type: FilterTypes.CheckboxGroup,
label: 'Genres',
value: [],
options: [
{ label: 'Isekai', value: 'isekai' },
{ label: 'Romance', value: 'romans' },
],
},
} satisfies Filters;Types of filters supported. The FilterTypes enum values shown below are also the strings used to
serialize each filter's type (e.g. FilterTypes.CheckboxGroup === 'Checkbox').
| FilterType | Serialized as | Description | value |
options |
|---|---|---|---|---|
Picker |
'Picker' |
A spinner for choosing one of the choices provided in options |
string the picked value |
Picker options |
TextInput |
'Text' |
A filter allowing a free text input | string written value |
N/A |
Switch |
'Switch' |
A boolean switch | boolean state of the switch |
N/A |
CheckboxGroup |
'Checkbox' |
A grouping of checkboxes | string[] array containing selected values |
CheckboxGroup options |
ExcludableCheckboxGroup |
'XCheckbox' |
A grouping of checkboxes where each one can be marked as included or excluded (e.g. "must have this genre" vs. "must not have this genre") | ExcludableCheckboxGroupValue object | CheckboxGroup options |
options: [
{
label: 'default', // in-app label
value: '', // in-code value
},
{
label: 'Value ABC',
value: 'abc',
},
];options: [
{
label: 'Value ABC', // in-app label
value: 'abc', // in-code value
},
{
label: 'Value DEF',
value: 'def',
},
];It is an object used inside of popularNovels that contains selected values for all filters defined in the Filter definition object.
The keys of the filter values correspond to Filter definition keys
// Filter definition object
filters = { abc: {} } satisfies Filters;
// then
options.filters; // FilterValues
options.filters.abc; // FilterValue for abc filterProperties of FilterValue:
type: FilterTypetype of the filtervaluevalue dependent on FilterTypes
options.filters.abc.value; // value of the filter
options.filters.abc.type; // type of the filter{
include?: string[]; // values of the checkboxes marked as included
exclude?: string[]; // values of the checkboxes marked as excluded
}Plugin settings allow plugins to define user-configurable options that are displayed in the app's settings UI. These settings are persistent and can be accessed within the plugin code.
A user-defined object that defines configurable settings for the plugin. Each property of this object is a different setting that will be displayed in the app's settings UI.
pluginSettings = {
settingKey: {
value: '',
label: 'Setting Label',
type: 'Text', // optional, defaults to 'Text'
},
};The shape of a setting depends on its type — see Setting Types below for the
value/options each one requires.
| Name | Type | Required | Description |
|---|---|---|---|
| value | depends on type, see below |
yes | Default value for this setting |
| label | string |
yes | Display label shown in the app's settings UI |
| type | 'Text' | 'Switch' | 'Select' | 'CheckboxGroup' |
no | Type of the setting UI component (see below) |
| options | { label: string; value: string }[] |
for Select/CheckboxGroup |
The choices shown for that setting |
Four setting types are supported:
| Type | Description | UI Component | value type |
|---|---|---|---|
Text |
A text input field (default if type is omitted) | TextInput | string |
Switch |
A boolean toggle switch | SwitchItem | boolean |
Select |
A single choice from a dropdown menu | Menu | string — must match one option's value |
CheckboxGroup |
Multiple choices toggled independently | Checkbox list | string[] — the selected option values |
Select and CheckboxGroup also require an options: { label: string; value: string }[] array,
the same shape as Picker options/CheckboxGroup options
for filters.
Note
If type is not specified, the setting defaults to Text type and will be rendered as a TextInput.
Settings values are stored and can be accessed using the storage utility:
import { storage } from '@libs/storage';
// Get a setting value
const settingValue = storage.get('settingKey');
// Set a setting value
storage.set('settingKey', 'newValue');Warning
The settings screen writes directly to storage — it does not reload or re-instantiate your
plugin. If you read a setting as a class-field initializer (as in the examples below), that
field is only evaluated once, when the plugin is loaded, so it won't reflect a value the user
changes afterwards until the plugin is reloaded (e.g. on app restart or plugin update). If your
plugin needs to react to a changed setting immediately, call storage.get('settingKey') inside
the method that needs it instead of caching it in a field.
class ExamplePlugin implements Plugin.PluginBase {
...
hideLocked = storage.get('hideLocked');
pluginSettings = {
hideLocked: {
value: false,
label: 'Hide locked chapters',
type: 'Switch',
},
};
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
// Use the setting value
if (this.hideLocked) {
// Filter out locked chapters
}
...
}
...
}class ExamplePlugin implements Plugin.PluginBase {
...
site = storage.get('url');
email = storage.get('email');
password = storage.get('password');
pluginSettings = {
url: {
value: '',
label: 'URL',
// type: 'Text' is optional
},
email: {
value: '',
label: 'Email',
type: 'Text',
},
password: {
value: '',
label: 'Password',
// type defaults to 'Text' if omitted
},
};
async makeRequest(url: string): Promise<string> {
return await fetchApi(url, {
headers: {
Authorization: `Basic ${btoa(this.email + ':' + this.password)}`,
Referer: this.site,
},
}).then(res => res.text());
}
...
}class ExamplePlugin implements Plugin.PluginBase {
...
pluginSettings = {
quality: {
value: 'high',
label: 'Image quality',
type: 'Select',
options: [
{ label: 'Low', value: 'low' },
{ label: 'High', value: 'high' },
],
},
excludedTags: {
value: [],
label: 'Excluded tags',
type: 'CheckboxGroup',
options: [
{ label: 'Mature', value: 'mature' },
{ label: 'Adaptation', value: 'adaptation' },
],
},
};
...
}NovelStatus is an enum of the standard values used for SourceNovel::status. Using
it (instead of a raw string) is what lets the app group/filter novels by status consistently
across plugins.
import { NovelStatus } from '@libs/novelStatus';| Member | Value |
|---|---|
Unknown |
'Unknown' |
Ongoing |
'Ongoing' |
Completed |
'Completed' |
Licensed |
'Licensed' |
PublishingFinished |
'Publishing Finished' |
Cancelled |
'Cancelled' |
OnHiatus |
'On Hiatus' |
STUB |
'STUB' |
Inactive |
'Inactive' |
status is typed as NovelStatus, not a free-form string — always assign one of the members
above (there's no fallback for other strings; pick Unknown if the source's status doesn't map
onto any of them).
Some sites split a novel's chapter list across multiple pages rather than returning it all from
parseNovel. For those, implement parsePage in addition to parseNovel:
parsePage?(novelPath: string, page: string): Promise<Plugin.SourcePage>;SourceNovel::chaptersshould hold the first page of chapters, andSourceNovel::totalPagesshould be set to the total number of pages.parsePageis called with the samenovelPathand apagestring (ChapterItem::page, if you set it) for every subsequent page the app needs, and should return that page's chapters:
type SourcePage = {
chapters: Plugin.ChapterItem[];
};class ExamplePlugin implements Plugin.PluginBase {
...
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novel: Plugin.SourceNovel = {
path: novelPath,
name: 'test',
chapters: await this.parseChapterListPage(novelPath, '1'),
totalPages: 5,
};
return novel;
}
async parsePage(
novelPath: string,
page: string,
): Promise<Plugin.SourcePage> {
return { chapters: await this.parseChapterListPage(novelPath, page) };
}
private async parseChapterListPage(
novelPath: string,
page: string,
): Promise<Plugin.ChapterItem[]> {
// fetch and parse the given page's chapter list
return [];
}
}If your plugin doesn't paginate chapter lists, omit parsePage/totalPages entirely and just
return the full list from parseNovel.
Most sites are scraped by fetching the page HTML and parsing it with Cheerio, a jQuery-like API for traversing/selecting elements server-side.
import { load as parseHTML } from 'cheerio';A typical popularNovels implementation fetches a listing page, loads it into Cheerio, and maps
each matching element to a NovelItem:
async popularNovels(page: number): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const body = await fetchApi(`${this.site}/novels?page=${page}`).then(res =>
res.text(),
);
const $ = parseHTML(body);
$('li.novel-item').each((i, el) => {
const name = $(el).find('.title').text().trim();
const path = $(el).find('a').attr('href')?.replace(this.site, '');
const cover = $(el).find('img').attr('src');
if (!path) return;
novels.push({ name, path, cover });
});
return novels;
}A similar pattern for parseNovel, pulling structured fields (author, genres, status) plus a
chapter list off the novel page:
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const body = await fetchApi(this.site + novelPath).then(res => res.text());
const $ = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: $('h1.novel-title').text().trim(),
cover: $('.novel-cover img').attr('src'),
author: $('.novel-author').text().trim(),
genres: $('.novel-genres a')
.map((i, el) => $(el).text().trim())
.get()
.join(','),
summary: $('.novel-summary').text().trim(),
status: $('.novel-status').text().includes('Ongoing')
? NovelStatus.Ongoing
: NovelStatus.Completed,
chapters: [],
};
$('ul.chapter-list li a').each((i, el) => {
const chapterPath = $(el).attr('href')?.replace(this.site, '');
if (!chapterPath) return;
novel.chapters.push({
name: $(el).text().trim(),
path: chapterPath,
chapterNumber: i + 1,
});
});
return novel;
}Notes:
$(el)re-scopes a selector to a single element found by.each(); without it you'd search the whole document again for every item.pathshould be relative (stripthis.site/the domain) — see NovelItem::path.- Prefer
.attr('href')/.attr('src')over.text()for links and images, and always guard forundefinedsince a selector can fail to match if the site changes its markup. .map((i, el) => ...).get()is Cheerio's way of turning a selection into a plain array —.get()is required, a bare.map()returns a Cheerio object, not an array.
See the Cheerio API docs for the full set of selectors/methods
(.find(), .first(), .eq(), .attr(), .text(), .html(), etc.), and look at existing
plugins under plugins/** for real examples.
Plugins can't use the browser/Node fetch directly — use the wrappers from @libs/fetch instead,
which handle plugin-specific request setup (default headers, etc.):
import { fetchApi, fetchText, fetchProto } from '@libs/fetch';declare function fetchApi(url: string, init?: FetchInit): Promise<Response>;The general-purpose fetcher. Returns a standard Response, so use .text(), .json(), etc. on
the result, the same way you would with the native fetch.
Every request automatically gets these default headers, merged under whatever you pass in
init.headers (a header you set yourself takes priority over the default of the same name):
User-Agent— the app's configured user agentConnection: keep-aliveAccept: */*Accept-Language: *Accept-Encoding: gzip, deflateSec-Fetch-Mode: corsCache-Control: max-age=0
You don't need to set these yourself; only pass headers the site actually requires beyond the
defaults (Referer, Authorization, Cookie, etc.).
const res = await fetchApi(this.resolveUrl(novelPath));
const body = await res.text();declare function fetchText(
url: string,
init?: FetchInit,
encoding?: string,
): Promise<string>;A shortcut for fetchApi(...).then(res => res.text()), with an optional encoding for sites that
don't serve UTF-8 (e.g. fetchText(url, undefined, 'gbk') for some Chinese-language sites).
Warning
fetchText never throws. If the request fails (network error) or the response isn't ok (e.g.
a 404/500), it silently returns an empty string '' instead of rejecting. Check for an
empty result before parsing it if the site's availability can't be assumed:
const body = await fetchText(url);
if (!body) {
// request failed or returned no content — bail out instead of parsing ''
return novels;
}declare function fetchProto(
protoInit: ProtoRequestInit,
url: string,
init?: FetchInit,
): Promise<unknown>;For sites whose API responds with Protocol Buffers instead of JSON/HTML.
type ProtoRequestInit = {
proto: string; // the .proto schema source
requestType: string; // message type to encode the request as
requestData?: any; // request payload, encoded as `requestType`
responseType: string; // message type to decode the response as
};This is an advanced/uncommon case — only reach for it if the site's API is proto-based, which you
can usually tell from binary (non-JSON) response bodies on an application/x-protobuf-style
content type.
The init object accepted by all three functions above:
type FetchInit = {
headers?: Record<string, string> | Headers;
method?: string;
body?: FormData | string;
[key: string]: string | Record<string, string> | FormData | Headers | undefined;
};It mirrors the standard fetch init object
(headers, method, body) — set headers like Referer/Authorization/Cookie under
headers, not as top-level keys.
A few smaller helpers and runtime-provided packages are available for less common cases. You
generally won't need most of these unless your target site requires them. Everything listed here
is what's actually injected into a running plugin's require(...) calls — anything else you
import won't resolve at runtime even if it type-checks locally.
| Package | Exposes | Typical use |
|---|---|---|
cheerio |
load |
HTML parsing — see Using Cheerio |
@libs/fetch |
fetchApi, fetchText, fetchProto |
Network requests — see Custom fetching functions |
dayjs |
the dayjs default export |
Parsing/formatting relative or oddly-formatted release dates |
urlencode |
encode, decode |
Percent-encoding for URLs/query params, with non-UTF-8 charset support |
htmlparser2 |
Parser |
Low-level streaming HTML/XML parsing, for pages too large or malformed for Cheerio |
@libs/storage |
storage, localStorage, sessionStorage |
Persistent key-value storage — see storage below |
@libs/isAbsoluteUrl |
isUrlAbsolute |
See below |
@libs/filterInputs |
FilterTypes, Filters, ... |
See Filters |
@libs/novelStatus |
NovelStatus |
See NovelStatus |
@libs/defaultCover |
defaultCover |
See Default cover |
@libs/aes |
gcm |
See AES decryption below |
@libs/utils |
utf8ToBytes, bytesToUtf8 |
See AES decryption below |
import { isUrlAbsolute } from '@libs/isAbsoluteUrl';
declare function isUrlAbsolute(url: string): boolean;Useful when a site mixes absolute and relative URLs in the same listing (e.g. some cover images are full URLs, others are paths) and you need to normalize them before returning a NovelItem/SourceNovel.
import { storage, localStorage, sessionStorage } from '@libs/storage';storage is the same persistent key-value store used for plugin settings —
you can also use it directly for things like caching a session cookie or an auth token between
requests. It supports more than a plain get/set pair:
| Method | Description |
|---|---|
storage.set(key, value, expires?) |
Stores value under key; expires is an optional Date or epoch-millisecond timestamp after which the entry is treated as gone |
storage.get(key, raw?) |
Returns the stored value, or undefined if missing/expired. Pass raw: true to get back { created, value, expires } instead of just value |
storage.delete(key) |
Removes a single key |
storage.clearAll() |
Removes every key this plugin has stored |
storage.getAllKeys() |
Returns all keys currently set by this plugin |
localStorage/sessionStorage are separate, lower-level stores for plugins that need that exact
browser-style API (for example, reusing scraping code shared with a web target) — but unlike
storage, they are read-only from plugin code: each only exposes get(), with no set().
They're populated by the app's own WebView integration, not written by your plugin. If your
plugin uses either of them, set webStorageUtilized to true on
the plugin so the app knows to provide that access.
import { gcm } from '@libs/aes';
import { utf8ToBytes, bytesToUtf8 } from '@libs/utils';For sites that encrypt their API responses with AES-GCM (uncommon, but seen on a handful of
sources). gcm(key, nonce, AAD?) returns a Cipher with encrypt/decrypt methods operating on
Uint8Array; utf8ToBytes/bytesToUtf8 convert between that and plain strings.
const cipher = gcm(keyBytes, nonceBytes);
const plaintext = bytesToUtf8(cipher.decrypt(ciphertextBytes));This is an advanced case — only needed if you've confirmed the site is actually encrypting its payloads, not just minifying/obfuscating them.
The plugin bundle API above is what your .ts file implements. Separately, this repository
publishes a manifest (plugins.json) listing every plugin, which is what the LNReader app
actually reads to show, install, and update plugins from this repo. The manifest entry for a
plugin is built automatically from your class's fields — you don't write it by hand — but it's
useful to know its shape, since it's what the app sees, not your source file directly.
// app-side manifest entry shape
type PluginItem = {
id: string;
name: string;
site: string;
lang: string; // full language name, e.g. "English"
version: string;
url: string; // raw URL to the compiled plugin's JS, which the app downloads and runs
iconUrl: string; // absolute URL to the icon, always present (falls back to a placeholder)
customJS?: string; // absolute URL to the custom JS file, if any
customCSS?: string; // absolute URL to the custom CSS file, if any
hasUpdate?: boolean;
hasSettings?: boolean;
};lang, url, and iconUrl are always required on every manifest entry; the app relies on all
three being present to list and install a plugin.
npm run build:manifest (scripts/build-plugin-manifest.js) compiles every plugin under
plugins/**, evaluates its default export, and reads off id, name, site, version, icon,
customJS, customCSS, and filters to build each plugins.json entry:
iconUrlis built from your class'siconfield:icon(orsiteNotAvailable.pngif unset) is appended to this repo'spublic/staticraw-content URL for the current branch.customJS/customCSSare built the same way from your class'scustomJS/customCSSfields, when set — omitted from the manifest entirely if you didn't set them.urlpoints at the compiled JS for your plugin, not your.tssource.
scripts/download-plugin-icons.js separately fetches/validates the icon file that
iconUrl will point to (falling back to the site's favicon when you haven't committed one) and
prunes any public/static assets no manifest entry references any more.
When the app installs or updates a plugin from a repository's manifest, it:
- Downloads the compiled JS from the entry's
urland evaluates it as the plugin. - If the entry has a
customJSand/orcustomCSSURL, downloads each into the plugin's own private on-device storage (notpublic/static— that's this repo's hosting location, not where the installed copy ends up) alongside the plugin's compiled code. - Displays the plugin using
iconUrldirectly — the app never resolves aniconpath itself.
In other words: icon/customJS/customCSS on your class are source-repo-relative paths that
exist only so the build step above can turn them into the absolute, downloadable URLs
(iconUrl/customJS/customCSS) the manifest — and therefore the app — actually uses.