docs: Fix Documentation Accuracy, Dead Links, and Grammar in Docs (#2402)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Rajarshee Chatterjee
2026-08-09 11:02:14 +05:30
committed by GitHub
parent f20fb69e44
commit be2dd52cd2
7 changed files with 560 additions and 230 deletions
+2 -1
View File
@@ -10,7 +10,7 @@ Community-driven plugin repository for [LNReader](https://github.com/LNReader/ln
## Quick Start ## Quick Start
**Prerequisites:** Node.js >= 22 **Prerequisites:** Node.js >= 22
```bash ```bash
npm install npm install
@@ -22,6 +22,7 @@ npm run dev:start
- **[Quick Start Guide](./docs/quickstart.md)** - Create your first plugin - **[Quick Start Guide](./docs/quickstart.md)** - Create your first plugin
- **[Plugin Development](./docs/docs.md)** - Complete API reference - **[Plugin Development](./docs/docs.md)** - Complete API reference
- **[Testing Guide](./docs/website-tutorial.md)** - Test plugins using the web interface - **[Testing Guide](./docs/website-tutorial.md)** - Test plugins using the web interface
- **[Live Check](./docs/testing.md)** - Required `npm run check:plugin` check before opening a PR
- **[Komga Plugin](./docs/komga-plugin.md)** - Self-hosted server integration - **[Komga Plugin](./docs/komga-plugin.md)** - Self-hosted server integration
## Testing Methods ## Testing Methods
+480 -204
View File
@@ -6,8 +6,10 @@
- [ChapterItem](#chapteritem) - [ChapterItem](#chapteritem)
- [Filters](#filters) - [Filters](#filters)
- [PluginSettings](#pluginsettings) - [PluginSettings](#pluginsettings)
- [NovelStatus](#novelstatus)
- [Using Cheerio](#using-cheerio) - [Using Cheerio](#using-cheerio)
- [Custom fetching functions](#custom-fetching-functions) - [Custom fetching functions](#custom-fetching-functions)
- [Other libraries](#other-libraries)
Most of the Plugin/Novel type definitions accessed using the `Plugin` namespace imported via Most of the Plugin/Novel type definitions accessed using the `Plugin` namespace imported via
@@ -23,20 +25,24 @@ PluginBase is a base class for all plugins.
class ExamplePlugin implements Plugin.PluginBase {} class ExamplePlugin implements Plugin.PluginBase {}
``` ```
| Field | Required | Description | | Field | Required | Description |
| -------------------------------------------------------------- | -------- | ----------------------------------------------------- | | ----------------------------------------------------------- | -------- | ------------------------------------------------------- |
| [id](#pluginbaseid) | yes | Plugin ID | | [id](#pluginbaseid) | yes | Plugin ID |
| [name](#pluginbasename) | yes | Plugin Name | | [name](#pluginbasename) | yes | Plugin Name |
| [icon](#pluginbasename) | yes | Plugin Icon | | [icon](#pluginbaseicon) | yes | Plugin Icon |
| [site](#pluginbasesite) | yes | Plugin site link | | [site](#pluginbasesite) | yes | Plugin site link |
| [version](#pluginbaseversion) | yes | Plugin version | | [version](#pluginbaseversion) | yes | Plugin version |
| [imageRequestInit](#pluginbaseimagerequestinit) | no | Plugin Image Request Init | | [imageRequestInit](#pluginbaseimagerequestinit) | no | Plugin Image Request Init |
| [filters](#pluginbasefilters) | no | [Filter definition](#filter-definition-object) object | | [filters](#pluginbasefilters) | no | [Filter definition](#filter-definition-object) object |
| [pluginSettings](#pluginbasepluginsettings) | no | [Plugin settings](#pluginsettings) object | | [pluginSettings](#pluginbasepluginsettings) | no | [Plugin settings](#pluginsettings) object |
| [popularNovels(page, options)](#pluginbasepopularnovels) | yes | Novel list getter | | [webStorageUtilized](#pluginbasewebstorageutilized) | no | Flag for plugins that need `localStorage`/`sessionStorage` |
| [parseNovel(path)](#pluginbaseparsenovel) | yes | Novel info and chapter list getter | | [customJS](#pluginbasecustomjs) | no | Path to a custom JS file bundled with the plugin |
| [parseChapter(path)](#pluginbaseparsechapter) | yes | Chapter text getter | | [customCSS](#pluginbasecustomcss) | no | Path to a custom CSS file bundled with the plugin |
| [searchNovels(searchTerm, page)](#pluginbasesearchnovels) | yes | Novel searching getter | | [popularNovels(page, options)](#pluginbasepopularnovels) | yes | Novel list getter |
| [parseNovel(path)](#pluginbaseparsenovel) | yes | Novel info and chapter list getter |
| [parseChapter(path)](#pluginbaseparsechapter) | yes | Chapter text getter |
| [searchNovels(searchTerm, page)](#pluginbasesearchnovels) | yes | Novel searching getter |
| [resolveUrl(path, isNovel)](#pluginbaseresolveurl) | no | Helper that turns a novel/chapter path into a full URL |
#### PluginBase::id #### PluginBase::id
@@ -44,9 +50,9 @@ Unique ID of your plugin
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
id = "templateID"; id = 'templateID';
... ...
} }
``` ```
@@ -56,21 +62,23 @@ The name of your plugin that is shown in-app
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
name = "template Plugin"; name = 'template Plugin';
... ...
} }
``` ```
#### PluginBase::icon #### PluginBase::icon
The path to your plugin's icon inside of `public/static` folder 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.
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
icon = "src/en/templateplugin/icon.png"; icon = 'src/en/templateplugin/icon.png';
... ...
} }
``` ```
@@ -85,9 +93,9 @@ The url to the plugin's site
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
site = "https://example.com"; site = 'https://example.com';
... ...
} }
``` ```
@@ -105,9 +113,9 @@ Where
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
version = "1.0.0"; version = '1.0.0';
... ...
} }
``` ```
@@ -121,48 +129,90 @@ Used if images failed to load due to site's protection
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
imageRequestInit: Plugin.ImageRequestInit = { imageRequestInit: Plugin.ImageRequestInit = {
headers: { headers: {
Referer: 'https://example.com', Referer: 'https://example.com',
}, },
}; };
... ...
}
```
#### PluginBase::webStorageUtilized
Optional flag that tells the app your plugin needs access to `localStorage`/`sessionStorage`
(see [Other libraries](#other-libraries)). Leave it unset if your plugin only uses `storage` for
[plugin settings](#pluginsettings).
```ts
class ExamplePlugin implements Plugin.PluginBase {
...
webStorageUtilized = true;
...
}
```
#### PluginBase::customJS
Path to a custom JavaScript file, relative to `public/static` (same convention as
[icon](#pluginbaseicon)). Used by some multi-source templates to run extra JS against the parsed
page (e.g. stripping a site's injected copyright notice).
```ts
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](#pluginbaseicon)), applied when rendering the chapter/novel page in-app.
```ts
class ExamplePlugin implements Plugin.PluginBase {
...
customCSS = 'src/en/templateplugin/customCSS.css';
...
} }
``` ```
#### PluginBase::filters #### PluginBase::filters
A [Filter definition]() object that holds filters used in [popularNovels](#pluginbasepopularnovels) function A [Filter definition](#filter-definition-object) object that holds filters used in the
[popularNovels](#pluginbasepopularnovels) function
###### Example ###### Example
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
filters = { filters = {
order: { order: {
label:"Order", label: 'Order',
options: [ options: [
{ label: "Popular", value: "" }, { label: 'Popular', value: '' },
{ label: "Newest", value: "newest" } { label: 'Newest', value: 'newest' },
], ],
type: FilterTypes.Picker, type: FilterTypes.Picker,
value: "" value: '',
}, },
status: { status: {
label: "Status", label: 'Status',
options: [ options: [
{ label: "All", value: "" }, { label: 'All', value: '' },
{ label: "Ongoing", value: "ongoing" }, { label: 'Ongoing', value: 'ongoing' },
{ label: "Hiatus", value: "hiatus" }, { label: 'Hiatus', value: 'hiatus' },
{ label: "Completed", value: "completed" }, { label: 'Completed', value: 'completed' },
], ],
type: FilterTypes.Picker, type: FilterTypes.Picker,
value: "", value: '',
} },
} } satisfies Filters;
... ...
} }
``` ```
@@ -188,25 +238,25 @@ See [Using cheerio](#using-cheerio) for more information on how to parse HTML do
`NovelItem[]` An array of filtered main-page [NovelItems](#novelitem) `NovelItem[]` An array of filtered main-page [NovelItems](#novelitem)
###### Example: ###### Example
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
async popularNovels( async popularNovels(
page: number, page: number,
options: Plugin.PopularNovelsOptions<typeof this.filters> options: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> { ): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = []; const novels: Plugin.NovelItem[] = [];
if(options.filters.example.value === "test"){ if (options.filters.example.value === 'test') {
novels.push({ novels.push({
name: "Novel1", name: 'Novel1',
path: "/novel1", path: '/novel1',
cover:defaultCover cover: defaultCover,
}) });
}
return novels;
} }
return novels;
}
} }
``` ```
@@ -214,13 +264,13 @@ class ExamplePlugin implements Plugin.PluginBase {
This type is used for getting the options of the [popularNovels](#pluginbasepopularnovels) function This type is used for getting the options of the [popularNovels](#pluginbasepopularnovels) function
- <span id='popularnovelsoptions-showlatestnovels'></span>`showLatestNovels: boolean` flag set when opened with `Latest` button - <span id='popularnovelsoptions-showlatestnovels'></span>`showLatestNovels: boolean` flag set when opened with the `Latest` button
- <span id='popularnovelsoptions-showlatestnovels'></span>`filters: FilterValues<typeof filters>` object containing all selected filter values. [More about Filters](#filters) - <span id='popularnovelsoptions-filters'></span>`filters: FilterToValues<typeof filters>` object containing all selected filter values. [More about Filters](#filters)
#### PluginBase::parseNovel #### PluginBase::parseNovel
Function that is used to get the information about particular novel and the list of it's chapters Function that is used to get the information about a particular novel and the list of its chapters
```ts ```ts
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> async parseNovel(novelPath: string): Promise<Plugin.SourceNovel>
@@ -236,42 +286,43 @@ See [Using cheerio](#using-cheerio) for more information on how to parse HTML do
`SourceNovel` Novel information and chapter list as [SourceNovel](#sourcenovel) object `SourceNovel` Novel information and chapter list as [SourceNovel](#sourcenovel) object
> [!CAUTION] > [SourceNovel::path]() should be the same value as [NovelItem::path]() provided as parameter! > [!CAUTION]
> [SourceNovel::path](#sourcenovel) should be the same value as [NovelItem::path](#novelitempath) provided as parameter!
###### Example: ###### Example
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novel: Plugin.SourceNovel = { const novel: Plugin.SourceNovel = {
path: novelPath, path: novelPath,
name: "test", name: 'test',
artist: "none", artist: 'none',
author: "none", author: 'none',
cover: defaultCover, cover: defaultCover,
genres: "Isekai, Neverland", genres: 'Isekai, Neverland',
status: NovelStatus.Completed, status: NovelStatus.Completed,
summary: "" summary: '',
}; };
let chapters: Plugin.ChapterItem[] = []; const chapters: Plugin.ChapterItem[] = [];
const chapter: Plugin.ChapterItem = { const chapter: Plugin.ChapterItem = {
name: "", name: '',
path: "", path: '',
releaseTime: "", releaseTime: '',
chapterNumber: 0, chapterNumber: 0,
}; };
chapters.push(chapter); chapters.push(chapter);
novel.chapters = chapters; novel.chapters = chapters;
return novel; return novel;
} }
... ...
} }
``` ```
#### PluginBase::parseChapter #### PluginBase::parseChapter
Function that is used to get the information about particular novel and the list of it's chapters Function that is used to get the text content of a particular chapter
```ts ```ts
async parseChapter(chapterPath: string): Promise<string> async parseChapter(chapterPath: string): Promise<string>
@@ -287,21 +338,21 @@ See [Using cheerio](#using-cheerio) for more information on how to parse HTML do
`string` HTML content of the chapter `string` HTML content of the chapter
###### Example: ###### Example
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
async parseChapter(chapterPath: string): Promise<string>{ async parseChapter(chapterPath: string): Promise<string> {
return "<h1>No chapter here</h1>"; return '<h1>No chapter here</h1>';
} }
... ...
} }
``` ```
#### PluginBase::searchNovels #### PluginBase::searchNovels
Function that is used to find Novels in the source Function that is used to find novels in the source
```ts ```ts
async searchNovels(searchTerm: string, pageNo: number): Promise<Plugin.NovelItem[]> async searchNovels(searchTerm: string, pageNo: number): Promise<Plugin.NovelItem[]>
@@ -312,7 +363,7 @@ See [Using cheerio](#using-cheerio) for more information on how to parse HTML do
###### Parameters ###### Parameters
- `searchTerm` the search term - `searchTerm` the search term
- `page` search page number - `pageNo` search page number
###### Returns ###### Returns
@@ -322,15 +373,35 @@ See [Using cheerio](#using-cheerio) for more information on how to parse HTML do
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
async searchNovels( async searchNovels(
searchTerm: string, searchTerm: string,
pageNo: number pageNo: number,
): Promise<Plugin.NovelItem[]> { ): Promise<Plugin.NovelItem[]> {
let novels: Plugin.NovelItem[] = []; const novels: Plugin.NovelItem[] = [];
return novels; 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.
```ts
resolveUrl?(path: string, isNovel?: boolean): string;
```
###### Example
```ts
class ExamplePlugin implements Plugin.PluginBase {
...
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + (isNovel ? '/novel/' : '/chapter/') + path;
} }
``` ```
@@ -338,7 +409,7 @@ class ExamplePlugin implements Plugin.PluginBase {
### NovelItem ### NovelItem
It is an object representing information how to store/access the novel It is an object representing information on how to store/access the novel
| Field | type | Required | Description | | Field | type | Required | Description |
| -------------------------------- | -------- | -------- | ------------------------------------------ | | -------------------------------- | -------- | -------- | ------------------------------------------ |
@@ -358,30 +429,34 @@ import { defaultCover } from '@libs/defaultCover';
### SourceNovel ### SourceNovel
| Field | Type | Required | Desciption | `SourceNovel` extends [NovelItem](#novelitem), so `path`, `name`, and `cover` behave the same way
| ------- | ------------------------- | -------- | ---------- | here as they do there.
| path | string | yes | |
| name | string | no | string |
| cover | `string` | no | |
| genres | `string` | no | |
| summary | `string` | no | |
| author | `string` | no | |
| artist | `string` | no | |
| status | [NovelStatus] or `string` | no | |
chapters?: ChapterItem[]; | Field | Type | Required | Description |
| -------- | ---------------------------------- | -------- | --------------------------------------------- |
| path | `string` | yes | Must match the [NovelItem::path](#novelitempath) 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](#novelstatus) or `string` | no | See [NovelStatus](#novelstatus) for the standard values |
| rating | `number` | no | Rating out of 5, as a float |
| chapters | [ChapterItem](#chapteritem)`[]` | no | The novel's chapter list |
--- ---
### ChapterItem ### ChapterItem
| Field | Type | Required | Description | | Field | Type | Required | Description |
| ------------- | -------- | -------- | ------------------------------- | | ------------- | ------------------------ | -------- | ----------------------------------------------------------------- |
| name | string | yes | | | name | `string` | yes | |
| path | string | yes | | | path | `string` | yes | |
| releaseTime | string | no | release time in `YYYY-MM-DD` | | releaseTime | `string` | no | `"YYYY-MM-DD"` or an ISO date string |
| chapterNumber | number | no | | | chapterNumber | `number` | no | |
| page | string | no | for multi-page chapter lists | | page | `string` | no | Only used for novels without pages (see `SourcePage`/`PagePlugin`) |
| scanlator | `string` or `string[]` | no | Name(s) of the scanlation/translation group(s) |
### Filters ### Filters
@@ -403,10 +478,10 @@ Every property of this object is a different filter. The key of the object is th
```ts ```ts
filters = { filters = {
order: {<FilterProperties>} order: {<FilterProperties>},
} satisfies Filters; } satisfies Filters;
// accessible in popularNovels as // accessible in popularNovels as
options.filters.order options.filters.order;
``` ```
> [!CAUTION] > [!CAUTION]
@@ -414,7 +489,7 @@ options.filters.order
##### FilterProperties ##### FilterProperties
| Name | Type | Required | Desciption | | Name | Type | Required | Description |
| ------- | ---------------------------- | ------------- | ------------------------------------------------------------------ | | ------- | ---------------------------- | ------------- | ------------------------------------------------------------------ |
| label | `string` | yes | in-app label | | label | `string` | yes | in-app label |
| type | `FilterTypes` | yes | type of the filter | | type | `FilterTypes` | yes | type of the filter |
@@ -447,7 +522,7 @@ Types of filters supported
| `TextInput` | A filter allowing a free text input | `string` written value | N/A | | `TextInput` | A filter allowing a free text input | `string` written value | N/A |
| `Switch` | A boolean switch | `boolean` state of the switch | N/A | | `Switch` | A boolean switch | `boolean` state of the switch | N/A |
| `CheckboxGroup` | A grouping of checkboxes | `string[]` array containing selected values | [CheckboxGroup](#checkboxgroup-options) options | | `CheckboxGroup` | A grouping of checkboxes | `string[]` array containing selected values | [CheckboxGroup](#checkboxgroup-options) options |
| `ExcludableCheckboxGroup` | A filter allowing to pick one of the choices provided in `options` | [ExcludableCheckboxGroupValues](#excludablecheckboxgroupvalue-object) object | [CheckboxGroup](#checkboxgroup-options) options | | `ExcludableCheckboxGroup` | 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](#excludablecheckboxgroupvalue-object) object | [CheckboxGroup](#checkboxgroup-options) options |
###### Picker options ###### Picker options
@@ -481,7 +556,7 @@ options: [
#### FilterValues object #### FilterValues object
It is an object used inisde of `popularNovels` that contains selected values for all filters defined in the [Filter definition](#filter-definition-object) object. It is an object used inside of `popularNovels` that contains selected values for all filters defined in the [Filter definition](#filter-definition-object) object.
The keys of the filter values correspond to Filter definition keys The keys of the filter values correspond to Filter definition keys
```ts ```ts
@@ -509,8 +584,8 @@ options.filters.abc.type; // type of the filter
```ts ```ts
{ {
included: string[], // options with selected selected include?: string[]; // values of the checkboxes marked as included
excluded: string[] // options with excluded selected exclude?: string[]; // values of the checkboxes marked as excluded
} }
``` ```
@@ -526,11 +601,11 @@ A user-defined object that defines configurable settings for the plugin. Each pr
```ts ```ts
pluginSettings = { pluginSettings = {
settingKey: { settingKey: {
value: '', value: '',
label: 'Setting Label', label: 'Setting Label',
type: 'Text', // optional, defaults to 'Text' type: 'Text', // optional, defaults to 'Text'
}, },
}; };
``` ```
@@ -574,25 +649,25 @@ storage.set('settingKey', 'newValue');
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
hideLocked = storage.get('hideLocked'); hideLocked = storage.get('hideLocked');
pluginSettings = { pluginSettings = {
hideLocked: { hideLocked: {
value: '', value: '',
label: 'Hide locked chapters', label: 'Hide locked chapters',
type: 'Switch', type: 'Switch',
}, },
}; };
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
// Use the setting value // Use the setting value
if (this.hideLocked) { if (this.hideLocked) {
// Filter out locked chapters // Filter out locked chapters
}
...
} }
... ...
}
...
} }
``` ```
@@ -600,43 +675,244 @@ class ExamplePlugin implements Plugin.PluginBase {
```ts ```ts
class ExamplePlugin implements Plugin.PluginBase { class ExamplePlugin implements Plugin.PluginBase {
... ...
site = storage.get('url'); site = storage.get('url');
email = storage.get('email'); email = storage.get('email');
password = storage.get('password'); password = storage.get('password');
pluginSettings = { pluginSettings = {
url: { url: {
value: '', value: '',
label: 'URL', label: 'URL',
// type: 'Text' is optional // type: 'Text' is optional
}, },
email: { email: {
value: '', value: '',
label: 'Email', label: 'Email',
type: 'Text', type: 'Text',
}, },
password: { password: {
value: '', value: '',
label: 'Password', label: 'Password',
// type defaults to 'Text' if omitted // type defaults to 'Text' if omitted
}, },
}; };
async makeRequest(url: string): Promise<string> { async makeRequest(url: string): Promise<string> {
return await fetchApi(url, { return await fetchApi(url, {
headers: { headers: {
'Authorization': `Basic ${this.btoa(this.email + ':' + this.password)}`, Authorization: `Basic ${btoa(this.email + ':' + this.password)}`,
}, Referer: this.site,
Referer: this.site, },
}).then(res => res.text()); }).then(res => res.text());
} }
... ...
} }
``` ```
--- ---
### NovelStatus
`NovelStatus` is an enum of the standard values used for [SourceNovel::status](#sourcenovel). Using
it (instead of a raw string) is what lets the app group/filter novels by status consistently
across plugins.
```ts
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` isn't restricted to these values (it accepts any `string`), but prefer a `NovelStatus`
member whenever the source's status maps onto one — free-text values won't be recognized by the
app's status filter.
---
### Using Cheerio ### Using Cheerio
Most sites are scraped by fetching the page HTML and parsing it with [Cheerio](https://cheerio.js.org/),
a jQuery-like API for traversing/selecting elements server-side.
```ts
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](#novelitem):
```ts
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;
}
```
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](#novelitempath).
- 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.
See the [Cheerio API docs](https://cheerio.js.org/docs/api) 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 ### 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 (proxying, headers, etc.):
```ts
import { fetchApi, fetchText, fetchProto } from '@libs/fetch';
```
#### fetchApi
```ts
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`.
```ts
const res = await fetchApi(this.resolveUrl(novelPath));
const body = await res.text();
```
#### fetchText
```ts
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).
#### fetchProto
```ts
declare function fetchProto(
protoInit: ProtoRequestInit,
url: string,
init?: FetchInit,
): Promise<unknown>;
```
For sites whose API responds with [Protocol Buffers](https://protobuf.dev/) instead of JSON/HTML.
```ts
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:
```ts
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](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)
(`headers`, `method`, `body`) — set headers like `Referer`/`Authorization`/`Cookie` under
`headers`, not as top-level keys.
---
### Other libraries
A few smaller helpers are available for less common cases. You generally won't need these unless
your target site requires them.
#### isUrlAbsolute
```ts
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](#novelitem)/[SourceNovel](#sourcenovel).
#### storage (localStorage / sessionStorage)
```ts
import { storage, localStorage, sessionStorage } from '@libs/storage';
```
`storage` is the same persistent key-value store used for [plugin settings](#pluginsettings) —
you can also use it directly for things like caching a session cookie or an auth token between
requests. `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). If
your plugin uses either of them, set [`webStorageUtilized`](#pluginbasewebstorageutilized) to
`true` on the plugin so the app knows to provide that access.
#### AES decryption
```ts
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.
```ts
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.
+11 -5
View File
@@ -1,5 +1,11 @@
1. Install Komga plugin; # Komga Plugin
2. In the installed plugins page press the cog icon to open the plugin settings;
3. Fill in the required information (email, password and your komga server url); [Komga](https://komga.org/) is a self-hosted media server. Unlike the other plugins, which scrape
4. Press save and restart the app; a fixed public site, the Komga plugin connects to *your own* server, so it needs a bit of one-time
5. The komga plugin will now work like the other plugins. setup after installing.
1. Install the Komga plugin.
2. On the installed plugins page, press the cog icon to open the plugin settings.
3. Fill in the required information (email, password, and your Komga server URL).
4. Press save and restart the app.
5. The Komga plugin will now work like the other plugins.
+5 -5
View File
@@ -19,7 +19,7 @@ class TemplatePlugin implements Plugin.PluginBase {
filters: Filters | undefined = undefined; filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required. // Flag indicating whether access to localStorage/sessionStorage is required.
webStorageUtilized?: boolean; webStorageUtilized?: boolean;
async popularNovels( async popularNovels(
@@ -45,8 +45,8 @@ class TemplatePlugin implements Plugin.PluginBase {
name: 'Untitled', name: 'Untitled',
}; };
// TODO: get here data from the site and // TODO: fetch the novel's data from the site, then
// un-comment and fill-in the relevant fields // un-comment and fill in the relevant fields below
// novel.name = ''; // novel.name = '';
// novel.artist = ''; // novel.artist = '';
@@ -58,9 +58,9 @@ class TemplatePlugin implements Plugin.PluginBase {
const chapters: Plugin.ChapterItem[] = []; const chapters: Plugin.ChapterItem[] = [];
// TODO: here parse the chapter list // TODO: parse the chapter list here
// TODO: add each chapter to the list using // TODO: add each chapter to `chapters`, e.g.:
const chapter: Plugin.ChapterItem = { const chapter: Plugin.ChapterItem = {
name: '', name: '',
path: '', path: '',
+43 -14
View File
@@ -1,32 +1,61 @@
# Quick start # Quick start
1. [Requirements](#requirements) 1. [Requirements](#requirements)
2. [Single plugin guide](#quick-guide) 2. [Single plugin guide](#single-plugin-guide)
3. [Multi-src guide](#creating-multi-src-plugins) 3. [Multi-source guide](#creating-multi-source-plugins)
4. [Testing your plugin](./testing.md) 4. [Testing your plugin](./testing.md)
### Requirements ### Requirements
- [git](https://git-scm.com/doc/ext) basics - [git](https://git-scm.com/doc/ext) basics
- Typescript or Javascript basics - TypeScript or JavaScript basics
- Node >=22 - Node.js >= 22
- Installing the dependencies with `npm i` - Install the dependencies with `npm i`
### Guide ### Single plugin guide
1. Create plugin script in `/plugins` [<span style="font-size: 0.8rem;">(learn more)</span>](#creating-plugin-script) 1. Create your plugin script in `/plugins` [<span style="font-size: 0.8rem;">(learn more)</span>](#creating-plugin-script)
2. Copy code from [plugin-template.ts](./plugin-template.ts) 2. Copy the code from [plugin-template.ts](./plugin-template.ts)
3. Start coding [<span style="font-size:0.8rem">(documentation)</span>](./docs.md) 3. Start coding [<span style="font-size:0.8rem">(documentation)</span>](./docs.md)
4. Run `npm run check:plugin -- plugins/<lang>/yourPlugin.ts` before opening a PR — see [Testing your plugin](./testing.md) 4. Run `npm run check:plugin -- plugins/<lang>/yourPlugin.ts` before opening a PR — see [Testing your plugin](./testing.md)
#### Creating plugin script #### Creating plugin script
1. Remember to create your plugin inside the language folder corresponding to the language of the novels 1. Remember to create your plugin inside the language folder corresponding to the language of the novels.
2. File should have the `.ts` extension These folders are spelled out in full, e.g. `plugins/english/`, `plugins/portuguese/` (see the
Example `plugins/english/nobleMTL.ts` existing folders under `plugins/` for the full list).
3. Add an icon to `public/static/src/<lang>/<plugin-name>/icon.png` 2. The file should have the `.ts` extension.
Example: `plugins/english/nobleMTL.ts`
3. Add a 96x96px icon at `public/static/src/<lang>/<plugin-name>/icon.png`, then reference it from
your plugin as `icon = 'src/<lang>/<plugin-name>/icon.png'` (without the `public/static` prefix
— see [PluginBase::icon](./docs.md#pluginbaseicon)).
> [!WARNING] > [!WARNING]
> Icon size should be 96x96px! > The `<lang>` folder here uses the **short** language code (`en`, `pt-br`, `fr`, ...), which is
> different from the full language name used for the `plugins/<lang>/` folder in step 1. Check
> the existing folders under `public/static/src/` for the codes already in use.
### Creating multi-source plugins ### Creating multi-source plugins
Some sites run on the same off-the-shelf CMS/theme (WordPress themes, Madara, etc.), so instead of
writing a near-identical plugin by hand for each one, this repo generates them from a shared
template. That system lives in `plugins/multisrc/`, where each subfolder is one **generator** —
for example `plugins/multisrc/lightnovelwp/` covers sites using the LightNovel WordPress theme, and
`plugins/multisrc/madara/` covers sites using the Madara theme.
**Adding a new source to an existing generator** (the common case — check `plugins/multisrc/` first
to see if a generator already matches your target site's CMS):
1. Open the generator's folder, e.g. `plugins/multisrc/lightnovelwp/`, and add an entry for your
site to its `sources.json`.
2. Run `npm run build:multisrc` to materialize the actual plugin file(s) into
`plugins/<lang>/<name>[<generator>].ts`.
3. Follow the generator's own `README.md` for anything specific to it — icon handling, available
filters, and `sources.json` fields differ between generators (compare
`plugins/multisrc/lightnovelwp/README.md` and `plugins/multisrc/madara/README.md` for examples).
4. [Test your plugin](./testing.md) the same way you would a single-source one.
**Adding a new generator** (only if no existing generator's CMS matches your target site) is a
larger undertaking — read an existing generator's `generator.js` and `template.ts` first to see the
shape expected by `plugins/multisrc/generate-multisrc-plugins.js`, which drives all generators via
`npm run build:multisrc`.
+7
View File
@@ -42,3 +42,10 @@ block a merge.
You can also trigger it manually against any plugin path from the Actions tab You can also trigger it manually against any plugin path from the Actions tab
(`Plugin Live Check` → `Run workflow`), which is useful for re-checking an existing plugin after (`Plugin Live Check` → `Run workflow`), which is useful for re-checking an existing plugin after
its target site changes layout. its target site changes layout.
## See also
`check:plugin` catches wrong/missing data automatically, but it's not a substitute for actually
looking at the output. See the [website tutorial](./website-tutorial.md) for testing your plugin
interactively in the browser — useful for spot-checking filters, pagination, and chapter
formatting by eye before opening a PR.
+12 -1
View File
@@ -28,11 +28,22 @@ The testing website provides five main sections to test different plugin functio
## Pre-Submission Testing ## Pre-Submission Testing
Before submitting your plugin, verify that all five sections work without errors, multiple pages load, search returns accurate results, novel parsing extracts all metadata, chapter content is clean, filters work (if implemented), no console errors appear, paths are properly formatted, and images load correctly. Before submitting your plugin, verify that:
- All five sections work without errors
- Multiple pages load correctly
- Search returns accurate results
- Novel parsing extracts all metadata
- Chapter content is clean
- Filters work (if implemented)
- No console errors appear
- Paths are properly formatted
- Images load correctly
## Need Help? ## Need Help?
- **Plugin Development:** See [docs.md](./docs.md) for API reference - **Plugin Development:** See [docs.md](./docs.md) for API reference
- **Quick Start:** See [quickstart.md](./quickstart.md) for plugin creation - **Quick Start:** See [quickstart.md](./quickstart.md) for plugin creation
- **Pre-PR Check:** See [testing.md](./testing.md) for the required `npm run check:plugin` live check
- **Issues:** Create a [GitHub issue](https://github.com/LNReader/lnreader-plugins/issues/new) - **Issues:** Create a [GitHub issue](https://github.com/LNReader/lnreader-plugins/issues/new)
- **Community:** Join us on [Discord](https://discord.gg/QdcWN4MD63) - **Community:** Join us on [Discord](https://discord.gg/QdcWN4MD63)