From 0188e11e57beb5bd9283bea64ff0067a132a2b76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 05:12:28 +0000 Subject: [PATCH] docs: Fix Documentation Accuracy, Dead Links, and Grammar in Docs - Correct API docs against actual src/types/plugin.ts and libs/*: fix FilterToValues type name, add missing PluginBase fields (customJS, customCSS, webStorageUtilized, resolveUrl), add missing SourceNovel.rating and ChapterItem.scanlator fields, fix ExcludableCheckboxGroupValue's include/exclude keys, document the NovelStatus enum - Fill in the previously-empty "Using Cheerio" and "Custom fetching functions" sections, and add a new "Other libraries" section (isUrlAbsolute, storage variants, AES helpers) - Write real content for quickstart.md's "Creating multi-source plugins" section instead of a bare heading, and clarify the lang-folder-name mismatch between plugins/ and public/static/src/ - Fix broken/empty markdown links, mismatched TOC anchors, a duplicate anchor id, and a malformed GitHub alert callout - Normalize all code examples to the repo's own Prettier style (2-space, single-quote) - Fix typos, grammar, and copy-paste errors (Desciption, it's/its, duplicated parseChapter description, SesesionStorage, run-on pre-submission checklist) - Cross-link testing.md and website-tutorial.md; add missing testing.md entry to README's docs index; add a title to komga-plugin.md Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K19WYNGJcSdB5331FvEDEH --- README.md | 3 +- docs/docs.md | 686 +++++++++++++++++++++++++++------------ docs/komga-plugin.md | 16 +- docs/plugin-template.ts | 10 +- docs/quickstart.md | 57 +++- docs/testing.md | 7 + docs/website-tutorial.md | 13 +- 7 files changed, 561 insertions(+), 231 deletions(-) diff --git a/README.md b/README.md index 02e3116fd..6f24901dc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/docs.md b/docs/docs.md index e21efff0b..52c16d2e6 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -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/` 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 - ): Promise { - 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, + ): Promise { + 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 -- `showLatestNovels: boolean` flag set when opened with `Latest` button +- `showLatestNovels: boolean` flag set when opened with the `Latest` button -- `filters: FilterValues` object containing all selected filter values. [More about Filters](#filters) +- `filters: FilterToValues` 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 @@ -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 { - 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 { + 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 @@ -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{ - return "

No chapter here

"; - } - ... + ... + async parseChapter(chapterPath: string): Promise { + return '

No chapter here

'; + } + ... } ``` #### 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 @@ -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 { - let novels: Plugin.NovelItem[] = []; - return novels; - } - ... + ... + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + 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 | | - - chapters?: ChapterItem[]; +`SourceNovel` extends [NovelItem](#novelitem), so `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](#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: {} + order: {}, } 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 { - // 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 { + // 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 { - 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 { + 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 { + 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; +``` + +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; +``` + +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; +``` + +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 | Headers; + method?: string; + body?: FormData | string; + [key: string]: string | Record | 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. diff --git a/docs/komga-plugin.md b/docs/komga-plugin.md index 360bc8a00..302924810 100644 --- a/docs/komga-plugin.md +++ b/docs/komga-plugin.md @@ -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. \ No newline at end of file +# 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. diff --git a/docs/plugin-template.ts b/docs/plugin-template.ts index fa34cff9b..974e38dbd 100644 --- a/docs/plugin-template.ts +++ b/docs/plugin-template.ts @@ -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: '', diff --git a/docs/quickstart.md b/docs/quickstart.md index 7a871aaf9..c7e2cf29b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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` [(learn more)](#creating-plugin-script) -2. Copy code from [plugin-template.ts](./plugin-template.ts) +1. Create your plugin script in `/plugins` [(learn more)](#creating-plugin-script) +2. Copy the code from [plugin-template.ts](./plugin-template.ts) 3. Start coding [(documentation)](./docs.md) 4. Run `npm run check:plugin -- plugins//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///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///icon.png`, then reference it from + your plugin as `icon = 'src///icon.png'` (without the `public/static` prefix + — see [PluginBase::icon](./docs.md#pluginbaseicon)). -> [!WARNING] -> Icon size should be 96x96px! + > [!WARNING] + > The `` folder here uses the **short** language code (`en`, `pt-br`, `fr`, ...), which is + > different from the full language name used for the `plugins//` 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//[].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`. diff --git a/docs/testing.md b/docs/testing.md index cc9d4d87b..fcac60c38 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/docs/website-tutorial.md b/docs/website-tutorial.md index e0194ad83..f3750f91d 100644 --- a/docs/website-tutorial.md +++ b/docs/website-tutorial.md @@ -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)