diff --git a/plugins/english/sylthorian.ts b/plugins/english/sylthorian.ts
new file mode 100644
index 000000000..1bd4163a0
--- /dev/null
+++ b/plugins/english/sylthorian.ts
@@ -0,0 +1,179 @@
+import { load as parseHTML } from 'cheerio';
+import { fetchApi, FetchInit } from '@libs/fetch';
+import { Plugin } from '@/types/plugin';
+import { defaultCover } from '@libs/defaultCover';
+
+/**
+ * The Sylthorian (thesylthorian.com) — English LNReader plugin.
+ *
+ * thesylthorian.com is the author's own Squarespace storefront. The free web
+ * chapters are readable in full; some of the same books are also sold as
+ * e-books/paperbacks under `/published-books-portofolio/`, which this plugin
+ * intentionally ignores.
+ *
+ * The site is a hand-built Squarespace 7.1 site, so each section is authored
+ * HTML rather than a CMS collection. The shape the plugin relies on:
+ *
+ * - Listing: `/web-novels` — one `
` title immediately followed by a
+ * "Read it!" button linking to `/web-novels-1/`. All 10
+ * web novels fit on this single page; no covers are exposed.
+ * - Novel page: `/web-novels-1/` — title in the first ``, cover in
+ * `og:image`, a "Synopsis:" text block, and the full chapter
+ * list as `/latest-chapters/-N` links (ascending, no
+ * pagination — up to ~189 chapters for the largest novel).
+ * - Chapter page: `/latest-chapters/-N` — body HTML inside
+ * `.blog-item-content.e-content .sqs-html-content`.
+ * - Search: the site's own `/search?q=` page is JS-driven and mostly
+ * returns chapter hits, burying novel pages behind them, so
+ * the plugin filters the small `/web-novels` catalogue by
+ * title instead — deterministic and complete for 10 novels.
+ */
+class Sylthorian implements Plugin.PluginBase {
+ id = 'sylthorian';
+ name = 'The Sylthorian';
+ version = '1.0.0';
+ icon = 'src/en/sylthorian/icon.png';
+ site = 'https://thesylthorian.com';
+
+ // Throw (carrying the HTTP status) on a refused response so a runner-side
+ // block is reported INCONCLUSIVE per docs/testing.md instead of being
+ // parsed into a false empty-result FAIL.
+ private async fetchSite(url: string, init?: FetchInit) {
+ const res = await fetchApi(url, init);
+ if (!res.ok) {
+ throw Object.assign(new Error('Request failed: ' + res.status), {
+ status: res.status,
+ });
+ }
+ return res;
+ }
+
+ private fetchSiteText(url: string) {
+ return this.fetchSite(url).then(res => res.text());
+ }
+
+ /** Squarespace serves `og:image` over cleartext `http://`; upgrade it. */
+ private toHttps(url?: string) {
+ return url ? url.replace(/^http:\/\//, 'https://') : url;
+ }
+
+ /**
+ * The `/web-novels` page pairs each novel's `` title with the "Read it!"
+ * button that follows it, so walk the two in document order.
+ */
+ private parseNovelList(html: string): Plugin.NovelItem[] {
+ const $ = parseHTML(html);
+ const novels: Plugin.NovelItem[] = [];
+ const seen = new Set();
+ let lastTitle = '';
+
+ $('h3, a[href^="/web-novels-1/"]').each((_, el) => {
+ const node = $(el);
+ if (node.is('h3')) {
+ lastTitle = node.text().replace(/\s+/g, ' ').trim();
+ return;
+ }
+ const path = node.attr('href');
+ if (!path || seen.has(path)) return;
+ seen.add(path);
+ novels.push({
+ name: lastTitle || path.split('/').pop() || path,
+ path,
+ cover: defaultCover,
+ });
+ lastTitle = '';
+ });
+
+ return novels;
+ }
+
+ private parseSummary($: ReturnType): string | undefined {
+ let summary: string | undefined;
+ $('.sqs-html-content').each((_, el) => {
+ const text = $(el).text().replace(/\s+/g, ' ').trim();
+ if (/^synopsis\b/i.test(text)) {
+ summary = text.replace(/^synopsis\s*:?\s*/i, '').trim();
+ }
+ });
+ return summary;
+ }
+
+ private parseChapters(
+ $: ReturnType,
+ novelPath: string,
+ ): Plugin.ChapterItem[] {
+ // Chapter slugs reuse the novel slug (`/web-novels-1/` ->
+ // `/latest-chapters/-N`), so match on that prefix to avoid picking
+ // up unrelated chapter links from the page navigation.
+ const slug = novelPath.split('/').filter(Boolean).pop() ?? '';
+ const prefix = `/latest-chapters/${slug}-`;
+ const chapters: Plugin.ChapterItem[] = [];
+ const seen = new Set();
+
+ $(`a[href^="${prefix}"]`).each((_, el) => {
+ const node = $(el);
+ const path = node.attr('href');
+ const name = node.text().replace(/\s+/g, ' ').trim();
+ const number = path?.slice(prefix.length).match(/^(\d+)$/);
+ if (!path || !name || !number || seen.has(path)) return;
+ seen.add(path);
+
+ chapters.push({ name, path, chapterNumber: parseInt(number[1], 10) });
+ });
+
+ return chapters;
+ }
+
+ async popularNovels(pageNo: number): Promise {
+ // All web novels are listed on one page.
+ if (pageNo > 1) return [];
+ const html = await this.fetchSiteText(this.site + '/web-novels');
+ return this.parseNovelList(html);
+ }
+
+ async parseNovel(novelPath: string): Promise {
+ const html = await this.fetchSiteText(this.site + novelPath);
+ const $ = parseHTML(html);
+
+ const novel: Plugin.SourceNovel = {
+ path: novelPath,
+ name: $('h2').first().text().replace(/\s+/g, ' ').trim() || 'Untitled',
+ cover: this.toHttps($('meta[property="og:image"]').attr('content')),
+ summary: this.parseSummary($),
+ chapters: this.parseChapters($, novelPath),
+ };
+
+ return novel;
+ }
+
+ async parseChapter(chapterPath: string): Promise {
+ const html = await this.fetchSiteText(this.site + chapterPath);
+ const $ = parseHTML(html);
+
+ const node = $('.blog-item-content.e-content .sqs-html-content').first();
+ if (!node.length) {
+ // A page without the post body is a real failure, not empty content.
+ throw new Error('Chapter content not found');
+ }
+
+ node.find('script, style').remove();
+ return node.html() ?? '';
+ }
+
+ async searchNovels(
+ searchTerm: string,
+ pageNo: number,
+ ): Promise {
+ if (pageNo > 1) return [];
+ const novels = this.parseNovelList(
+ await this.fetchSiteText(this.site + '/web-novels'),
+ );
+ const term = searchTerm.trim().toLowerCase();
+ if (!term) return novels;
+ return novels.filter(novel => novel.name.toLowerCase().includes(term));
+ }
+
+ resolveUrl = (path: string) => this.site + path;
+}
+
+export default new Sylthorian();
diff --git a/public/static/src/en/sylthorian/icon.png b/public/static/src/en/sylthorian/icon.png
new file mode 100644
index 000000000..2564f0db7
Binary files /dev/null and b/public/static/src/en/sylthorian/icon.png differ