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
**Prerequisites:** Node.js >= 22
**Prerequisites:** Node.js >= 22
```bash
npm install
@@ -22,6 +22,7 @@ npm run dev:start
- **[Quick Start Guide](./docs/quickstart.md)** - Create your first plugin
- **[Plugin Development](./docs/docs.md)** - Complete API reference
- **[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
## Testing Methods
+480 -204
View File
@@ -6,8 +6,10 @@
- [ChapterItem](#chapteritem)
- [Filters](#filters)
- [PluginSettings](#pluginsettings)
- [NovelStatus](#novelstatus)
- [Using Cheerio](#using-cheerio)
- [Custom fetching functions](#custom-fetching-functions)
- [Other libraries](#other-libraries)
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 {}
```
| Field | Required | Description |
| -------------------------------------------------------------- | -------- | ----------------------------------------------------- |
| [id](#pluginbaseid) | yes | Plugin ID |
| [name](#pluginbasename) | yes | Plugin Name |
| [icon](#pluginbasename) | yes | Plugin Icon |
| [site](#pluginbasesite) | yes | Plugin site link |
| [version](#pluginbaseversion) | yes | Plugin version |
| [imageRequestInit](#pluginbaseimagerequestinit) | no | Plugin Image Request Init |
| [filters](#pluginbasefilters) | no | [Filter definition](#filter-definition-object) object |
| [pluginSettings](#pluginbasepluginsettings) | no | [Plugin settings](#pluginsettings) object |
| [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 |
| Field | Required | Description |
| ----------------------------------------------------------- | -------- | ------------------------------------------------------- |
| [id](#pluginbaseid) | yes | Plugin ID |
| [name](#pluginbasename) | yes | Plugin Name |
| [icon](#pluginbaseicon) | yes | Plugin Icon |
| [site](#pluginbasesite) | yes | Plugin site link |
| [version](#pluginbaseversion) | yes | Plugin version |
| [imageRequestInit](#pluginbaseimagerequestinit) | no | Plugin Image Request Init |
| [filters](#pluginbasefilters) | no | [Filter definition](#filter-definition-object) object |
| [pluginSettings](#pluginbasepluginsettings) | no | [Plugin settings](#pluginsettings) object |
| [webStorageUtilized](#pluginbasewebstorageutilized) | no | Flag for plugins that need `localStorage`/`sessionStorage` |
| [customJS](#pluginbasecustomjs) | no | Path to a custom JS file bundled with the plugin |
| [customCSS](#pluginbasecustomcss) | no | Path to a custom CSS file bundled with the plugin |
| [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
@@ -44,9 +50,9 @@ Unique ID of your plugin
```ts
class ExamplePlugin implements Plugin.PluginBase {
...
id = "templateID";
...
...
id = 'templateID';
...
}
```
@@ -56,21 +62,23 @@ The name of your plugin that is shown in-app
```ts
class ExamplePlugin implements Plugin.PluginBase {
...
name = "template Plugin";
...
...
name = 'template Plugin';
...
}
```
#### 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
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
class ExamplePlugin implements Plugin.PluginBase {
...
site = "https://example.com";
...
...
site = 'https://example.com';
...
}
```
@@ -105,9 +113,9 @@ Where
```ts
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
class ExamplePlugin implements Plugin.PluginBase {
...
imageRequestInit: Plugin.ImageRequestInit = {
headers: {
Referer: 'https://example.com',
},
};
...
...
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](#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
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
```ts
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: "",
}
}
...
...
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;
...
}
```
@@ -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)
###### Example:
###### Example
```ts
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.example.value === "test"){
novels.push({
name: "Novel1",
path: "/novel1",
cover:defaultCover
})
}
return novels;
...
async popularNovels(
page: number,
options: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
if (options.filters.example.value === 'test') {
novels.push({
name: 'Novel1',
path: '/novel1',
cover: defaultCover,
});
}
return novels;
}
}
```
@@ -214,13 +264,13 @@ class ExamplePlugin implements Plugin.PluginBase {
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
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
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
> [!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
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: ""
};
let chapters: Plugin.ChapterItem[] = [];
const chapter: Plugin.ChapterItem = {
name: "",
path: "",
releaseTime: "",
chapterNumber: 0,
};
chapters.push(chapter);
novel.chapters = chapters;
return novel;
}
...
...
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: '',
};
const chapters: Plugin.ChapterItem[] = [];
const chapter: Plugin.ChapterItem = {
name: '',
path: '',
releaseTime: '',
chapterNumber: 0,
};
chapters.push(chapter);
novel.chapters = chapters;
return novel;
}
...
}
```
#### 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
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
###### Example:
###### Example
```ts
class ExamplePlugin implements Plugin.PluginBase {
...
async parseChapter(chapterPath: string): Promise<string>{
return "<h1>No chapter here</h1>";
}
...
...
async parseChapter(chapterPath: string): Promise<string> {
return '<h1>No chapter here</h1>';
}
...
}
```
#### PluginBase::searchNovels
Function that is used to find Novels in the source
Function that is used to find novels in the source
```ts
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
- `searchTerm` the search term
- `page` search page number
- `pageNo` search page number
###### Returns
@@ -322,15 +373,35 @@ See [Using cheerio](#using-cheerio) for more information on how to parse HTML do
```ts
class ExamplePlugin implements Plugin.PluginBase {
...
async searchNovels(
searchTerm: string,
pageNo: number
): Promise<Plugin.NovelItem[]> {
let novels: Plugin.NovelItem[] = [];
return novels;
}
...
...
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.
```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
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 |
| -------------------------------- | -------- | -------- | ------------------------------------------ |
@@ -358,30 +429,34 @@ import { defaultCover } from '@libs/defaultCover';
### SourceNovel
| Field | Type | Required | Desciption |
| ------- | ------------------------- | -------- | ---------- |
| 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 | |
`SourceNovel` extends [NovelItem](#novelitem), so `path`, `name`, and `cover` behave the same way
here as they do there.
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
| Field | Type | Required | Description |
| ------------- | -------- | -------- | ------------------------------- |
| name | string | yes | |
| path | string | yes | |
| releaseTime | string | no | release time in `YYYY-MM-DD` |
| chapterNumber | number | no | |
| page | string | no | for multi-page chapter lists |
| 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 `SourcePage`/`PagePlugin`) |
| scanlator | `string` or `string[]` | no | Name(s) of the scanlation/translation group(s) |
### Filters
@@ -403,10 +478,10 @@ Every property of this object is a different filter. The key of the object is th
```ts
filters = {
order: {<FilterProperties>}
order: {<FilterProperties>},
} satisfies Filters;
// accessible in popularNovels as
options.filters.order
options.filters.order;
```
> [!CAUTION]
@@ -414,7 +489,7 @@ options.filters.order
##### FilterProperties
| Name | Type | Required | Desciption |
| Name | Type | Required | Description |
| ------- | ---------------------------- | ------------- | ------------------------------------------------------------------ |
| label | `string` | yes | in-app label |
| 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 |
| `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 |
| `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
@@ -481,7 +556,7 @@ options: [
#### 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
```ts
@@ -509,8 +584,8 @@ options.filters.abc.type; // type of the filter
```ts
{
included: string[], // options with selected selected
excluded: string[] // options with excluded selected
include?: string[]; // values of the checkboxes marked as included
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
pluginSettings = {
settingKey: {
value: '',
label: 'Setting Label',
type: 'Text', // optional, defaults to 'Text'
},
settingKey: {
value: '',
label: 'Setting Label',
type: 'Text', // optional, defaults to 'Text'
},
};
```
@@ -574,25 +649,25 @@ storage.set('settingKey', 'newValue');
```ts
class ExamplePlugin implements Plugin.PluginBase {
...
hideLocked = storage.get('hideLocked');
pluginSettings = {
hideLocked: {
value: '',
label: 'Hide locked chapters',
type: 'Switch',
},
};
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
// Use the setting value
if (this.hideLocked) {
// Filter out locked chapters
}
...
...
hideLocked = storage.get('hideLocked');
pluginSettings = {
hideLocked: {
value: '',
label: 'Hide locked chapters',
type: 'Switch',
},
};
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
// Use the setting value
if (this.hideLocked) {
// Filter out locked chapters
}
...
}
...
}
```
@@ -600,43 +675,244 @@ class ExamplePlugin implements Plugin.PluginBase {
```ts
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 ${this.btoa(this.email + ':' + this.password)}`,
},
Referer: this.site,
}).then(res => res.text());
}
...
...
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());
}
...
}
```
---
### 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
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
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;
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);
4. Press save and restart the app;
5. The komga plugin will now work like the other plugins.
# Komga Plugin
[Komga](https://komga.org/) is a self-hosted media server. Unlike the other plugins, which scrape
a fixed public site, the Komga plugin connects to *your own* server, so it needs a bit of one-time
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;
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;
async popularNovels(
@@ -45,8 +45,8 @@ class TemplatePlugin implements Plugin.PluginBase {
name: 'Untitled',
};
// TODO: get here data from the site and
// un-comment and fill-in the relevant fields
// TODO: fetch the novel's data from the site, then
// un-comment and fill in the relevant fields below
// novel.name = '';
// novel.artist = '';
@@ -58,9 +58,9 @@ class TemplatePlugin implements Plugin.PluginBase {
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 = {
name: '',
path: '',
+43 -14
View File
@@ -1,32 +1,61 @@
# Quick start
1. [Requirements](#requirements)
2. [Single plugin guide](#quick-guide)
3. [Multi-src guide](#creating-multi-src-plugins)
2. [Single plugin guide](#single-plugin-guide)
3. [Multi-source guide](#creating-multi-source-plugins)
4. [Testing your plugin](./testing.md)
### Requirements
- [git](https://git-scm.com/doc/ext) basics
- Typescript or Javascript basics
- Node >=22
- Installing the dependencies with `npm i`
- TypeScript or JavaScript basics
- Node.js >= 22
- 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)
2. Copy code from [plugin-template.ts](./plugin-template.ts)
1. Create your plugin script in `/plugins` [<span style="font-size: 0.8rem;">(learn more)</span>](#creating-plugin-script)
2. Copy the code from [plugin-template.ts](./plugin-template.ts)
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)
#### Creating plugin script
1. Remember to create your plugin inside the language folder corresponding to the language of the novels
2. File should have the `.ts` extension
Example `plugins/english/nobleMTL.ts`
3. Add an icon to `public/static/src/<lang>/<plugin-name>/icon.png`
1. Remember to create your plugin inside the language folder corresponding to the language of the novels.
These folders are spelled out in full, e.g. `plugins/english/`, `plugins/portuguese/` (see the
existing folders under `plugins/` for the full list).
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]
> Icon size should be 96x96px!
> [!WARNING]
> 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
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
(`Plugin Live Check` → `Run workflow`), which is useful for re-checking an existing plugin after
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
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?
- **Plugin Development:** See [docs.md](./docs.md) for API reference
- **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)
- **Community:** Join us on [Discord](https://discord.gg/QdcWN4MD63)