@aletheia-ios/tools 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/cli.js +979 -0
- package/package.json +51 -0
- package/template/filters.json +9 -0
- package/template/fixtures/catalogue.json +18 -0
- package/template/fixtures/smoke.json +1 -0
- package/template/icon.svg +1 -0
- package/template/package.json +9 -0
- package/template/source.json +27 -0
- package/template/src/index.ts +60 -0
- package/template/tsconfig.json +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aletheia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @aletheia-ios/tools
|
|
2
|
+
|
|
3
|
+
The `aletheia` command. Turns a repository of source packages into `.althsource` files and the
|
|
4
|
+
`index.json` a source list publishes. Runs on a Mac or in CI, never on a phone - the on-device
|
|
5
|
+
half is [`@aletheia-ios/sdk`](https://github.com/aletheia-ios/sdk).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add -D @aletheia-ios/tools
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
usage: aletheia <command> [options]
|
|
13
|
+
|
|
14
|
+
build [--only <slug>] bundle each package's src/index.ts to dist/build/<slug>/main.js
|
|
15
|
+
check [--only <slug>] typecheck, build, verify exports and run fixtures under JavaScriptCore
|
|
16
|
+
pack [--only <slug>] build, rasterise the icon and zip to dist/packages/<slug>-v<version>.althsource
|
|
17
|
+
index write dist/<target>/index.json for every list in lists/
|
|
18
|
+
serve [--port <n>] pack, index, serve dist/ on the lan and rebuild on change
|
|
19
|
+
new <slug> [--name <n>] scaffold packages/<slug> from the template
|
|
20
|
+
live <slug> <series|-> [query]
|
|
21
|
+
run a package against the live site in node and print what it returns
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## A repository
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
my-sources/
|
|
28
|
+
packages/
|
|
29
|
+
mangadex/ source.json filters.json icon.svg src/index.ts fixtures/smoke.json
|
|
30
|
+
...
|
|
31
|
+
lists/
|
|
32
|
+
main.json { "name": "My Sources", "target": "main", "sources": ["mangadex"] }
|
|
33
|
+
dist/ generated
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`aletheia` finds the nearest `packages/` above the working directory. Every package folder
|
|
37
|
+
name is its slug and must match `source.json`.
|
|
38
|
+
|
|
39
|
+
## What each command guarantees
|
|
40
|
+
|
|
41
|
+
**build** - one esbuild IIFE per package, ES2022, no Node or DOM assumptions, ending with the
|
|
42
|
+
source object on `globalThis.__source`. Fails if the bundle pulled in zod, which means a source
|
|
43
|
+
imported `@aletheia-ios/sdk/schemas`.
|
|
44
|
+
|
|
45
|
+
**check** - `tsc` per package when TypeScript is installed in the repository, then build, then
|
|
46
|
+
the bundle is evaluated in the `jsc` shell inside `JavaScriptCore.framework` - the engine the
|
|
47
|
+
phone runs, not Node. Verifies the four required exports, reports which optional ones exist,
|
|
48
|
+
and if `fixtures/smoke.json` is present runs search, details, chapters and content against
|
|
49
|
+
canned responses. A Web API the bundle assumed and the phone lacks fails here, not on a device.
|
|
50
|
+
|
|
51
|
+
**pack** - rasterises whichever of `icon.svg`, `icon.png`, `icon.jpg` is checked in (prefer
|
|
52
|
+
svg > png > jpg) to a 512x512 `icon.png`, then zips the five files with fixed timestamps. The
|
|
53
|
+
same content always produces the same bytes; the app treats one package published by two lists
|
|
54
|
+
as one package because of this.
|
|
55
|
+
|
|
56
|
+
**index** - for every `lists/*.json`, reads the packed files and writes
|
|
57
|
+
`dist/<target>/index.json` (validated against the sdk's `Index` schema, relative URLs, sha256,
|
|
58
|
+
size, the package folder's last commit date) plus `dist/<target>/manifest.json` naming the
|
|
59
|
+
package and icon files that target's deploy has to upload.
|
|
60
|
+
|
|
61
|
+
**serve** - runs pack and index, serves `dist/` on the LAN with `cache-control: no-store`, and
|
|
62
|
+
reruns both when anything under `packages/` or `lists/` changes. Point the app's developer list
|
|
63
|
+
at the printed URL.
|
|
64
|
+
|
|
65
|
+
**new** - copies the template: an offline source with `hosts: []` that passes `check` as-is,
|
|
66
|
+
so the first thing you see from a new package is green.
|
|
67
|
+
|
|
68
|
+
**live** - builds one package and runs it in Node with a real network behind `__host.fetch`,
|
|
69
|
+
honouring the manifest's `hosts` allowlist. For comparing a port against what the app shows
|
|
70
|
+
today. Never part of `check`.
|
|
71
|
+
|
|
72
|
+
## Fixtures
|
|
73
|
+
|
|
74
|
+
`fixtures/smoke.json` is an array of `{ "match": "<substring of a URL>", "body": <json> }`.
|
|
75
|
+
`check` answers each `__host.fetch` with the first fixture whose `match` appears in the URL,
|
|
76
|
+
and calls `search("smoke")`, `details("series")`, `chapters("series")`,
|
|
77
|
+
`content("series", "chapter")` in that order. Capture real responses from the site and trim
|
|
78
|
+
them; the point is that the mapping code runs over real shapes without a network.
|
|
79
|
+
|
|
80
|
+
MIT.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { cp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, extname, join, normalize, relative, resolve } from "node:path";
|
|
6
|
+
import { build } from "esbuild";
|
|
7
|
+
import { createReadStream, existsSync, mkdtempSync, watch, writeFileSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { AuthSpec, Filters, Index, SourceManifest } from "@aletheia-ios/sdk/schemas";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import sharp from "sharp";
|
|
12
|
+
import { networkInterfaces, tmpdir } from "node:os";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import vm from "node:vm";
|
|
16
|
+
import { zipSync } from "fflate";
|
|
17
|
+
import { createServer } from "node:http";
|
|
18
|
+
//#region src/lib/log.ts
|
|
19
|
+
/** Bytes per kilobyte as the size lines report it. */
|
|
20
|
+
const KB = 1024;
|
|
21
|
+
/**
|
|
22
|
+
* A failure the cli reports as plain lines and exits 1 on.
|
|
23
|
+
*
|
|
24
|
+
* Anything else that escapes is a bug in the cli and is printed with its stack. Commands
|
|
25
|
+
* collect every problem they can find into one `CliError` so a broken repository is fixed
|
|
26
|
+
* in one round trip rather than one file at a time.
|
|
27
|
+
*/
|
|
28
|
+
var CliError = class extends Error {
|
|
29
|
+
/** One problem per line, already prefixed with the file or package it belongs to. */
|
|
30
|
+
lines;
|
|
31
|
+
constructor(lines, options) {
|
|
32
|
+
super(lines.join("\n"), options);
|
|
33
|
+
this.name = "CliError";
|
|
34
|
+
this.lines = lines;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** Prints a progress line to stdout. */
|
|
38
|
+
function info(message) {
|
|
39
|
+
console.log(message);
|
|
40
|
+
}
|
|
41
|
+
/** Prints a non-fatal problem to stderr with a `warning:` prefix. */
|
|
42
|
+
function warn(message) {
|
|
43
|
+
console.warn(`warning: ${message}`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Prints an error to stderr: a `CliError` as its lines, anything else as-is with its stack.
|
|
47
|
+
*
|
|
48
|
+
* Shared by the process entry and by `serve`, which keeps running after a failed rebuild.
|
|
49
|
+
*/
|
|
50
|
+
function report(error) {
|
|
51
|
+
if (error instanceof CliError) for (const line of error.lines) console.error(line);
|
|
52
|
+
else console.error(error);
|
|
53
|
+
}
|
|
54
|
+
/** Formats a byte count as `12.3 kB`. */
|
|
55
|
+
function kb(bytes) {
|
|
56
|
+
return `${(bytes / KB).toFixed(1)} kB`;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/context.ts
|
|
60
|
+
/**
|
|
61
|
+
* Finds this cli's own package root by walking up to the folder holding `template/`.
|
|
62
|
+
*
|
|
63
|
+
* `dist/cli.js` and `src/commands/*.ts` sit at different depths, so a fixed relative path
|
|
64
|
+
* would resolve correctly from only one of them.
|
|
65
|
+
*/
|
|
66
|
+
function ownRoot() {
|
|
67
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
68
|
+
while (!existsSync(join(dir, "template"))) {
|
|
69
|
+
const parent = dirname(dir);
|
|
70
|
+
if (parent === dir) throw new Error("template/ not found next to the aletheia cli");
|
|
71
|
+
dir = parent;
|
|
72
|
+
}
|
|
73
|
+
return dir;
|
|
74
|
+
}
|
|
75
|
+
/** The folder this cli is installed in, holding `template/` and its own `node_modules/`. */
|
|
76
|
+
const OWN_ROOT = ownRoot();
|
|
77
|
+
/**
|
|
78
|
+
* Locates the repository from a working directory: the nearest ancestor holding `packages/`.
|
|
79
|
+
*
|
|
80
|
+
* @throws `CliError` when no ancestor has one.
|
|
81
|
+
*/
|
|
82
|
+
function findRepo(cwd = process.cwd()) {
|
|
83
|
+
let dir = resolve(cwd);
|
|
84
|
+
for (;;) {
|
|
85
|
+
if (existsSync(join(dir, "packages"))) return {
|
|
86
|
+
root: dir,
|
|
87
|
+
packages: join(dir, "packages"),
|
|
88
|
+
lists: join(dir, "lists"),
|
|
89
|
+
dist: join(dir, "dist")
|
|
90
|
+
};
|
|
91
|
+
const parent = dirname(dir);
|
|
92
|
+
if (parent === dir) throw new CliError([`no packages/ directory found above ${cwd}`]);
|
|
93
|
+
dir = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Reads and parses a json file.
|
|
98
|
+
*
|
|
99
|
+
* @throws `CliError` naming the file when it is missing or malformed, with the original error
|
|
100
|
+
* as the cause.
|
|
101
|
+
*/
|
|
102
|
+
async function readJSON(path) {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new CliError([`${path}: ${error instanceof Error ? error.message : String(error)}`], { cause: error });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Turns a failed parse into `file path: message` lines; empty for a successful one. */
|
|
110
|
+
function issues(file, result) {
|
|
111
|
+
if (result.success) return [];
|
|
112
|
+
return result.error.issues.map((issue) => `${file} ${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Loads one package by folder name, validating `source.json`, `filters.json` and, when
|
|
116
|
+
* present, `auth.json` against the sdk schemas.
|
|
117
|
+
*
|
|
118
|
+
* Every problem across the three files is collected before anything is thrown, and the
|
|
119
|
+
* manifest's slug has to match the folder name because the folder is what lists refer to.
|
|
120
|
+
*
|
|
121
|
+
* @throws `CliError` listing every problem found.
|
|
122
|
+
*/
|
|
123
|
+
async function loadPackage(repo, slug) {
|
|
124
|
+
const dir = join(repo.packages, slug);
|
|
125
|
+
const problems = [];
|
|
126
|
+
const manifestResult = SourceManifest.safeParse(await readJSON(join(dir, "source.json")));
|
|
127
|
+
problems.push(...issues(`${slug}/source.json`, manifestResult));
|
|
128
|
+
if (manifestResult.success && manifestResult.data.slug !== slug) problems.push(`${slug}/source.json slug: "${manifestResult.data.slug}" must match the folder name`);
|
|
129
|
+
const filtersResult = Filters.safeParse(await readJSON(join(dir, "filters.json")));
|
|
130
|
+
problems.push(...issues(`${slug}/filters.json`, filtersResult));
|
|
131
|
+
let auth = null;
|
|
132
|
+
if (existsSync(join(dir, "auth.json"))) {
|
|
133
|
+
const authResult = AuthSpec.safeParse(await readJSON(join(dir, "auth.json")));
|
|
134
|
+
problems.push(...issues(`${slug}/auth.json`, authResult));
|
|
135
|
+
if (authResult.success) auth = authResult.data;
|
|
136
|
+
}
|
|
137
|
+
if (!existsSync(join(dir, "src", "index.ts"))) problems.push(`${slug}/src/index.ts is missing`);
|
|
138
|
+
if (problems.length > 0 || !manifestResult.success || !filtersResult.success) throw new CliError(problems);
|
|
139
|
+
return {
|
|
140
|
+
slug,
|
|
141
|
+
dir,
|
|
142
|
+
manifest: manifestResult.data,
|
|
143
|
+
filters: filtersResult.data,
|
|
144
|
+
auth
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Loads every package folder under `packages/`, sorted by name, skipping dotfolders.
|
|
149
|
+
*
|
|
150
|
+
* With `only` (the `--only` flags) the set is narrowed first, and a name that is not a
|
|
151
|
+
* folder is an error rather than silently nothing. Problems from every package are
|
|
152
|
+
* collected before anything is thrown.
|
|
153
|
+
*
|
|
154
|
+
* @throws `CliError` listing unknown `only` names, or every problem across the packages.
|
|
155
|
+
*/
|
|
156
|
+
async function loadPackages(repo, only) {
|
|
157
|
+
const names = (await readdir(repo.packages, { withFileTypes: true })).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name).filter((name) => only === void 0 || only.includes(name)).sort();
|
|
158
|
+
if (only !== void 0) {
|
|
159
|
+
const missing = only.filter((name) => !names.includes(name));
|
|
160
|
+
if (missing.length > 0) throw new CliError(missing.map((name) => `no package "${name}"`));
|
|
161
|
+
}
|
|
162
|
+
const problems = [];
|
|
163
|
+
const packages = [];
|
|
164
|
+
for (const name of names) try {
|
|
165
|
+
packages.push(await loadPackage(repo, name));
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error instanceof CliError) problems.push(...error.lines);
|
|
168
|
+
else throw error;
|
|
169
|
+
}
|
|
170
|
+
if (problems.length > 0) throw new CliError(problems);
|
|
171
|
+
return packages;
|
|
172
|
+
}
|
|
173
|
+
/** Where `build` writes a package's bundle: `dist/build/<slug>/main.js`. */
|
|
174
|
+
function bundlePath(repo, slug) {
|
|
175
|
+
return join(repo.dist, "build", slug, "main.js");
|
|
176
|
+
}
|
|
177
|
+
/** Where `pack` writes a package's archive: `dist/packages/<slug>-v<version>.althsource`. */
|
|
178
|
+
function packagePath(repo, pkg) {
|
|
179
|
+
return join(repo.dist, "packages", `${pkg.slug}-v${pkg.manifest.version}.althsource`);
|
|
180
|
+
}
|
|
181
|
+
/** Where `pack` writes a package's rasterised icon: `dist/icons/<slug>.png`. */
|
|
182
|
+
function iconPath(repo, slug) {
|
|
183
|
+
return join(repo.dist, "icons", `${slug}.png`);
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/commands/build.ts
|
|
187
|
+
/**
|
|
188
|
+
* A fallback resolution root for the bundler.
|
|
189
|
+
*
|
|
190
|
+
* The sdk installed next to this cli resolves from here, so a repository that has not
|
|
191
|
+
* installed `@aletheia-ios/sdk` itself still builds.
|
|
192
|
+
*/
|
|
193
|
+
const OWN_NODE_MODULES = join(OWN_ROOT, "node_modules");
|
|
194
|
+
/** Matches a zod file in esbuild's input list, under a flat or pnpm layout. */
|
|
195
|
+
const ZOD_INPUT = /node_modules\/(\.pnpm\/)?zod[/@]/;
|
|
196
|
+
/**
|
|
197
|
+
* Bundles one package's `src/index.ts` to `dist/build/<slug>/main.js`.
|
|
198
|
+
*
|
|
199
|
+
* The output is a single ES2022 iife for a neutral platform, with no node or dom
|
|
200
|
+
* assumptions, ending by leaving the default export on `globalThis.__source` where the
|
|
201
|
+
* host reads it after evaluation.
|
|
202
|
+
*
|
|
203
|
+
* @throws `CliError` when the bundle pulled in zod, which means the source imported
|
|
204
|
+
* `@aletheia-ios/sdk/schemas` and would ship a validator to every phone.
|
|
205
|
+
*/
|
|
206
|
+
async function buildOne(repo, pkg) {
|
|
207
|
+
const outfile = bundlePath(repo, pkg.slug);
|
|
208
|
+
await mkdir(dirname(outfile), { recursive: true });
|
|
209
|
+
const result = await build({
|
|
210
|
+
entryPoints: [join(pkg.dir, "src", "index.ts")],
|
|
211
|
+
bundle: true,
|
|
212
|
+
format: "iife",
|
|
213
|
+
globalName: "__bundle",
|
|
214
|
+
footer: { js: "globalThis.__source = __bundle.default;" },
|
|
215
|
+
platform: "neutral",
|
|
216
|
+
target: "es2022",
|
|
217
|
+
outfile,
|
|
218
|
+
tsconfig: join(pkg.dir, "tsconfig.json"),
|
|
219
|
+
nodePaths: [OWN_NODE_MODULES],
|
|
220
|
+
logLevel: "warning",
|
|
221
|
+
metafile: true
|
|
222
|
+
});
|
|
223
|
+
if (Object.keys(result.metafile.inputs).some((input) => ZOD_INPUT.test(input))) throw new CliError([`${pkg.slug}: main.js bundles zod - a source must not import @aletheia-ios/sdk/schemas`]);
|
|
224
|
+
return {
|
|
225
|
+
pkg,
|
|
226
|
+
path: outfile,
|
|
227
|
+
bytes: Object.values(result.metafile.outputs)[0]?.bytes ?? 0
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** The `build` command: bundles each package in turn and prints its size. */
|
|
231
|
+
async function build$1(repo, packages) {
|
|
232
|
+
const built = [];
|
|
233
|
+
for (const pkg of packages) {
|
|
234
|
+
const one = await buildOne(repo, pkg);
|
|
235
|
+
info(`${pkg.slug}: main.js ${kb(one.bytes)}`);
|
|
236
|
+
built.push(one);
|
|
237
|
+
}
|
|
238
|
+
return built;
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/lib/icon.ts
|
|
242
|
+
/** The file names a package may use for its icon, in order of preference. */
|
|
243
|
+
const CANDIDATES = [
|
|
244
|
+
"icon.svg",
|
|
245
|
+
"icon.png",
|
|
246
|
+
"icon.jpg"
|
|
247
|
+
];
|
|
248
|
+
/** The smallest raster the cli will upscale; anything less looks blurred on a phone. */
|
|
249
|
+
const MIN_RASTER = 256;
|
|
250
|
+
/** The density an svg is rendered at so its edges stay sharp after the resize. */
|
|
251
|
+
const SVG_DENSITY = 300;
|
|
252
|
+
/**
|
|
253
|
+
* Returns the path of the package's icon.
|
|
254
|
+
*
|
|
255
|
+
* Exactly one of `icon.svg`, `icon.png`, `icon.jpg` must be present: svg is preferred
|
|
256
|
+
* because it rasterises to any size, but a package keeps whichever it has and never two.
|
|
257
|
+
*
|
|
258
|
+
* @throws `CliError` when the folder has no icon or more than one.
|
|
259
|
+
*/
|
|
260
|
+
function findIcon(dir, slug) {
|
|
261
|
+
const present = CANDIDATES.filter((name) => existsSync(join(dir, name)));
|
|
262
|
+
if (present.length !== 1) throw new CliError([present.length === 0 ? `${slug}: no icon - add icon.svg, icon.png or icon.jpg` : `${slug}: more than one icon (${present.join(", ")}) - keep exactly one`]);
|
|
263
|
+
return join(dir, present[0]);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Renders an icon file to a square png of `ICON_SIZE`, letterboxed on transparency.
|
|
267
|
+
*
|
|
268
|
+
* The app decodes png only, so an svg is rasterised here and never on the device. A raster
|
|
269
|
+
* smaller than `MIN_RASTER` on its long side is refused rather than upscaled.
|
|
270
|
+
*
|
|
271
|
+
* @throws `CliError` when a raster icon is too small.
|
|
272
|
+
*/
|
|
273
|
+
async function rasterise(source, slug) {
|
|
274
|
+
const image = sharp(source, { density: SVG_DENSITY });
|
|
275
|
+
const metadata = await image.metadata();
|
|
276
|
+
if (metadata.format !== "svg" && Math.max(metadata.width ?? 0, metadata.height ?? 0) < MIN_RASTER) throw new CliError([`${slug}: icon is ${metadata.width}x${metadata.height}, need at least ${MIN_RASTER}px on the long side`]);
|
|
277
|
+
return image.resize(512, 512, {
|
|
278
|
+
fit: "contain",
|
|
279
|
+
background: {
|
|
280
|
+
r: 0,
|
|
281
|
+
g: 0,
|
|
282
|
+
b: 0,
|
|
283
|
+
alpha: 0
|
|
284
|
+
}
|
|
285
|
+
}).png().toBuffer();
|
|
286
|
+
}
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/lib/jsc.ts
|
|
289
|
+
/**
|
|
290
|
+
* The shell inside `JavaScriptCore.framework` on macOS.
|
|
291
|
+
*
|
|
292
|
+
* It is the same engine the phone runs, which is the point: node has `fetch`, `atob` and
|
|
293
|
+
* `TextEncoder`, and JavaScriptCore does not. A bundle that assumes one of them fails here
|
|
294
|
+
* instead of on a device.
|
|
295
|
+
*/
|
|
296
|
+
const JSC = "/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc";
|
|
297
|
+
/** Whether the jsc shell exists on this machine; false anywhere but macOS. */
|
|
298
|
+
function hasJSC() {
|
|
299
|
+
return existsSync(JSC);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The stand-in host the bundle sees: a `__host.fetch` answered from fixtures, no html
|
|
303
|
+
* bridge, and a `console` that goes to jsc's `print`.
|
|
304
|
+
*/
|
|
305
|
+
const HARNESS = `
|
|
306
|
+
var __calls = [];
|
|
307
|
+
var __host = {
|
|
308
|
+
fetch: function (request) {
|
|
309
|
+
__calls.push(request.url);
|
|
310
|
+
var fixture = __fixtures.find(function (f) { return request.url.indexOf(f.match) !== -1; });
|
|
311
|
+
if (!fixture) return Promise.reject(new Error("no fixture for " + request.url));
|
|
312
|
+
return Promise.resolve({ status: 200, headers: {}, url: request.url, text: JSON.stringify(fixture.body) });
|
|
313
|
+
},
|
|
314
|
+
html: {},
|
|
315
|
+
};
|
|
316
|
+
var console = { log: print, warn: print, error: print };
|
|
317
|
+
`;
|
|
318
|
+
/** Prints the `typeof` of every contract method on the exported source. */
|
|
319
|
+
const EXPORTS = `
|
|
320
|
+
print("RESULT " + JSON.stringify({
|
|
321
|
+
search: typeof __source.search, details: typeof __source.details,
|
|
322
|
+
chapters: typeof __source.chapters, content: typeof __source.content,
|
|
323
|
+
comments: typeof __source.comments, replies: typeof __source.replies,
|
|
324
|
+
chaptersChanged: typeof __source.chaptersChanged, isChallenge: typeof __source.isChallenge,
|
|
325
|
+
pingURL: typeof __source.pingURL,
|
|
326
|
+
}));
|
|
327
|
+
`;
|
|
328
|
+
/**
|
|
329
|
+
* Calls search, details, chapters and content in turn and prints what came back.
|
|
330
|
+
*
|
|
331
|
+
* `drainMicrotasks` is jsc's way of running the promise chain to completion; there is no
|
|
332
|
+
* event loop in the shell. JavaScriptCore's `Error.stack` omits the message, so it is
|
|
333
|
+
* printed first along with a `SourceError` code when there is one.
|
|
334
|
+
*/
|
|
335
|
+
const SMOKE = `
|
|
336
|
+
var __ctx = { settings: {} };
|
|
337
|
+
var __out = {};
|
|
338
|
+
__source.search({ text: "smoke", filters: [], sort: null, cursor: null, route: null }, __ctx)
|
|
339
|
+
.then(function (s) { __out.search = s; return __source.details("series", __ctx); })
|
|
340
|
+
.then(function (d) { __out.details = d; return __source.chapters("series", __ctx); })
|
|
341
|
+
.then(function (c) { __out.chapters = c; return __source.content("series", "chapter", __ctx); })
|
|
342
|
+
.then(function (p) { __out.content = p; print("RESULT " + JSON.stringify(__out)); })
|
|
343
|
+
.catch(function (e) {
|
|
344
|
+
if (!(e && e.message)) return print("ERROR " + e);
|
|
345
|
+
print("ERROR " + (e.code ? e.code + ": " : "") + e.message + "\\n" + e.stack);
|
|
346
|
+
});
|
|
347
|
+
drainMicrotasks();
|
|
348
|
+
`;
|
|
349
|
+
/**
|
|
350
|
+
* Writes harness + fixtures + bundle + script to a temp file and runs it under jsc.
|
|
351
|
+
*
|
|
352
|
+
* The script reports through a single `RESULT ` or `ERROR ` line on stdout; a non-zero exit
|
|
353
|
+
* (an uncaught throw while the bundle loads) counts as a failure too.
|
|
354
|
+
*/
|
|
355
|
+
function run(bundle, fixtures, script) {
|
|
356
|
+
const file = join(mkdtempSync(join(tmpdir(), "aletheia-jsc-")), "run.js");
|
|
357
|
+
writeFileSync(file, `${HARNESS}\nvar __fixtures = ${JSON.stringify(fixtures)};\n${bundle}\n${script}`);
|
|
358
|
+
const child = spawnSync(JSC, [file], { encoding: "utf8" });
|
|
359
|
+
const output = `${child.stdout}${child.stderr}`;
|
|
360
|
+
const line = child.stdout.split("\n").find((candidate) => candidate.startsWith("RESULT ") || candidate.startsWith("ERROR "));
|
|
361
|
+
if (child.status !== 0 || !line || line.startsWith("ERROR ")) return {
|
|
362
|
+
ok: false,
|
|
363
|
+
result: null,
|
|
364
|
+
output
|
|
365
|
+
};
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
result: JSON.parse(line.slice(7)),
|
|
369
|
+
output
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/** Evaluates the bundle under jsc and reports which contract methods it exports. */
|
|
373
|
+
function exportsOf(bundle) {
|
|
374
|
+
return run(bundle, [], EXPORTS);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Evaluates the bundle under jsc and runs the four required calls against fixtures.
|
|
378
|
+
*
|
|
379
|
+
* The calls use the placeholder slugs `series` and `chapter` and the query text `smoke`;
|
|
380
|
+
* fixtures answer whatever URLs the source builds from them.
|
|
381
|
+
*/
|
|
382
|
+
function smoke(bundle, fixtures) {
|
|
383
|
+
return run(bundle, fixtures, SMOKE);
|
|
384
|
+
}
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/commands/check.ts
|
|
387
|
+
/** The methods every source must export. */
|
|
388
|
+
const REQUIRED = [
|
|
389
|
+
"search",
|
|
390
|
+
"details",
|
|
391
|
+
"chapters",
|
|
392
|
+
"content"
|
|
393
|
+
];
|
|
394
|
+
/** The methods whose presence the app reads as a capability. */
|
|
395
|
+
const OPTIONAL = [
|
|
396
|
+
"comments",
|
|
397
|
+
"replies",
|
|
398
|
+
"chaptersChanged",
|
|
399
|
+
"isChallenge",
|
|
400
|
+
"pingURL"
|
|
401
|
+
];
|
|
402
|
+
/**
|
|
403
|
+
* Runs the repository's own `tsc` against the package's tsconfig.
|
|
404
|
+
*
|
|
405
|
+
* Skipped with a warning when TypeScript is not installed in the repository; the bundler
|
|
406
|
+
* strips types without checking them, so this is the only place a type error surfaces.
|
|
407
|
+
* Returns tsc's output lines, empty when it passed.
|
|
408
|
+
*/
|
|
409
|
+
function typecheck(repo, pkg) {
|
|
410
|
+
const tsc = join(repo.root, "node_modules", ".bin", "tsc");
|
|
411
|
+
if (!existsSync(tsc)) {
|
|
412
|
+
warn(`${pkg.slug}: typescript is not installed in this repository, skipping typecheck`);
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
const child = spawnSync(tsc, [
|
|
416
|
+
"-p",
|
|
417
|
+
join(pkg.dir, "tsconfig.json"),
|
|
418
|
+
"--noEmit"
|
|
419
|
+
], { encoding: "utf8" });
|
|
420
|
+
if (child.status === 0) return [];
|
|
421
|
+
return child.stdout.trim().split("\n");
|
|
422
|
+
}
|
|
423
|
+
/** The icon problems for a package as lines, empty when it has exactly one icon. */
|
|
424
|
+
function icon(pkg) {
|
|
425
|
+
try {
|
|
426
|
+
findIcon(pkg.dir, pkg.slug);
|
|
427
|
+
return [];
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (error instanceof CliError) return error.lines;
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Evaluates a built bundle under JavaScriptCore and returns its problems as lines.
|
|
435
|
+
*
|
|
436
|
+
* Verifies the required exports, prints the capabilities the app will derive, and when
|
|
437
|
+
* `fixtures/smoke.json` exists runs the four calls against it. Without fixtures the
|
|
438
|
+
* exports alone are verified and a warning says so.
|
|
439
|
+
*/
|
|
440
|
+
async function evaluate$1(pkg, path) {
|
|
441
|
+
const bundle = await readFile(path, "utf8");
|
|
442
|
+
const exported = exportsOf(bundle);
|
|
443
|
+
if (!exported.ok || exported.result === null) return [`${pkg.slug}: main.js failed to evaluate in JavaScriptCore\n${exported.output}`];
|
|
444
|
+
const missing = REQUIRED.filter((name) => exported.result?.[name] !== "function");
|
|
445
|
+
if (missing.length > 0) return missing.map((name) => `${pkg.slug}: missing export ${name}()`);
|
|
446
|
+
const capabilities = OPTIONAL.filter((name) => exported.result?.[name] === "function");
|
|
447
|
+
if (pkg.auth !== null) capabilities.push("auth");
|
|
448
|
+
const label = `${pkg.slug}: ok [${capabilities.join(", ") || "base"}]`;
|
|
449
|
+
const fixturesPath = join(pkg.dir, "fixtures", "smoke.json");
|
|
450
|
+
if (!existsSync(fixturesPath)) {
|
|
451
|
+
warn(`${pkg.slug}: no fixtures/smoke.json - exports verified, calls not exercised`);
|
|
452
|
+
info(label);
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
const result = smoke(bundle, JSON.parse(await readFile(fixturesPath, "utf8")));
|
|
456
|
+
if (!result.ok || result.result === null) return [`${pkg.slug}: smoke failed in JavaScriptCore\n${result.output}`];
|
|
457
|
+
const out = result.result;
|
|
458
|
+
info(`${label} - "${out.details.title}", ${out.search.items.length} results, ${out.chapters.length} chapters, ${out.content.length} pages`);
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* The `check` command: typecheck, build, then verify each bundle under JavaScriptCore.
|
|
463
|
+
*
|
|
464
|
+
* Type errors stop the run before anything is built. After that every package is checked
|
|
465
|
+
* and every problem reported together. The JavaScriptCore step needs the jsc shell that
|
|
466
|
+
* ships with macOS and is skipped with a warning elsewhere.
|
|
467
|
+
*
|
|
468
|
+
* @throws `CliError` listing every problem found.
|
|
469
|
+
*/
|
|
470
|
+
async function check(repo, packages) {
|
|
471
|
+
const problems = [];
|
|
472
|
+
for (const pkg of packages) problems.push(...typecheck(repo, pkg));
|
|
473
|
+
if (problems.length > 0) throw new CliError(problems);
|
|
474
|
+
const built = await build$1(repo, packages);
|
|
475
|
+
const jsc = hasJSC();
|
|
476
|
+
if (!jsc) warn("jsc shell not found (macOS only) - skipping the JavaScriptCore run");
|
|
477
|
+
for (const { pkg, path } of built) {
|
|
478
|
+
problems.push(...icon(pkg));
|
|
479
|
+
if (jsc) problems.push(...await evaluate$1(pkg, path));
|
|
480
|
+
}
|
|
481
|
+
if (problems.length > 0) throw new CliError(problems);
|
|
482
|
+
}
|
|
483
|
+
//#endregion
|
|
484
|
+
//#region src/commands/indexes.ts
|
|
485
|
+
/**
|
|
486
|
+
* Validates one `lists/<name>.json`: the list's display name, the `dist/<target>` folder
|
|
487
|
+
* it publishes to, and the package slugs it includes.
|
|
488
|
+
*/
|
|
489
|
+
const List = z.strictObject({
|
|
490
|
+
name: z.string().min(1),
|
|
491
|
+
target: z.string().regex(/^[a-z0-9-]+$/),
|
|
492
|
+
sources: z.array(z.string().min(1)).min(1)
|
|
493
|
+
});
|
|
494
|
+
/**
|
|
495
|
+
* Loads every `lists/*.json`, sorted by file name.
|
|
496
|
+
*
|
|
497
|
+
* @throws `CliError` when there is no `lists/` directory, or listing every invalid field
|
|
498
|
+
* across the lists.
|
|
499
|
+
*/
|
|
500
|
+
async function loadLists(repo) {
|
|
501
|
+
if (!existsSync(repo.lists)) throw new CliError([`no lists/ directory in ${repo.root}`]);
|
|
502
|
+
const names = (await readdir(repo.lists)).filter((name) => name.endsWith(".json")).sort();
|
|
503
|
+
const lists = [];
|
|
504
|
+
const problems = [];
|
|
505
|
+
for (const name of names) {
|
|
506
|
+
const result = List.safeParse(JSON.parse(await readFile(join(repo.lists, name), "utf8")));
|
|
507
|
+
if (result.success) lists.push(result.data);
|
|
508
|
+
else problems.push(...result.error.issues.map((issue) => `lists/${name} ${issue.path.join(".") || "(root)"}: ${issue.message}`));
|
|
509
|
+
}
|
|
510
|
+
if (problems.length > 0) throw new CliError(problems);
|
|
511
|
+
return lists;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The package folder's last commit date as ISO 8601.
|
|
515
|
+
*
|
|
516
|
+
* Taken from git so an unchanged package keeps its date across rebuilds and the app does
|
|
517
|
+
* not see a fresh update every deploy. Falls back to now outside a git checkout.
|
|
518
|
+
*/
|
|
519
|
+
function updatedDate(pkg) {
|
|
520
|
+
const child = spawnSync("git", [
|
|
521
|
+
"log",
|
|
522
|
+
"-1",
|
|
523
|
+
"--format=%cI",
|
|
524
|
+
"--",
|
|
525
|
+
pkg.dir
|
|
526
|
+
], {
|
|
527
|
+
cwd: pkg.dir,
|
|
528
|
+
encoding: "utf8"
|
|
529
|
+
});
|
|
530
|
+
const iso = child.status === 0 ? child.stdout.trim() : "";
|
|
531
|
+
return iso === "" ? (/* @__PURE__ */ new Date()).toISOString() : new Date(iso).toISOString();
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Builds a package's entry for a list: manifest fields, the packed file's size and
|
|
535
|
+
* sha256, and download and icon URLs relative to `dist/<target>/`.
|
|
536
|
+
*
|
|
537
|
+
* @throws `CliError` when the package has not been packed.
|
|
538
|
+
*/
|
|
539
|
+
async function entry(repo, pkg, target) {
|
|
540
|
+
const path = packagePath(repo, pkg);
|
|
541
|
+
if (!existsSync(path)) throw new CliError([`${pkg.slug}: not packed - run pack first`]);
|
|
542
|
+
const bytes = await readFile(path);
|
|
543
|
+
const base = join(repo.dist, target);
|
|
544
|
+
return {
|
|
545
|
+
slug: pkg.slug,
|
|
546
|
+
name: pkg.manifest.name,
|
|
547
|
+
version: pkg.manifest.version,
|
|
548
|
+
minAppVersion: pkg.manifest.minAppVersion,
|
|
549
|
+
contractVersion: pkg.manifest.contractVersion,
|
|
550
|
+
contentRating: pkg.manifest.contentRating,
|
|
551
|
+
languages: pkg.manifest.languages,
|
|
552
|
+
size: (await stat(path)).size,
|
|
553
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
554
|
+
updatedDate: updatedDate(pkg),
|
|
555
|
+
downloadURL: relative(base, path),
|
|
556
|
+
iconURL: relative(base, iconPath(repo, pkg.slug))
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The `index` command: writes `dist/<target>/index.json` for every list.
|
|
561
|
+
*
|
|
562
|
+
* The index is validated against the sdk's `Index` schema before it is written, so a
|
|
563
|
+
* deploy never publishes what the app would reject. Next to it, `manifest.json` names the
|
|
564
|
+
* package and icon files that target's deploy has to upload besides its own directory.
|
|
565
|
+
*
|
|
566
|
+
* @throws `CliError` when a list names a package that does not exist or is not packed.
|
|
567
|
+
*/
|
|
568
|
+
async function indexes(repo, packages) {
|
|
569
|
+
const bySlug = new Map(packages.map((pkg) => [pkg.slug, pkg]));
|
|
570
|
+
for (const list of await loadLists(repo)) {
|
|
571
|
+
const missing = list.sources.filter((slug) => !bySlug.has(slug));
|
|
572
|
+
if (missing.length > 0) throw new CliError(missing.map((slug) => `list "${list.name}": no package "${slug}"`));
|
|
573
|
+
const members = list.sources.map((slug) => bySlug.get(slug));
|
|
574
|
+
const entries = [];
|
|
575
|
+
for (const pkg of members) entries.push(await entry(repo, pkg, list.target));
|
|
576
|
+
const index = Index.parse({
|
|
577
|
+
name: list.name,
|
|
578
|
+
updatedDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
579
|
+
sources: entries
|
|
580
|
+
});
|
|
581
|
+
const dir = join(repo.dist, list.target);
|
|
582
|
+
await mkdir(dir, { recursive: true });
|
|
583
|
+
await writeFile(join(dir, "index.json"), `${JSON.stringify(index, null, 2)}\n`);
|
|
584
|
+
const manifest = {
|
|
585
|
+
packages: members.map((pkg) => basename(packagePath(repo, pkg))),
|
|
586
|
+
icons: members.map((pkg) => basename(iconPath(repo, pkg.slug)))
|
|
587
|
+
};
|
|
588
|
+
await writeFile(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
589
|
+
info(`${list.target}/index.json: ${entries.length} source(s)`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region src/commands/live.ts
|
|
594
|
+
/** How many search results are listed before the walk moves on. */
|
|
595
|
+
const PREVIEW = 5;
|
|
596
|
+
/**
|
|
597
|
+
* Evaluates the bundle in a node vm with a real network behind `__host.fetch`.
|
|
598
|
+
*
|
|
599
|
+
* The manifest's `hosts` allowlist is enforced the way the app enforces it, so a source
|
|
600
|
+
* that reaches somewhere it did not declare fails here too. There is no html bridge.
|
|
601
|
+
*/
|
|
602
|
+
function evaluate(bundle, hosts) {
|
|
603
|
+
const context = vm.createContext({
|
|
604
|
+
console,
|
|
605
|
+
__host: {
|
|
606
|
+
async fetch(request) {
|
|
607
|
+
const { host } = new URL(request.url);
|
|
608
|
+
if (!hosts.includes(host)) throw new Error(`host not allowed by source.json: ${host}`);
|
|
609
|
+
const response = await fetch(request.url, {
|
|
610
|
+
method: request.method ?? "GET",
|
|
611
|
+
headers: {
|
|
612
|
+
"user-agent": "aletheia-tools live",
|
|
613
|
+
...request.headers ?? {}
|
|
614
|
+
},
|
|
615
|
+
...request.body === void 0 ? {} : { body: request.body }
|
|
616
|
+
});
|
|
617
|
+
return {
|
|
618
|
+
status: response.status,
|
|
619
|
+
headers: Object.fromEntries(response.headers),
|
|
620
|
+
url: response.url,
|
|
621
|
+
text: await response.text()
|
|
622
|
+
};
|
|
623
|
+
},
|
|
624
|
+
html: {}
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
vm.runInContext(bundle, context);
|
|
628
|
+
return context.__source;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* The `live` command: builds one package and walks it against the live site in node.
|
|
632
|
+
*
|
|
633
|
+
* Searches for `text`, opens `series` (or the first result when it is `-`), lists its
|
|
634
|
+
* chapters, revalidates when the source can, and fetches the first chapter's pages,
|
|
635
|
+
* printing a summary of each step. This is how a port is compared with what the app shows
|
|
636
|
+
* today; it is never part of `check`.
|
|
637
|
+
*
|
|
638
|
+
* @throws `CliError` when the search returns nothing to open.
|
|
639
|
+
*/
|
|
640
|
+
async function live(repo, pkg, series, text) {
|
|
641
|
+
const { path } = await buildOne(repo, pkg);
|
|
642
|
+
const source = evaluate(await readFile(path, "utf8"), pkg.manifest.hosts);
|
|
643
|
+
const ctx = { settings: {} };
|
|
644
|
+
const started = Date.now();
|
|
645
|
+
const search = await source.search({
|
|
646
|
+
text,
|
|
647
|
+
filters: [],
|
|
648
|
+
sort: null,
|
|
649
|
+
cursor: null,
|
|
650
|
+
route: null
|
|
651
|
+
}, ctx);
|
|
652
|
+
info(`search "${text}": ${search.items.length} results, next=${search.next}`);
|
|
653
|
+
for (const item of search.items.slice(0, PREVIEW)) info(` ${item.slug} ${item.title}`);
|
|
654
|
+
const slug = series === "-" ? search.items[0]?.slug : series;
|
|
655
|
+
if (slug === void 0) throw new CliError(["nothing to open - no results"]);
|
|
656
|
+
const details = await source.details(slug, ctx);
|
|
657
|
+
info(`details: ${details.title} [${details.classification}, ${details.publication}]`);
|
|
658
|
+
info(` ${details.covers.length} covers, ${details.tags.length} tags, authors ${details.authors.join(", ")}`);
|
|
659
|
+
const chapters = await source.chapters(slug, ctx);
|
|
660
|
+
info(`chapters: ${chapters.length}`);
|
|
661
|
+
const [first] = chapters;
|
|
662
|
+
if (first !== void 0) {
|
|
663
|
+
info(` first: #${first.number} "${first.title}" ${first.language} by ${first.scanlator}`);
|
|
664
|
+
if (source.chaptersChanged) {
|
|
665
|
+
const revalidation = await source.chaptersChanged(slug, chapters.length, ctx);
|
|
666
|
+
info(`revalidate with stored=${chapters.length}: ${revalidation.kind}`);
|
|
667
|
+
}
|
|
668
|
+
const pages = await source.content(slug, first.slug, ctx);
|
|
669
|
+
info(`content: ${pages.length} pages, first ${pages[0]?.url ?? "-"}`);
|
|
670
|
+
}
|
|
671
|
+
info(`done in ${Date.now() - started} ms`);
|
|
672
|
+
}
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/commands/new.ts
|
|
675
|
+
/** The package skeleton shipped with the cli: an offline source that passes `check` as-is. */
|
|
676
|
+
const TEMPLATE = join(OWN_ROOT, "template");
|
|
677
|
+
/** File extensions that get placeholder substitution; everything else is copied verbatim. */
|
|
678
|
+
const TEXT = /* @__PURE__ */ new Set([
|
|
679
|
+
".json",
|
|
680
|
+
".ts",
|
|
681
|
+
".md"
|
|
682
|
+
]);
|
|
683
|
+
/** A valid package slug: lowercase letters, digits and hyphens. */
|
|
684
|
+
const SLUG = /^[a-z0-9-]+$/;
|
|
685
|
+
/** Rewrites every text file under `dir`, replacing each placeholder wherever it appears. */
|
|
686
|
+
async function substitute(dir, values) {
|
|
687
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
688
|
+
const path = join(dir, entry.name);
|
|
689
|
+
if (entry.isDirectory()) {
|
|
690
|
+
await substitute(path, values);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (!TEXT.has(path.slice(path.lastIndexOf(".")))) continue;
|
|
694
|
+
let text = await readFile(path, "utf8");
|
|
695
|
+
for (const [placeholder, value] of values) text = text.replaceAll(placeholder, value);
|
|
696
|
+
await writeFile(path, text);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* The `new` command: copies the template to `packages/<slug>` and fills in the slug and
|
|
701
|
+
* display name.
|
|
702
|
+
*
|
|
703
|
+
* The result is an offline source with `hosts: []` that passes `check` unchanged, so the
|
|
704
|
+
* first thing seen from a new package is green.
|
|
705
|
+
*
|
|
706
|
+
* @throws `CliError` when the slug is not lowercase-letters-digits-hyphens or the folder
|
|
707
|
+
* already exists.
|
|
708
|
+
*/
|
|
709
|
+
async function create(repo, slug, name) {
|
|
710
|
+
if (!SLUG.test(slug)) throw new CliError([`"${slug}" is not a slug - lowercase letters, digits and hyphens only`]);
|
|
711
|
+
const dir = join(repo.packages, slug);
|
|
712
|
+
if (existsSync(dir)) throw new CliError([`packages/${slug} already exists`]);
|
|
713
|
+
await cp(TEMPLATE, dir, { recursive: true });
|
|
714
|
+
await substitute(dir, [["__SLUG__", slug], ["__NAME__", name]]);
|
|
715
|
+
info(`created packages/${slug} - it passes \`aletheia check\` as an offline source; replace src/ with the real one`);
|
|
716
|
+
}
|
|
717
|
+
//#endregion
|
|
718
|
+
//#region src/lib/zip.ts
|
|
719
|
+
/**
|
|
720
|
+
* The modification time stamped on every entry.
|
|
721
|
+
*
|
|
722
|
+
* A fixed mtime is what makes two builds of the same content byte-identical, which the app
|
|
723
|
+
* relies on to treat one package published by two lists as one package.
|
|
724
|
+
*/
|
|
725
|
+
const EPOCH = /* @__PURE__ */ new Date("2000-01-01T00:00:00Z");
|
|
726
|
+
/**
|
|
727
|
+
* Zips files into bytes that depend only on their names and contents.
|
|
728
|
+
*
|
|
729
|
+
* Entries are written in sorted name order with the fixed mtime and maximum deflate, so
|
|
730
|
+
* the same input always produces the same archive and the same sha256.
|
|
731
|
+
*/
|
|
732
|
+
function deterministicZip(files) {
|
|
733
|
+
const entries = {};
|
|
734
|
+
for (const name of Object.keys(files).sort()) entries[name] = [files[name], {
|
|
735
|
+
mtime: EPOCH,
|
|
736
|
+
level: 9
|
|
737
|
+
}];
|
|
738
|
+
return zipSync(entries);
|
|
739
|
+
}
|
|
740
|
+
//#endregion
|
|
741
|
+
//#region src/commands/pack.ts
|
|
742
|
+
/** How many hex characters of the sha256 the progress line shows. */
|
|
743
|
+
const SHORT_HASH = 12;
|
|
744
|
+
/**
|
|
745
|
+
* The `pack` command: builds each package, rasterises its icon, and zips the package files
|
|
746
|
+
* to `dist/packages/<slug>-v<version>.althsource`.
|
|
747
|
+
*
|
|
748
|
+
* The archive holds `source.json`, `filters.json`, `icon.png`, `main.js` and, when the
|
|
749
|
+
* package has one, `auth.json`. The zip is deterministic, so the same content always gives
|
|
750
|
+
* the same bytes and sha256. The icon is also written on its own to `dist/icons/` for the
|
|
751
|
+
* index to link.
|
|
752
|
+
*/
|
|
753
|
+
async function pack(repo, packages) {
|
|
754
|
+
const built = await build$1(repo, packages);
|
|
755
|
+
const packed = [];
|
|
756
|
+
for (const { pkg, path } of built) {
|
|
757
|
+
const icon = await rasterise(findIcon(pkg.dir, pkg.slug), pkg.slug);
|
|
758
|
+
const files = {
|
|
759
|
+
"source.json": await readFile(join(pkg.dir, "source.json")),
|
|
760
|
+
"filters.json": await readFile(join(pkg.dir, "filters.json")),
|
|
761
|
+
"icon.png": icon,
|
|
762
|
+
"main.js": await readFile(path)
|
|
763
|
+
};
|
|
764
|
+
if (existsSync(join(pkg.dir, "auth.json"))) files["auth.json"] = await readFile(join(pkg.dir, "auth.json"));
|
|
765
|
+
const zip = deterministicZip(files);
|
|
766
|
+
const out = packagePath(repo, pkg);
|
|
767
|
+
const iconOut = iconPath(repo, pkg.slug);
|
|
768
|
+
await mkdir(dirname(out), { recursive: true });
|
|
769
|
+
await mkdir(dirname(iconOut), { recursive: true });
|
|
770
|
+
await writeFile(out, zip);
|
|
771
|
+
await writeFile(iconOut, icon);
|
|
772
|
+
const sha256 = createHash("sha256").update(zip).digest("hex");
|
|
773
|
+
info(`${pkg.slug}: ${out.slice(repo.root.length + 1)} ${kb(zip.byteLength)} ${sha256.slice(0, SHORT_HASH)}`);
|
|
774
|
+
packed.push({
|
|
775
|
+
pkg,
|
|
776
|
+
path: out,
|
|
777
|
+
icon: iconOut,
|
|
778
|
+
bytes: zip.byteLength,
|
|
779
|
+
sha256
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return packed;
|
|
783
|
+
}
|
|
784
|
+
//#endregion
|
|
785
|
+
//#region src/commands/serve.ts
|
|
786
|
+
/** Content types for what `dist/` holds; anything else is served as octet-stream. */
|
|
787
|
+
const TYPES = {
|
|
788
|
+
".json": "application/json",
|
|
789
|
+
".png": "image/png",
|
|
790
|
+
".althsource": "application/zip",
|
|
791
|
+
".js": "text/javascript"
|
|
792
|
+
};
|
|
793
|
+
/** Leading `../` segments left after normalisation, which would escape `dist/`. */
|
|
794
|
+
const TRAVERSAL = /^(\.\.[/\\])+/;
|
|
795
|
+
/** How long after the last change to wait before rebuilding, so an editor's save burst is one build. */
|
|
796
|
+
const DEBOUNCE_MS = 300;
|
|
797
|
+
const OK = 200;
|
|
798
|
+
const NOT_FOUND = 404;
|
|
799
|
+
/**
|
|
800
|
+
* Packs and indexes the whole repository, reporting failures instead of throwing.
|
|
801
|
+
*
|
|
802
|
+
* A broken package while serving is a message on stderr, not a dead server; the previous
|
|
803
|
+
* build stays served until the next successful one.
|
|
804
|
+
*/
|
|
805
|
+
async function rebuild(repo) {
|
|
806
|
+
try {
|
|
807
|
+
const packages = await loadPackages(repo);
|
|
808
|
+
await pack(repo, packages);
|
|
809
|
+
await indexes(repo, packages);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
report(error);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/** The first non-internal IPv4 address, which is what a phone on the same wifi can reach. */
|
|
815
|
+
function lanAddress() {
|
|
816
|
+
for (const list of Object.values(networkInterfaces())) for (const iface of list ?? []) if (iface.family === "IPv4" && !iface.internal) return iface.address;
|
|
817
|
+
return "127.0.0.1";
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* The file under `dist/` a request names, or null when it names nothing servable.
|
|
821
|
+
*
|
|
822
|
+
* Null covers a missing file, a directory, a path that would escape `dist/`, and a URL
|
|
823
|
+
* that does not decode.
|
|
824
|
+
*/
|
|
825
|
+
async function resolveFile(dist, url) {
|
|
826
|
+
let path;
|
|
827
|
+
try {
|
|
828
|
+
path = normalize(decodeURIComponent(url)).replace(TRAVERSAL, "");
|
|
829
|
+
} catch {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
const file = join(dist, path);
|
|
833
|
+
return file.startsWith(dist) && existsSync(file) && (await stat(file)).isFile() ? file : null;
|
|
834
|
+
}
|
|
835
|
+
/** A static server over `dist/` that sends `cache-control: no-store` so the app never caches a dev build. */
|
|
836
|
+
function fileServer(dist) {
|
|
837
|
+
return createServer(async (request, response) => {
|
|
838
|
+
const file = await resolveFile(dist, request.url ?? "/");
|
|
839
|
+
if (file === null) {
|
|
840
|
+
response.writeHead(NOT_FOUND).end();
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
response.writeHead(OK, {
|
|
844
|
+
"content-type": TYPES[extname(file)] ?? "application/octet-stream",
|
|
845
|
+
"cache-control": "no-store"
|
|
846
|
+
});
|
|
847
|
+
createReadStream(file).pipe(response);
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
/** Watches `packages/` and `lists/` and rebuilds once per burst of changes. */
|
|
851
|
+
function watchRepo(repo) {
|
|
852
|
+
let timer = null;
|
|
853
|
+
const trigger = () => {
|
|
854
|
+
if (timer) clearTimeout(timer);
|
|
855
|
+
timer = setTimeout(() => {
|
|
856
|
+
info("change detected, rebuilding");
|
|
857
|
+
rebuild(repo).catch(report);
|
|
858
|
+
}, DEBOUNCE_MS);
|
|
859
|
+
};
|
|
860
|
+
return [watch(repo.packages, { recursive: true }, trigger), watch(repo.lists, { recursive: true }, trigger)];
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* The `serve` command: packs, indexes, serves `dist/` on the lan and rebuilds on change.
|
|
864
|
+
*
|
|
865
|
+
* Binds every interface and prints the lan URL of each list's `index.json`, which is what
|
|
866
|
+
* the app's developer list points at. Port 0 binds a free port. Resolves once listening;
|
|
867
|
+
* the returned `close` stops the watchers and the server.
|
|
868
|
+
*
|
|
869
|
+
* @throws `CliError` when there is no `lists/` directory, before any port is bound.
|
|
870
|
+
*/
|
|
871
|
+
async function serve(repo, port) {
|
|
872
|
+
const lists = await loadLists(repo);
|
|
873
|
+
await rebuild(repo);
|
|
874
|
+
const server = fileServer(repo.dist);
|
|
875
|
+
const watchers = watchRepo(repo);
|
|
876
|
+
await new Promise((resolve) => server.listen(port, "0.0.0.0", resolve));
|
|
877
|
+
const address = server.address();
|
|
878
|
+
const bound = typeof address === "object" && address !== null ? address.port : port;
|
|
879
|
+
const url = `http://${lanAddress()}:${bound}/`;
|
|
880
|
+
info(`serving ${repo.dist} on ${url}`);
|
|
881
|
+
for (const list of lists) info(` ${list.name}: ${url}${list.target}/index.json`);
|
|
882
|
+
return {
|
|
883
|
+
url,
|
|
884
|
+
close: () => {
|
|
885
|
+
for (const watcher of watchers) watcher.close();
|
|
886
|
+
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
//#endregion
|
|
891
|
+
//#region src/cli.ts
|
|
892
|
+
/** What `aletheia`, `aletheia --help` and an unknown command print. */
|
|
893
|
+
const USAGE = `usage: aletheia <command> [options]
|
|
894
|
+
|
|
895
|
+
build [--only <slug>] bundle each package's src/index.ts to dist/build/<slug>/main.js
|
|
896
|
+
check [--only <slug>] typecheck, build, verify exports and run fixtures under JavaScriptCore
|
|
897
|
+
pack [--only <slug>] build, rasterise the icon and zip to dist/packages/<slug>-v<version>.althsource
|
|
898
|
+
index write dist/<target>/index.json for every list in lists/
|
|
899
|
+
serve [--port <n>] pack, index, serve dist/ on the lan and rebuild on change
|
|
900
|
+
new <slug> [--name <n>] scaffold packages/<slug> from the template
|
|
901
|
+
live <slug> <series|-> [query]
|
|
902
|
+
run a package against the live site in node and print what it returns
|
|
903
|
+
|
|
904
|
+
Run from anywhere inside a repository that has a packages/ directory.`;
|
|
905
|
+
/** The port `serve` binds when `--port` is not given. */
|
|
906
|
+
const DEFAULT_PORT = "8787";
|
|
907
|
+
/**
|
|
908
|
+
* Parses the arguments and dispatches to one command.
|
|
909
|
+
*
|
|
910
|
+
* Every command runs from the repository found above the working directory, and all but
|
|
911
|
+
* `serve` and `new` load the packages first so a broken manifest fails before any work.
|
|
912
|
+
*
|
|
913
|
+
* @throws `CliError` for an unknown command or a missing positional, plus whatever the
|
|
914
|
+
* command throws.
|
|
915
|
+
*/
|
|
916
|
+
async function main(argv) {
|
|
917
|
+
const { values, positionals } = parseArgs({
|
|
918
|
+
args: argv,
|
|
919
|
+
allowPositionals: true,
|
|
920
|
+
options: {
|
|
921
|
+
only: {
|
|
922
|
+
type: "string",
|
|
923
|
+
multiple: true
|
|
924
|
+
},
|
|
925
|
+
port: { type: "string" },
|
|
926
|
+
name: { type: "string" },
|
|
927
|
+
help: {
|
|
928
|
+
type: "boolean",
|
|
929
|
+
short: "h"
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
const [command, ...rest] = positionals;
|
|
934
|
+
if (values.help || command === void 0) {
|
|
935
|
+
info(USAGE);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const repo = findRepo();
|
|
939
|
+
switch (command) {
|
|
940
|
+
case "build":
|
|
941
|
+
await build$1(repo, await loadPackages(repo, values.only));
|
|
942
|
+
return;
|
|
943
|
+
case "check":
|
|
944
|
+
await check(repo, await loadPackages(repo, values.only));
|
|
945
|
+
return;
|
|
946
|
+
case "pack":
|
|
947
|
+
await pack(repo, await loadPackages(repo, values.only));
|
|
948
|
+
return;
|
|
949
|
+
case "index":
|
|
950
|
+
await indexes(repo, await loadPackages(repo));
|
|
951
|
+
return;
|
|
952
|
+
case "serve":
|
|
953
|
+
await serve(repo, Number.parseInt(values.port ?? DEFAULT_PORT, 10));
|
|
954
|
+
return;
|
|
955
|
+
case "new": {
|
|
956
|
+
const [slug] = rest;
|
|
957
|
+
if (slug === void 0) throw new CliError(["usage: aletheia new <slug> [--name <name>]"]);
|
|
958
|
+
await create(repo, slug, values.name ?? slug);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
case "live": {
|
|
962
|
+
const [slug, series, text] = rest;
|
|
963
|
+
if (slug === void 0 || series === void 0) throw new CliError(["usage: aletheia live <slug> <series|-> [query]"]);
|
|
964
|
+
await live(repo, await loadPackage(repo, slug), series, text ?? "");
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
default: throw new CliError([
|
|
968
|
+
`unknown command "${command}"`,
|
|
969
|
+
"",
|
|
970
|
+
USAGE
|
|
971
|
+
]);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
975
|
+
report(error);
|
|
976
|
+
process.exit(1);
|
|
977
|
+
});
|
|
978
|
+
//#endregion
|
|
979
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aletheia-ios/tools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The aletheia CLI: build, check, pack, index and serve source packages",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/aletheia-ios/tools.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"packageManager": "pnpm@10.16.1",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"aletheia": "./dist/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"template"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsdown && chmod +x dist/cli.js",
|
|
27
|
+
"typecheck": "tsc",
|
|
28
|
+
"lint": "biome check .",
|
|
29
|
+
"format": "biome format --write .",
|
|
30
|
+
"test": "vitest run --coverage",
|
|
31
|
+
"check": "pnpm typecheck && pnpm lint && pnpm build && pnpm test && publint",
|
|
32
|
+
"prepare": "lefthook install"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@aletheia-ios/sdk": "^0.1.0",
|
|
36
|
+
"esbuild": "^0.28.2",
|
|
37
|
+
"fflate": "^0.8.3",
|
|
38
|
+
"sharp": "^0.35.4",
|
|
39
|
+
"zod": "^4.5.4"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@biomejs/biome": "^2.5.12",
|
|
43
|
+
"@types/node": "^24.13.3",
|
|
44
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
45
|
+
"lefthook": "^2.1.12",
|
|
46
|
+
"publint": "^0.3.24",
|
|
47
|
+
"tsdown": "^0.23.0",
|
|
48
|
+
"typescript": "^7.0.2",
|
|
49
|
+
"vitest": "^5.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"series": [
|
|
3
|
+
{
|
|
4
|
+
"slug": "series",
|
|
5
|
+
"title": "Example Series",
|
|
6
|
+
"synopsis": "A series that exists only to prove the package loads.",
|
|
7
|
+
"tags": ["Action"],
|
|
8
|
+
"authors": ["Example Author"],
|
|
9
|
+
"chapters": [
|
|
10
|
+
{
|
|
11
|
+
"slug": "chapter",
|
|
12
|
+
"title": "Chapter 1",
|
|
13
|
+
"pages": ["https://example.com/pages/1.png", "https://example.com/pages/2.png"]
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#2f3542"/><circle cx="32" cy="32" r="14" fill="none" stroke="#ffffff" stroke-width="4"/></svg>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../node_modules/@aletheia-ios/sdk/dist/json-schema/source.json",
|
|
3
|
+
"slug": "__SLUG__",
|
|
4
|
+
"name": "__NAME__",
|
|
5
|
+
"description": "",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"minAppVersion": "1.4.0",
|
|
8
|
+
"contractVersion": "1.0",
|
|
9
|
+
"languages": ["en"],
|
|
10
|
+
"baseURL": "https://example.com",
|
|
11
|
+
"referer": "https://example.com",
|
|
12
|
+
"hosts": [],
|
|
13
|
+
"contentRating": "mixed",
|
|
14
|
+
"sort": {
|
|
15
|
+
"default": "recent",
|
|
16
|
+
"options": [{ "id": "recent", "name": "Recently Added" }]
|
|
17
|
+
},
|
|
18
|
+
"presets": [
|
|
19
|
+
{
|
|
20
|
+
"id": "latest",
|
|
21
|
+
"name": "Latest Updates",
|
|
22
|
+
"subtitle": "Freshly released",
|
|
23
|
+
"order": 0,
|
|
24
|
+
"sort": "recent"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Source } from "@aletheia-ios/sdk/types";
|
|
2
|
+
import { SourceError } from "@aletheia-ios/sdk/utils";
|
|
3
|
+
import catalogue from "../fixtures/catalogue.json" with { type: "json" };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An offline source: everything it returns is baked in at build time, and `hosts` is `[]`
|
|
7
|
+
* so the app refuses any network call.
|
|
8
|
+
*
|
|
9
|
+
* Replace this file with a real one; the mangadex package in aletheia-ios/sample shows the
|
|
10
|
+
* shape of a source that talks to a site.
|
|
11
|
+
*/
|
|
12
|
+
const source: Source = {
|
|
13
|
+
async search(query) {
|
|
14
|
+
const text = (query.text ?? "").toLowerCase();
|
|
15
|
+
const items = catalogue.series
|
|
16
|
+
.filter((series) => text === "" || series.title.toLowerCase().includes(text))
|
|
17
|
+
.map((series) => ({ slug: series.slug, title: series.title, cover: null, adult: false }));
|
|
18
|
+
return { items, next: null };
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async details(seriesSlug) {
|
|
22
|
+
const series = catalogue.series.find((candidate) => candidate.slug === seriesSlug);
|
|
23
|
+
if (series === undefined) throw new SourceError("notFound", seriesSlug);
|
|
24
|
+
return {
|
|
25
|
+
slug: series.slug,
|
|
26
|
+
title: series.title,
|
|
27
|
+
altTitles: [],
|
|
28
|
+
synopsis: series.synopsis,
|
|
29
|
+
url: `https://example.com/series/${series.slug}`,
|
|
30
|
+
classification: "Safe",
|
|
31
|
+
publication: "Ongoing",
|
|
32
|
+
covers: [],
|
|
33
|
+
tags: series.tags,
|
|
34
|
+
authors: series.authors,
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async chapters(seriesSlug) {
|
|
39
|
+
const series = catalogue.series.find((candidate) => candidate.slug === seriesSlug);
|
|
40
|
+
if (series === undefined) throw new SourceError("notFound", seriesSlug);
|
|
41
|
+
return series.chapters.map((chapter, index) => ({
|
|
42
|
+
slug: chapter.slug,
|
|
43
|
+
title: chapter.title,
|
|
44
|
+
number: index + 1,
|
|
45
|
+
language: "en" as const,
|
|
46
|
+
scanlator: "__NAME__",
|
|
47
|
+
url: `https://example.com/series/${series.slug}/${chapter.slug}`,
|
|
48
|
+
publishedDate: null,
|
|
49
|
+
}));
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async content(seriesSlug, chapterSlug) {
|
|
53
|
+
const series = catalogue.series.find((candidate) => candidate.slug === seriesSlug);
|
|
54
|
+
const chapter = series?.chapters.find((candidate) => candidate.slug === chapterSlug);
|
|
55
|
+
if (chapter === undefined) throw new SourceError("notFound", chapterSlug);
|
|
56
|
+
return chapter.pages.map((url, index) => ({ index, url }));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export default source;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2022",
|
|
4
|
+
"lib": ["es2022"],
|
|
5
|
+
"module": "esnext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"erasableSyntaxOnly": true,
|
|
12
|
+
"noUncheckedIndexedAccess": true,
|
|
13
|
+
"exactOptionalPropertyTypes": true,
|
|
14
|
+
"paths": {
|
|
15
|
+
"@/*": ["./src/*"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"include": ["src", "*.json"]
|
|
19
|
+
}
|