Writing an extension
The extension SDK has the template, the types and the lunaris-ext tool. Download lunaris-ext for your system from its Releases page. It has no browser, so render and Cloudflare checks only work in the app.
An extension is one JavaScript file that teaches Lunaris to read one site: how to search it, list a title’s chapters, and list a chapter’s pages. Lunaris downloads the file from a repo and runs it in a sandbox, so there is nothing to compile or sign.
Quick start
Section titled “Quick start”-
Copy
template.js,lunaris.d.tsandjsconfig.jsoninto a folder of your own, and name the script after its id:my-site.js. -
Fill in
infoand the CSS selectors for your site. -
Try each function with
lunaris-ext:Terminal window lunaris-ext run my-site.js search '"one piece"' 1lunaris-ext run my-site.js chapters '"https://my-site.com/title/123"'lunaris-ext run my-site.js pages '"https://my-site.com/chapter/456"'It prints what the function returns as JSON, or the error with its line number. Arguments are JSON, so wrap strings in double quotes inside single quotes.
-
Publish it (see Publishing a repo).
For editor checks (autocomplete and type errors), keep /// <reference path="./lunaris.d.ts" />
at the top of the script. The jsconfig.json next to it switches type checking on in
VS Code; bunx tsc -p jsconfig.json runs the same check from the terminal.
The shape of an extension
Section titled “The shape of an extension”A script calls extension() exactly once:
/// <reference path="./lunaris.d.ts" />const BASE = "https://my-site.com";
extension({ info: { id: "my-site", // lowercase letters, digits, dashes; never change it name: "My Site", lang: "en", // or "ja", …, "multi" kinds: ["manga"], // any of manga, manhwa, manhua, novel version: "1.0.0", // raise on every release api: 1, // the host API you wrote it for content: "sfw", // "sfw", "mixed" (some adult titles) or "nsfw" (mostly adult) site: "https://my-site.com", // optional: its icon becomes the extension's icon },
search(query, page) { /* return [{ title, url, cover? }] */ }, chapters(url) { /* return [{ title, url }], newest first */ }, pages(url) { /* return ["https://…/1.jpg", …] in reading order */ },});| Function | Gets | Returns |
|---|---|---|
search(query, page) |
the user’s text; page starts at 1 |
titles: { title, url, cover? }; [] when no more |
chapters(url) |
a title’s url from search |
chapters: { title, url }, newest first |
pages(url) |
a chapter’s url from chapters |
image URLs, in reading order |
details(url) |
a title’s url from search |
optional; see below |
text(url) |
a chapter’s url from chapters |
novels only: the chapter as HTML |
Search results may also carry kind and author. details(url) is optional: Lunaris
calls it when a title is first opened and when the user refreshes it, never during
background update checks. It returns any of description (plain text, paragraphs split by
"\n"), tags, status (lowercase, such as "ongoing"), author, artist, altTitles,
rating (out of 10) and bookmarks (followers on the source).
Extensions with "novel" in info.kinds implement text(url) instead of returning pages
(pages can return []). Return the chapter’s HTML as the site has it: Lunaris keeps only
basic text tags (paragraphs, line breaks, emphasis, headings, lists, quotes) and drops
every attribute, image, link and script before showing or saving it.
Return absolute URLs. Lunaris passes the url you returned straight back to the next
function, and loads images and covers from the URLs as given.
What a script can use
Section titled “What a script can use”Only these, on top of plain JavaScript (ES2022: arrow functions, template strings,
destructuring, ?., ??, JSON, RegExp, …):
-
fetch(url, { method, headers, body })returns{ status, text }. It blocks until the response arrives, so there is noawaitand no.then. It throws on network errors but not on error statuses: checkstatusyourself. -
html(text)parses HTML and returns a node:select(css): the descendants that match, as an array of nodestext(): the text content, trimmedattr(name): an attribute, or""when it’s missinghtml(): the inner HTMLquerySelector(css),querySelectorAll(css)andgetAttribute(name): the DOM’s names for the same thing, returningnullwhen nothing matches, so code written for node-html-parser ports over (API 2)
-
render(url, { waitFor })loads the page in a hidden browser window, runs the site’s own JavaScript, and returns the resulting HTML as a string forhtml()(API 2).waitForis a CSS selector to wait for when the site adds content after the page loads. It blocks likefetch, takes seconds rather than milliseconds, and throws if the page isn’t ready within 30 seconds. It only works in the app, not inlunaris-ext.With
script, a JavaScript expression,renderruns it inside the loaded page (awaiting a promise) and returns its result instead of the HTML: a string as is, anything else as JSON. Use it when the content needs a click, or only arrives through the site’s own API calls: the JSON responses the page gets (from any host) are inwindow.__lunaris.responses({ url, text }), andlunaris:responsefires onwindowfor each. The script runs in the site’s page, not your extension, so pass any helper in as source:render(url, { script: `(${fn})(${JSON.stringify(arg)})` }). Timers fire only about once a second in the hidden window, so wait on that event or aMutationObserverrather than polling.With
verify: true, the page may need the user: a “verify you’re human” box, or a site that hands out a token only after one. If the page or the script hasn’t finished after 10 seconds, Lunaris shows the window, titled “Verify to continue:”, until renderreturns; closing it cancels. Waiting for the page then gets up to two minutes, but the whole call still stops at one minute. Have the script wait without a timeout of its own while the user acts.
There is no DOM (document, window), no require or import, no file system and no
timers. Sites whose content is built by their own JavaScript usually have a JSON API behind
it: open the browser’s network tab, find that request, and JSON.parse(fetch(api).text).
Reach for render only when there’s no such API.
- One call at a time. Lunaris runs one function of your extension at a time, and each call is stopped after one minute.
- Throw on failure.
throw new Error("…")with a short reason. Lunaris shows the message. Don’t return made-up data. - Be gentle. Make as few requests as a function needs. Don’t loop through every page
of results in one call; that’s what the
pageargument is for. - Never get past a human check for the user. Use
renderwithverify: trueand let the user pass it. Whatever the site saves afterwards (cookies,localStorage, a token) stays in the window for laterrendercalls and survives restarts, so let the site keep it rather than copying it into your script; the check then comes back only when the site asks again. - Don’t pretend to be the site’s own pages. If its API only answers requests from the
site (an
OriginorReferercheck), read the page’s answers withrenderinstead of forging those headers. Page images are handled for you: when an image server refuses Lunaris, Lunaris asks again from the hidden window, from a page on the chapter’s site, and keeps doing so for that server. - Cloudflare is handled for you. When a site shows a Cloudflare check, Lunaris solves it
in its own browser window (asking the user if it must) and retries your request. Don’t set
User-AgentorCookie: cf_clearanceyourself. - Prefer official APIs when a site has one (MangaDex has). They’re faster, and they don’t break when the site’s layout changes.
- Respect the site’s terms. If they forbid scraping, say so in your repo’s README.
Publishing a repo
Section titled “Publishing a repo”A repo is any static file host (GitHub Pages, a raw GitHub folder, your own server) with a
folder per site, each script named after its extension’s id, and an index.json at the top:
my-repo/ index.json my-site/ my-site.js my-site-novels.js other-site/ other-site.jslunaris-ext index ./my-repoThis checks every extension, refuses a script not named after its extension’s id, then
writes index.json with each one’s info, path and SHA-256 checksum. (A small repo can also
keep its scripts loose next to index.json.) Run it again after every change; Lunaris refuses a
script whose checksum, id or version doesn’t match the index. Users add the URL of
index.json in Lunaris to see your extensions.
Updating an extension
Section titled “Updating an extension”Raise version, rebuild index.json, and upload both. Lunaris offers the update when the
index lists a version different from the installed one. Keep id the same forever.
Keeping your source private
Section titled “Keeping your source private”Lunaris only ever sees the script you publish, so your source can live in a private repo in any shape: TypeScript, many files, a test suite. Publish one bundled, minified script:
bun build src/index.ts --minify --format=iife --outfile=my-repo/my-site/my-site.jslunaris-ext index ./my-repo--format=iife, so the output has noimportorexport.- Bundled packages must fit the sandbox. Anything that needs Node or the browser
(
fs,window, timers, a Promise-basedfetch) won’t run; use the host’sfetchandhtml. - Functions sent to
rendermust stand alone. The minifier renames and moves your helpers, so pass the helpers a page script needs as arguments, as described underrenderabove.
Minifying makes a script hard to read, not secret: whoever installs it can still read the code that runs.
Host API versions
Section titled “Host API versions”info.api is the host API your script was written for. Lunaris refuses scripts that need a
newer API than it has, so users get “update Lunaris” instead of a broken extension. The
current API is 2 (it added render and the DOM names on nodes); set api: 2 only if
you use them, so older versions of Lunaris can still run your script. New functions will
come with a new API number; this guide and lunaris.d.ts always describe the newest.