Skip to content

Latest commit

 

History

History
1238 lines (967 loc) · 45.6 KB

File metadata and controls

1238 lines (967 loc) · 45.6 KB

Documentation for LNReader plugins


Getting started

  1. Pick the language folder your source belongs to under plugins/<language>/ (full language name, e.g. plugins/english/), and create a .ts file there — e.g. plugins/english/myNovelSite.ts.
  2. Copy docs/plugin-template.ts into that file as a starting point. It already imports the pieces most plugins need (fetchApi/fetchText, Plugin namespace, Filters, cheerio, defaultCover, NovelStatus) and stubs out the required methods.
  3. 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 set icon = '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.
  4. Fill in popularNovels, parseNovel, parseChapter, and searchNovels against 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.
  5. Test locally with npm run dev:start, which launches a browser playground at http://localhost:3000 where you can run your plugin's functions against the real site. Before opening a PR, run npm run check:plugin -- plugins/<lang>/yourPlugin.ts (see docs/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.


Plugin bundle API

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

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

PluginBase::id

Unique ID of your plugin

class ExamplePlugin implements Plugin.PluginBase {
  ...
  id = 'templateID';
  ...
}

PluginBase::name

The name of your plugin that is shown in-app

class ExamplePlugin implements Plugin.PluginBase {
  ...
  name = 'template Plugin';
  ...
}

PluginBase::icon

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

PluginBase::site

The url to the plugin's site

Example
class ExamplePlugin implements Plugin.PluginBase {
  ...
  site = 'https://example.com';
  ...
}

PluginBase::version

Version of your plugin formatted according to semver2.0 spec i.e. <major>.<minor>.<patch>

Where

  • patch increments on small fixes that fix the plugin (like site changed a selector, filter had a typo etc.)
  • minor increments on fixes that improve the plugin (like adding/removing filters, adding search options etc.)
  • major increments on fixes that fix the major issues with the plugin (like changing site link)
Example
class ExamplePlugin implements Plugin.PluginBase {
  ...
  version = '1.0.0';
  ...
}

PluginBase::imageRequestInit

The init for request to obtain images

Used if images failed to load due to site's protection

Example
class ExamplePlugin implements Plugin.PluginBase {
  ...
  imageRequestInit: Plugin.ImageRequestInit = {
    headers: {
      Referer: 'https://example.com',
    },
  };
  ...
}

PluginBase::webStorageUtilized

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;
  ...
}

PluginBase::customJS

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';
  ...
}

PluginBase::customCSS

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';
  ...
}

PluginBase::filters

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.

Example
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;
  ...
}

PluginBase::popularNovels

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

Parameters
Returns

NovelItem[] An array of filtered main-page NovelItems

Example
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;
  }
}
PluginBase::PopularNovelsOptions

This type is used for getting the options of the popularNovels function

  • showLatestNovels: boolean flag set when opened with the Latest button

  • filters: FilterToValues<typeof filters> object containing all selected filter values. More about Filters

PluginBase::parseNovel

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

Parameters
Returns

SourceNovel Novel information and chapter list as SourceNovel object

Caution

SourceNovel::path should be the same value as NovelItem::path provided as parameter!

Example
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;
  }
  ...
}

PluginBase::parseChapter

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

Parameters
Returns

string HTML content of the chapter

Example
class ExamplePlugin implements Plugin.PluginBase {
  ...
  async parseChapter(chapterPath: string): Promise<string> {
    return '<h1>No chapter here</h1>';
  }
  ...
}

PluginBase::searchNovels

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

Parameters
  • searchTerm the search term
  • pageNo search page number
Returns

NovelItem[] An array of found NovelItems

Example
class ExamplePlugin implements Plugin.PluginBase {
  ...
  async searchNovels(
    searchTerm: string,
    pageNo: number,
  ): Promise<Plugin.NovelItem[]> {
    const novels: Plugin.NovelItem[] = [];
    return novels;
  }
}

PluginBase::resolveUrl

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;
Example
class ExamplePlugin implements Plugin.PluginBase {
  ...
  resolveUrl = (path: string, isNovel?: boolean) =>
    this.site + (isNovel ? '/novel/' : '/chapter/') + path;
}

NovelItem

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

Default cover

You can use the default Cover not available cover by importing

import { defaultCover } from '@libs/defaultCover';

SourceNovel

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

ChapterItem

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

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

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!

FilterProperties
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
Example
filters = {
  genre: {
    type: FilterTypes.CheckboxGroup,
    label: 'Genres',
    value: [],
    options: [
      { label: 'Isekai', value: 'isekai' },
      { label: 'Romance', value: 'romans' },
    ],
  },
} satisfies Filters;
Filter types

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
Picker options
options: [
  {
    label: 'default', // in-app label
    value: '', // in-code value
  },
  {
    label: 'Value ABC',
    value: 'abc',
  },
];
CheckboxGroup options
options: [
  {
    label: 'Value ABC', // in-app label
    value: 'abc', // in-code value
  },
  {
    label: 'Value DEF',
    value: 'def',
  },
];

FilterValues object

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 filter
FilterValue

Properties of FilterValue:

  • type: FilterType type of the filter
  • value value dependent on FilterTypes
options.filters.abc.value; // value of the filter
options.filters.abc.type; // type of the filter
ExcludableCheckboxGroupValue object
{
  include?: string[]; // values of the checkboxes marked as included
  exclude?: string[]; // values of the checkboxes marked as excluded
}

PluginSettings

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.

PluginBase::pluginSettings

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'
  },
};
Setting Properties

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
Setting Types

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.

Accessing Settings Values

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.

Examples
Example 1: Switch Setting
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
    }
    ...
  }
  ...
}
Example 2: Text Settings
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());
  }
  ...
}
Example 3: Select and CheckboxGroup Settings
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

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).


Pagination

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::chapters should hold the first page of chapters, and SourceNovel::totalPages should be set to the total number of pages.
  • parsePage is called with the same novelPath and a page string (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[];
};
Example
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.


Using Cheerio

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.
  • path should be relative (strip this.site/the domain) — see NovelItem::path.
  • Prefer .attr('href') / .attr('src') over .text() for links and images, and always guard for undefined since 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.


Custom fetching functions

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';

fetchApi

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 agent
  • Connection: keep-alive
  • Accept: */*
  • Accept-Language: *
  • Accept-Encoding: gzip, deflate
  • Sec-Fetch-Mode: cors
  • Cache-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();

fetchText

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;
}

fetchProto

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.

FetchInit

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.


Other libraries

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

isUrlAbsolute

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.

storage (localStorage / sessionStorage)

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.

AES decryption

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.


Repository manifest & install metadata

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.

How manifest fields are produced

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:

  • iconUrl is built from your class's icon field: icon (or siteNotAvailable.png if unset) is appended to this repo's public/static raw-content URL for the current branch.
  • customJS/customCSS are built the same way from your class's customJS/customCSS fields, when set — omitted from the manifest entirely if you didn't set them.
  • url points at the compiled JS for your plugin, not your .ts source.

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.

Install flow (app side)

When the app installs or updates a plugin from a repository's manifest, it:

  1. Downloads the compiled JS from the entry's url and evaluates it as the plugin.
  2. If the entry has a customJS and/or customCSS URL, downloads each into the plugin's own private on-device storage (not public/static — that's this repo's hosting location, not where the installed copy ends up) alongside the plugin's compiled code.
  3. Displays the plugin using iconUrl directly — the app never resolves an icon path 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.