Skip to content

Building sources ​

A source is a small TypeScript project that Mana loads to browse, search and read from one website. Types come from @mana-app/types; sources are built with mana-dev. Start with Getting started.

How Mana talks to a source ​

Mana creates one instance of your Target class and calls its methods when the user does something. Every method is async and returns plain JSON-compatible objects.

How Mana talks to a source
Mana calls async methods on your Target class · your class fetches through HttpClient

Features are opt-in ​

Mana only offers a feature when your class has the methods behind it. The build detects them, so rebuild after adding or removing a method. Some features need a pair of methods. A few methods are not called by Mana yet; see API status.

FeatureMethods
Title pages (required)getContent, getChapterData, plus getChapters unless Content.chapters is filled
Searchsearch; optional getSearchForm, getSortOptions, validateSearchForm
Custom home pagegetSectionsForPage and resolvePageSection
Library linksgetLibraryPageLinks
SettingsgetPreferenceMenu
First-run setupgetSetupMenu and validateSetupForm and isSourceSetup
Sign-ingetAuthenticatedUser and handleUserSignOut, plus one sign-in style
Chapter eventsonChaptersMarked and onChapterRead; optional onPageRead
Library eventsonContentsAddedToLibrary and onContentsRemovedFromLibrary
Progress syncgetProgressState
Deep linkshandleURL with config.owningLinks
Custom image loadinggetCoverTransport, getPageTransport
Tile descramblingshouldRedrawImage and redrawImageWithSize
TrackergetContent, search, didUpdateLastReadChapter, didUpdateStatus, getEntryForm, didSubmitEntryForm (see Trackers)

Smallest useful source ​

ts
import { load } from "cheerio";
import {
  HttpClient,
  RateLimit,
  type Chapter,
  type ChapterData,
  type ChapterSource,
  type Content,
  type PagedSearchResult,
  type SearchRequest,
  type SourceInfo,
} from "@mana-app/types";

const BASE = "https://example.org";

export class Target implements ChapterSource {
  readonly info: SourceInfo = {
    id: "example",
    name: "Example",
    version: "1.0.0",
    website: BASE,
    supportedLanguages: ["en_US"],
  };

  private readonly http = new HttpClient()
    .setBaseURL(BASE)
    .setRateLimit(3, 1_000);

  async search(request: SearchRequest): Promise<PagedSearchResult> {
    const $ = load(await (await this.http.get("/search", {
      params: { q: request.query ?? "", page: request.page },
    })).requireOk().text());

    const results = $(".item").map((_, el) => ({
      id: $(el).attr("data-id")!,
      title: $(el).find(".title").text().trim(),
      cover: $(el).find("img").attr("src")!,
    })).get();

    return { results, isLastPage: $(".next").length === 0 };
  }

  async getContent(contentId: string): Promise<Content> {
    const $ = load(await (await this.http.get(`/series/${contentId}`)).requireOk().text());
    return {
      title: $("h1").text().trim(),
      cover: $(".cover img").attr("src")!,
      summary: $(".summary").text().trim(),
      webUrl: `${BASE}/series/${contentId}`,
    };
  }

  async getChapters(contentId: string): Promise<Chapter[]> {
    const $ = load(await (await this.http.get(`/series/${contentId}`)).requireOk().text());
    const rows = $(".chapter").get();
    return rows.map((el, i) => ({
      chapterId: $(el).attr("data-id")!,
      number: Number($(el).attr("data-number")),
      index: rows.length - 1 - i,           // newest first on the page, oldest = 0
      date: new Date($(el).find("time").attr("datetime")!),
      language: "en_US",
    }));
  }

  async getChapterData(contentId: string, chapterId: string): Promise<ChapterData> {
    const $ = load(await (await this.http.get(`/read/${chapterId}`)).requireOk().text());
    return { pages: $(".page img").map((_, img) => ({ url: $(img).attr("src")! })).get() };
  }
}