@aletheia-ios/tools 0.1.0 → 0.2.1
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/README.md +13 -0
- package/dist/cli.js +343 -37
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ usage: aletheia <command> [options]
|
|
|
15
15
|
check [--only <slug>] typecheck, build, verify exports and run fixtures under JavaScriptCore
|
|
16
16
|
pack [--only <slug>] build, rasterise the icon and zip to dist/packages/<slug>-v<version>.althsource
|
|
17
17
|
index write dist/<target>/index.json for every list in lists/
|
|
18
|
+
site write dist/<target>/site/ - the page a reader adds the list from
|
|
18
19
|
serve [--port <n>] pack, index, serve dist/ on the lan and rebuild on change
|
|
19
20
|
new <slug> [--name <n>] scaffold packages/<slug> from the template
|
|
20
21
|
live <slug> <series|-> [query]
|
|
@@ -33,6 +34,10 @@ my-sources/
|
|
|
33
34
|
dist/ generated
|
|
34
35
|
```
|
|
35
36
|
|
|
37
|
+
A list may also carry `"url"`, where that target's `index.json` will be served once deployed,
|
|
38
|
+
and `"adult": true`. Only `site` reads them: the first is what the deep link and QR point at,
|
|
39
|
+
the second puts an age gate in front of the page.
|
|
40
|
+
|
|
36
41
|
`aletheia` finds the nearest `packages/` above the working directory. Every package folder
|
|
37
42
|
name is its slug and must match `source.json`.
|
|
38
43
|
|
|
@@ -58,6 +63,14 @@ as one package because of this.
|
|
|
58
63
|
size, the package folder's last commit date) plus `dist/<target>/manifest.json` naming the
|
|
59
64
|
package and icon files that target's deploy has to upload.
|
|
60
65
|
|
|
66
|
+
**site** - for every list that declares a `url`, writes `dist/<target>/site/`: a static page
|
|
67
|
+
with an `aletheia://add-list` deep link, the URL, and a QR inlined as SVG at build time, then
|
|
68
|
+
a card per source carrying its icon, version, languages and rating. Each source's `icon.png`
|
|
69
|
+
and `source.json` are copied in beside the page, so the folder deploys as-is and every card
|
|
70
|
+
can link to the manifest that says what that source contacts. Generated from the same data as
|
|
71
|
+
the index, so the two cannot disagree. A list marked `"adult": true` gets an age gate that
|
|
72
|
+
fails closed without script.
|
|
73
|
+
|
|
61
74
|
**serve** - runs pack and index, serves `dist/` on the LAN with `cache-control: no-store`, and
|
|
62
75
|
reruns both when anything under `packages/` or `lists/` changes. Point the app's developer list
|
|
63
76
|
at the printed URL.
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { cp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { copyFile, cp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { basename, dirname, extname, join, normalize, relative, resolve } from "node:path";
|
|
6
6
|
import { build } from "esbuild";
|
|
7
|
-
import { createReadStream, existsSync, mkdtempSync, watch, writeFileSync } from "node:fs";
|
|
7
|
+
import { createReadStream, existsSync, mkdtempSync, readFileSync, watch, writeFileSync } from "node:fs";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { AuthSpec, Filters, Index, SourceManifest } from "@aletheia-ios/sdk/schemas";
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
@@ -15,6 +15,7 @@ import { z } from "zod";
|
|
|
15
15
|
import vm from "node:vm";
|
|
16
16
|
import { zipSync } from "fflate";
|
|
17
17
|
import { createServer } from "node:http";
|
|
18
|
+
import qrcode from "qrcode-generator";
|
|
18
19
|
//#region src/lib/log.ts
|
|
19
20
|
/** Bytes per kilobyte as the size lines report it. */
|
|
20
21
|
const KB = 1024;
|
|
@@ -75,6 +76,16 @@ function ownRoot() {
|
|
|
75
76
|
/** The folder this cli is installed in, holding `template/` and its own `node_modules/`. */
|
|
76
77
|
const OWN_ROOT = ownRoot();
|
|
77
78
|
/**
|
|
79
|
+
* This cli's own `name@version`, recorded in each index entry.
|
|
80
|
+
*
|
|
81
|
+
* A bundler upgrade can change `main.js` for unchanged source, so two lists can hold the same
|
|
82
|
+
* package at different shas without anyone tampering. This is what tells them apart.
|
|
83
|
+
*/
|
|
84
|
+
const BUILT_WITH = (() => {
|
|
85
|
+
const own = JSON.parse(readFileSync(join(OWN_ROOT, "package.json"), "utf8"));
|
|
86
|
+
return `${own.name}@${own.version}`;
|
|
87
|
+
})();
|
|
88
|
+
/**
|
|
78
89
|
* Locates the repository from a working directory: the nearest ancestor holding `packages/`.
|
|
79
90
|
*
|
|
80
91
|
* @throws `CliError` when no ancestor has one.
|
|
@@ -106,6 +117,16 @@ async function readJSON(path) {
|
|
|
106
117
|
throw new CliError([`${path}: ${error instanceof Error ? error.message : String(error)}`], { cause: error });
|
|
107
118
|
}
|
|
108
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether a manifest slug belongs in a folder of this name.
|
|
122
|
+
*
|
|
123
|
+
* Slugs are reverse-DNS, so `packages/mangadex` holding `com.example.mangadex` keeps the
|
|
124
|
+
* folder list readable. The full slug is accepted too, which is the way out when two
|
|
125
|
+
* publishers' packages in one repository end in the same segment.
|
|
126
|
+
*/
|
|
127
|
+
function namesFolder(slug, folder) {
|
|
128
|
+
return slug === folder || slug.slice(slug.lastIndexOf(".") + 1) === folder;
|
|
129
|
+
}
|
|
109
130
|
/** Turns a failed parse into `file path: message` lines; empty for a successful one. */
|
|
110
131
|
function issues(file, result) {
|
|
111
132
|
if (result.success) return [];
|
|
@@ -120,12 +141,13 @@ function issues(file, result) {
|
|
|
120
141
|
*
|
|
121
142
|
* @throws `CliError` listing every problem found.
|
|
122
143
|
*/
|
|
123
|
-
async function loadPackage(repo,
|
|
124
|
-
const dir = join(repo.packages,
|
|
144
|
+
async function loadPackage(repo, folder) {
|
|
145
|
+
const dir = join(repo.packages, folder);
|
|
146
|
+
const slug = folder;
|
|
125
147
|
const problems = [];
|
|
126
148
|
const manifestResult = SourceManifest.safeParse(await readJSON(join(dir, "source.json")));
|
|
127
149
|
problems.push(...issues(`${slug}/source.json`, manifestResult));
|
|
128
|
-
if (manifestResult.success && manifestResult.data.slug
|
|
150
|
+
if (manifestResult.success && !namesFolder(manifestResult.data.slug, slug)) problems.push(`${slug}/source.json slug: "${manifestResult.data.slug}" must match the folder name or end with it`);
|
|
129
151
|
const filtersResult = Filters.safeParse(await readJSON(join(dir, "filters.json")));
|
|
130
152
|
problems.push(...issues(`${slug}/filters.json`, filtersResult));
|
|
131
153
|
let auth = null;
|
|
@@ -137,7 +159,8 @@ async function loadPackage(repo, slug) {
|
|
|
137
159
|
if (!existsSync(join(dir, "src", "index.ts"))) problems.push(`${slug}/src/index.ts is missing`);
|
|
138
160
|
if (problems.length > 0 || !manifestResult.success || !filtersResult.success) throw new CliError(problems);
|
|
139
161
|
return {
|
|
140
|
-
slug,
|
|
162
|
+
slug: manifestResult.data.slug,
|
|
163
|
+
folder,
|
|
141
164
|
dir,
|
|
142
165
|
manifest: manifestResult.data,
|
|
143
166
|
filters: filtersResult.data,
|
|
@@ -204,7 +227,7 @@ const ZOD_INPUT = /node_modules\/(\.pnpm\/)?zod[/@]/;
|
|
|
204
227
|
* `@aletheia-ios/sdk/schemas` and would ship a validator to every phone.
|
|
205
228
|
*/
|
|
206
229
|
async function buildOne(repo, pkg) {
|
|
207
|
-
const outfile = bundlePath(repo, pkg.
|
|
230
|
+
const outfile = bundlePath(repo, pkg.folder);
|
|
208
231
|
await mkdir(dirname(outfile), { recursive: true });
|
|
209
232
|
const result = await build({
|
|
210
233
|
entryPoints: [join(pkg.dir, "src", "index.ts")],
|
|
@@ -220,7 +243,7 @@ async function buildOne(repo, pkg) {
|
|
|
220
243
|
logLevel: "warning",
|
|
221
244
|
metafile: true
|
|
222
245
|
});
|
|
223
|
-
if (Object.keys(result.metafile.inputs).some((input) => ZOD_INPUT.test(input))) throw new CliError([`${pkg.
|
|
246
|
+
if (Object.keys(result.metafile.inputs).some((input) => ZOD_INPUT.test(input))) throw new CliError([`${pkg.folder}: main.js bundles zod - a source must not import @aletheia-ios/sdk/schemas`]);
|
|
224
247
|
return {
|
|
225
248
|
pkg,
|
|
226
249
|
path: outfile,
|
|
@@ -232,7 +255,7 @@ async function build$1(repo, packages) {
|
|
|
232
255
|
const built = [];
|
|
233
256
|
for (const pkg of packages) {
|
|
234
257
|
const one = await buildOne(repo, pkg);
|
|
235
|
-
info(`${pkg.
|
|
258
|
+
info(`${pkg.folder}: main.js ${kb(one.bytes)}`);
|
|
236
259
|
built.push(one);
|
|
237
260
|
}
|
|
238
261
|
return built;
|
|
@@ -409,7 +432,7 @@ const OPTIONAL = [
|
|
|
409
432
|
function typecheck(repo, pkg) {
|
|
410
433
|
const tsc = join(repo.root, "node_modules", ".bin", "tsc");
|
|
411
434
|
if (!existsSync(tsc)) {
|
|
412
|
-
warn(`${pkg.
|
|
435
|
+
warn(`${pkg.folder}: typescript is not installed in this repository, skipping typecheck`);
|
|
413
436
|
return [];
|
|
414
437
|
}
|
|
415
438
|
const child = spawnSync(tsc, [
|
|
@@ -423,7 +446,7 @@ function typecheck(repo, pkg) {
|
|
|
423
446
|
/** The icon problems for a package as lines, empty when it has exactly one icon. */
|
|
424
447
|
function icon(pkg) {
|
|
425
448
|
try {
|
|
426
|
-
findIcon(pkg.dir, pkg.
|
|
449
|
+
findIcon(pkg.dir, pkg.folder);
|
|
427
450
|
return [];
|
|
428
451
|
} catch (error) {
|
|
429
452
|
if (error instanceof CliError) return error.lines;
|
|
@@ -440,20 +463,20 @@ function icon(pkg) {
|
|
|
440
463
|
async function evaluate$1(pkg, path) {
|
|
441
464
|
const bundle = await readFile(path, "utf8");
|
|
442
465
|
const exported = exportsOf(bundle);
|
|
443
|
-
if (!exported.ok || exported.result === null) return [`${pkg.
|
|
466
|
+
if (!exported.ok || exported.result === null) return [`${pkg.folder}: main.js failed to evaluate in JavaScriptCore\n${exported.output}`];
|
|
444
467
|
const missing = REQUIRED.filter((name) => exported.result?.[name] !== "function");
|
|
445
|
-
if (missing.length > 0) return missing.map((name) => `${pkg.
|
|
468
|
+
if (missing.length > 0) return missing.map((name) => `${pkg.folder}: missing export ${name}()`);
|
|
446
469
|
const capabilities = OPTIONAL.filter((name) => exported.result?.[name] === "function");
|
|
447
470
|
if (pkg.auth !== null) capabilities.push("auth");
|
|
448
|
-
const label = `${pkg.
|
|
471
|
+
const label = `${pkg.folder}: ok [${capabilities.join(", ") || "base"}]`;
|
|
449
472
|
const fixturesPath = join(pkg.dir, "fixtures", "smoke.json");
|
|
450
473
|
if (!existsSync(fixturesPath)) {
|
|
451
|
-
warn(`${pkg.
|
|
474
|
+
warn(`${pkg.folder}: no fixtures/smoke.json - exports verified, calls not exercised`);
|
|
452
475
|
info(label);
|
|
453
476
|
return [];
|
|
454
477
|
}
|
|
455
478
|
const result = smoke(bundle, JSON.parse(await readFile(fixturesPath, "utf8")));
|
|
456
|
-
if (!result.ok || result.result === null) return [`${pkg.
|
|
479
|
+
if (!result.ok || result.result === null) return [`${pkg.folder}: smoke failed in JavaScriptCore\n${result.output}`];
|
|
457
480
|
const out = result.result;
|
|
458
481
|
info(`${label} - "${out.details.title}", ${out.search.items.length} results, ${out.chapters.length} chapters, ${out.content.length} pages`);
|
|
459
482
|
return [];
|
|
@@ -485,10 +508,16 @@ async function check(repo, packages) {
|
|
|
485
508
|
/**
|
|
486
509
|
* Validates one `lists/<name>.json`: the list's display name, the `dist/<target>` folder
|
|
487
510
|
* it publishes to, and the package slugs it includes.
|
|
511
|
+
*
|
|
512
|
+
* `url` is where that target's `index.json` will be served once deployed. Only `site` needs
|
|
513
|
+
* it, to build the deep link and QR a reader adds the list with, so it stays optional until
|
|
514
|
+
* the zone exists. `adult` puts an age gate in front of the generated page.
|
|
488
515
|
*/
|
|
489
516
|
const List = z.strictObject({
|
|
490
517
|
name: z.string().min(1),
|
|
491
518
|
target: z.string().regex(/^[a-z0-9-]+$/),
|
|
519
|
+
url: z.url().optional(),
|
|
520
|
+
adult: z.boolean().optional(),
|
|
492
521
|
sources: z.array(z.string().min(1)).min(1)
|
|
493
522
|
});
|
|
494
523
|
/**
|
|
@@ -538,11 +567,12 @@ function updatedDate(pkg) {
|
|
|
538
567
|
*/
|
|
539
568
|
async function entry(repo, pkg, target) {
|
|
540
569
|
const path = packagePath(repo, pkg);
|
|
541
|
-
if (!existsSync(path)) throw new CliError([`${pkg.
|
|
570
|
+
if (!existsSync(path)) throw new CliError([`${pkg.folder}: not packed - run pack first`]);
|
|
542
571
|
const bytes = await readFile(path);
|
|
543
572
|
const base = join(repo.dist, target);
|
|
544
573
|
return {
|
|
545
574
|
slug: pkg.slug,
|
|
575
|
+
...pkg.manifest.replaces === void 0 ? {} : { replaces: pkg.manifest.replaces },
|
|
546
576
|
name: pkg.manifest.name,
|
|
547
577
|
version: pkg.manifest.version,
|
|
548
578
|
minAppVersion: pkg.manifest.minAppVersion,
|
|
@@ -553,7 +583,8 @@ async function entry(repo, pkg, target) {
|
|
|
553
583
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
554
584
|
updatedDate: updatedDate(pkg),
|
|
555
585
|
downloadURL: relative(base, path),
|
|
556
|
-
iconURL: relative(base, iconPath(repo, pkg.slug))
|
|
586
|
+
iconURL: relative(base, iconPath(repo, pkg.slug)),
|
|
587
|
+
builtWith: BUILT_WITH
|
|
557
588
|
};
|
|
558
589
|
}
|
|
559
590
|
/**
|
|
@@ -605,7 +636,7 @@ function evaluate(bundle, hosts) {
|
|
|
605
636
|
__host: {
|
|
606
637
|
async fetch(request) {
|
|
607
638
|
const { host } = new URL(request.url);
|
|
608
|
-
if (!hosts.
|
|
639
|
+
if (!hosts.some((name) => host === name || host.endsWith(`.${name}`))) throw new Error(`host not allowed by source.json: ${host}`);
|
|
609
640
|
const response = await fetch(request.url, {
|
|
610
641
|
method: request.method ?? "GET",
|
|
611
642
|
headers: {
|
|
@@ -680,8 +711,8 @@ const TEXT = /* @__PURE__ */ new Set([
|
|
|
680
711
|
".ts",
|
|
681
712
|
".md"
|
|
682
713
|
]);
|
|
683
|
-
/** A valid package slug:
|
|
684
|
-
const SLUG = /^[a-z0-9-]+$/;
|
|
714
|
+
/** A valid package slug: reverse-DNS, matching the sdk's `slug` scalar. */
|
|
715
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)+$/;
|
|
685
716
|
/** Rewrites every text file under `dir`, replacing each placeholder wherever it appears. */
|
|
686
717
|
async function substitute(dir, values) {
|
|
687
718
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
@@ -707,12 +738,13 @@ async function substitute(dir, values) {
|
|
|
707
738
|
* already exists.
|
|
708
739
|
*/
|
|
709
740
|
async function create(repo, slug, name) {
|
|
710
|
-
if (!SLUG.test(slug)) throw new CliError([`"${slug}" is not a slug -
|
|
711
|
-
const
|
|
712
|
-
|
|
741
|
+
if (!SLUG.test(slug)) throw new CliError([`"${slug}" is not a slug - reverse-dns, such as com.example.mysite`]);
|
|
742
|
+
const folder = slug.slice(slug.lastIndexOf(".") + 1);
|
|
743
|
+
const dir = join(repo.packages, folder);
|
|
744
|
+
if (existsSync(dir)) throw new CliError([`packages/${folder} already exists`]);
|
|
713
745
|
await cp(TEMPLATE, dir, { recursive: true });
|
|
714
746
|
await substitute(dir, [["__SLUG__", slug], ["__NAME__", name]]);
|
|
715
|
-
info(`created packages/${
|
|
747
|
+
info(`created packages/${folder} - it passes \`aletheia check\` as an offline source; replace src/ with the real one`);
|
|
716
748
|
}
|
|
717
749
|
//#endregion
|
|
718
750
|
//#region src/lib/zip.ts
|
|
@@ -754,7 +786,7 @@ async function pack(repo, packages) {
|
|
|
754
786
|
const built = await build$1(repo, packages);
|
|
755
787
|
const packed = [];
|
|
756
788
|
for (const { pkg, path } of built) {
|
|
757
|
-
const icon = await rasterise(findIcon(pkg.dir, pkg.
|
|
789
|
+
const icon = await rasterise(findIcon(pkg.dir, pkg.folder), pkg.folder);
|
|
758
790
|
const files = {
|
|
759
791
|
"source.json": await readFile(join(pkg.dir, "source.json")),
|
|
760
792
|
"filters.json": await readFile(join(pkg.dir, "filters.json")),
|
|
@@ -770,7 +802,7 @@ async function pack(repo, packages) {
|
|
|
770
802
|
await writeFile(out, zip);
|
|
771
803
|
await writeFile(iconOut, icon);
|
|
772
804
|
const sha256 = createHash("sha256").update(zip).digest("hex");
|
|
773
|
-
info(`${pkg.
|
|
805
|
+
info(`${pkg.folder}: ${out.slice(repo.root.length + 1)} ${kb(zip.byteLength)} ${sha256.slice(0, SHORT_HASH)}`);
|
|
774
806
|
packed.push({
|
|
775
807
|
pkg,
|
|
776
808
|
path: out,
|
|
@@ -782,13 +814,247 @@ async function pack(repo, packages) {
|
|
|
782
814
|
return packed;
|
|
783
815
|
}
|
|
784
816
|
//#endregion
|
|
817
|
+
//#region src/lib/html.ts
|
|
818
|
+
/** Error correction level; M survives a phone camera at an angle without doubling the size. */
|
|
819
|
+
const QR_CORRECTION = "M";
|
|
820
|
+
/** Auto-select the smallest QR version that fits the data. */
|
|
821
|
+
const QR_AUTO_VERSION = 0;
|
|
822
|
+
/** Module size in the generated SVG; the viewBox scales it, so this only sets the aspect grid. */
|
|
823
|
+
const QR_CELL = 4;
|
|
824
|
+
/**
|
|
825
|
+
* Escapes text for use in HTML markup or a double-quoted attribute.
|
|
826
|
+
*
|
|
827
|
+
* Everything interpolated into a page comes from a package's own json, so it is author-supplied
|
|
828
|
+
* rather than trusted.
|
|
829
|
+
*/
|
|
830
|
+
function escapeHTML(value) {
|
|
831
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Renders a QR code for a URL as a standalone, scalable `<svg>` element.
|
|
835
|
+
*
|
|
836
|
+
* Generated at build time so the page needs no script and no network to draw it.
|
|
837
|
+
*/
|
|
838
|
+
function qrSVG(url) {
|
|
839
|
+
const code = qrcode(QR_AUTO_VERSION, QR_CORRECTION);
|
|
840
|
+
code.addData(url);
|
|
841
|
+
code.make();
|
|
842
|
+
return code.createSvgTag({
|
|
843
|
+
cellSize: QR_CELL,
|
|
844
|
+
margin: 0,
|
|
845
|
+
scalable: true
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
//#endregion
|
|
849
|
+
//#region src/commands/site.ts
|
|
850
|
+
/** Where a target's generated page and its assets are written. */
|
|
851
|
+
function sitePath(repo, target) {
|
|
852
|
+
return join(repo.dist, target, "site");
|
|
853
|
+
}
|
|
854
|
+
const STYLE = `
|
|
855
|
+
:root {
|
|
856
|
+
--bg: #fbfbfd; --fg: #16161a; --muted: #6b6b76; --line: #e4e4ea;
|
|
857
|
+
--card: #ffffff; --accent: #4c5cff; --accent-fg: #ffffff;
|
|
858
|
+
}
|
|
859
|
+
@media (prefers-color-scheme: dark) {
|
|
860
|
+
:root {
|
|
861
|
+
--bg: #0f0f12; --fg: #f2f2f5; --muted: #9a9aa5; --line: #26262e;
|
|
862
|
+
--card: #17171c; --accent: #7c88ff; --accent-fg: #0f0f12;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
* { box-sizing: border-box; }
|
|
866
|
+
body {
|
|
867
|
+
margin: 0; padding: 3rem 1.25rem 5rem; background: var(--bg); color: var(--fg);
|
|
868
|
+
font: 16px/1.55 ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
869
|
+
}
|
|
870
|
+
main { max-width: 46rem; margin: 0 auto; }
|
|
871
|
+
h1 { font-size: 1.9rem; margin: 0 0 .35rem; letter-spacing: -.02em; }
|
|
872
|
+
.lede { color: var(--muted); margin: 0 0 2rem; }
|
|
873
|
+
.add { display: flex; align-items: flex-start; gap: 1.75rem; flex-wrap: wrap; margin-bottom: 2.75rem; }
|
|
874
|
+
.add-main { flex: 1 1 20rem; min-width: 0; }
|
|
875
|
+
.actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-bottom: 1rem; }
|
|
876
|
+
.button {
|
|
877
|
+
display: inline-block; padding: .7rem 1.15rem; border-radius: .6rem; border: 1px solid var(--line);
|
|
878
|
+
background: var(--card); color: var(--fg); font: inherit; font-weight: 600; cursor: pointer;
|
|
879
|
+
text-decoration: none; line-height: 1.2;
|
|
880
|
+
}
|
|
881
|
+
.button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
|
|
882
|
+
.url {
|
|
883
|
+
font: .8rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted);
|
|
884
|
+
word-break: break-all; margin: 0;
|
|
885
|
+
}
|
|
886
|
+
.qr { width: 7.25rem; height: 7.25rem; padding: .45rem; background: #fff; border-radius: .6rem; flex: none; }
|
|
887
|
+
.qr svg { display: block; width: 100%; height: 100%; }
|
|
888
|
+
.qr rect:first-of-type { fill: #fff; }
|
|
889
|
+
h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 0 0 .85rem; }
|
|
890
|
+
.source {
|
|
891
|
+
display: flex; align-items: flex-start; gap: 1rem; padding: 1rem; margin-bottom: .75rem;
|
|
892
|
+
background: var(--card); border: 1px solid var(--line); border-radius: .75rem;
|
|
893
|
+
}
|
|
894
|
+
.source img { width: 3rem; height: 3rem; border-radius: .55rem; flex: none; }
|
|
895
|
+
.source div { min-width: 0; }
|
|
896
|
+
.source h3 { margin: 0 0 .3rem; font-size: 1rem; line-height: 1.3; }
|
|
897
|
+
.source p { margin: 0 0 .7rem; color: var(--muted); font-size: .9rem; }
|
|
898
|
+
.meta { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; font-size: .75rem; color: var(--muted); }
|
|
899
|
+
.tag {
|
|
900
|
+
display: inline-block; padding: .2rem .55rem; border: 1px solid var(--line);
|
|
901
|
+
border-radius: 100px; line-height: 1.4;
|
|
902
|
+
}
|
|
903
|
+
a.tag { color: inherit; text-decoration: none; }
|
|
904
|
+
a.tag:hover { color: var(--fg); border-color: var(--accent); }
|
|
905
|
+
footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .85rem; }
|
|
906
|
+
footer p { margin: 0 0 .7rem; max-width: 38rem; }
|
|
907
|
+
footer p:last-child { margin-bottom: 0; }
|
|
908
|
+
#gate { text-align: center; padding: 4rem 0; }
|
|
909
|
+
#gate + * { display: none; }
|
|
910
|
+
`;
|
|
911
|
+
const SCRIPT = `
|
|
912
|
+
document.querySelectorAll("[data-copy]").forEach(function (button) {
|
|
913
|
+
button.addEventListener("click", function () {
|
|
914
|
+
navigator.clipboard.writeText(button.dataset.copy).then(function () {
|
|
915
|
+
var previous = button.textContent;
|
|
916
|
+
button.textContent = "Copied";
|
|
917
|
+
setTimeout(function () { button.textContent = previous; }, 1500);
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
var gate = document.getElementById("gate");
|
|
922
|
+
if (gate) {
|
|
923
|
+
gate.querySelector("button").addEventListener("click", function () {
|
|
924
|
+
gate.remove();
|
|
925
|
+
document.getElementById("list").style.display = "block";
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
`;
|
|
929
|
+
/** The age gate shown ahead of an adult list; without script the list stays hidden. */
|
|
930
|
+
function gate() {
|
|
931
|
+
return `<section id="gate">
|
|
932
|
+
<h2>Adults only</h2>
|
|
933
|
+
<p class="lede">This list contains explicit material. Confirm you are 18 or older.</p>
|
|
934
|
+
<button class="button primary" type="button">I am 18 or older</button>
|
|
935
|
+
</section>`;
|
|
936
|
+
}
|
|
937
|
+
/** One source's row: icon, name, description and what the app will record about it. */
|
|
938
|
+
function card(pkg) {
|
|
939
|
+
const { manifest } = pkg;
|
|
940
|
+
const languages = manifest.languages.map((code) => code.toUpperCase()).join(", ");
|
|
941
|
+
const rating = manifest.contentRating === "adult" ? "18+" : "Mixed ratings";
|
|
942
|
+
return `<article class="source">
|
|
943
|
+
<img src="icons/${escapeHTML(pkg.slug)}.png" alt="" width="48" height="48">
|
|
944
|
+
<div>
|
|
945
|
+
<h3>${escapeHTML(manifest.name)}</h3>
|
|
946
|
+
<p>${escapeHTML(manifest.description)}</p>
|
|
947
|
+
<div class="meta">
|
|
948
|
+
<span class="tag">v${escapeHTML(manifest.version)}</span>
|
|
949
|
+
<span class="tag">${escapeHTML(languages)}</span>
|
|
950
|
+
<span class="tag">${rating}</span>
|
|
951
|
+
<a class="tag" href="sources/${escapeHTML(pkg.slug)}.json">source.json</a>
|
|
952
|
+
</div>
|
|
953
|
+
</div>
|
|
954
|
+
</article>`;
|
|
955
|
+
}
|
|
956
|
+
/** The add buttons: a deep link for a phone, the URL and a QR for a desktop browser. */
|
|
957
|
+
function actions(url) {
|
|
958
|
+
return `<div class="add">
|
|
959
|
+
<div class="add-main">
|
|
960
|
+
<div class="actions">
|
|
961
|
+
<a class="button primary" href="${escapeHTML(`aletheia://add-list?url=${encodeURIComponent(url)}`)}">Open in Aletheia</a>
|
|
962
|
+
<button class="button" type="button" data-copy="${escapeHTML(url)}">Copy URL</button>
|
|
963
|
+
</div>
|
|
964
|
+
<p class="url">${escapeHTML(url)}</p>
|
|
965
|
+
</div>
|
|
966
|
+
<div class="qr">${qrSVG(url)}</div>
|
|
967
|
+
</div>`;
|
|
968
|
+
}
|
|
969
|
+
/** The notice every published list carries. */
|
|
970
|
+
function notice() {
|
|
971
|
+
return `<footer>
|
|
972
|
+
<p>This list is not an official part of Aletheia, and it is not connected to, endorsed by,
|
|
973
|
+
or approved by any of the sites named above. Their names and logos belong to them.</p>
|
|
974
|
+
<p>No comics, images or accounts are stored or shared here. An entry only tells the app
|
|
975
|
+
where to go looking, and you can open any entry to see the sites it uses.</p>
|
|
976
|
+
<p>If you would like something taken off this list, get in touch and it will be removed.</p>
|
|
977
|
+
</footer>`;
|
|
978
|
+
}
|
|
979
|
+
/** The whole page for one list. */
|
|
980
|
+
function page(list, url, members) {
|
|
981
|
+
const heading = escapeHTML(list.name);
|
|
982
|
+
const count = members.length === 1 ? "1 source" : `${members.length} sources`;
|
|
983
|
+
return `<!doctype html>
|
|
984
|
+
<html lang="en">
|
|
985
|
+
<head>
|
|
986
|
+
<meta charset="utf-8">
|
|
987
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
988
|
+
<title>${heading}</title>
|
|
989
|
+
<style>${STYLE}</style>
|
|
990
|
+
</head>
|
|
991
|
+
<body>
|
|
992
|
+
<main>
|
|
993
|
+
<h1>${heading}</h1>
|
|
994
|
+
<p class="lede">Opens in Aletheia with this address filled in. You confirm before anything
|
|
995
|
+
is added, and the app keeps the list up to date afterwards.</p>
|
|
996
|
+
${list.adult === true ? gate() : ""}
|
|
997
|
+
<div id="list"${list.adult === true ? " style=\"display:none\"" : ""}>
|
|
998
|
+
${actions(url)}
|
|
999
|
+
<h2>${count}</h2>
|
|
1000
|
+
${members.map(card).join("\n ")}
|
|
1001
|
+
</div>
|
|
1002
|
+
${notice()}
|
|
1003
|
+
</main>
|
|
1004
|
+
<script>${SCRIPT}<\/script>
|
|
1005
|
+
</body>
|
|
1006
|
+
</html>
|
|
1007
|
+
`;
|
|
1008
|
+
}
|
|
1009
|
+
/** Copies the icon and manifest each card links to, so the page stands alone once deployed. */
|
|
1010
|
+
async function assets(repo, out, members) {
|
|
1011
|
+
await mkdir(join(out, "icons"), { recursive: true });
|
|
1012
|
+
await mkdir(join(out, "sources"), { recursive: true });
|
|
1013
|
+
for (const pkg of members) {
|
|
1014
|
+
const icon = iconPath(repo, pkg.slug);
|
|
1015
|
+
if (!existsSync(icon)) throw new CliError([`${pkg.folder}: not packed - run pack first`]);
|
|
1016
|
+
await copyFile(icon, join(out, "icons", `${pkg.slug}.png`));
|
|
1017
|
+
await copyFile(join(pkg.dir, "source.json"), join(out, "sources", `${pkg.slug}.json`));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* The `site` command: writes `dist/<target>/site/` for every list that declares a `url`.
|
|
1022
|
+
*
|
|
1023
|
+
* The page is generated from the same `lists/` and `source.json` data as the index it
|
|
1024
|
+
* advertises, so the two cannot disagree. It is static and self-contained: the QR is inlined
|
|
1025
|
+
* at build time and each source's icon and manifest are copied in beside it.
|
|
1026
|
+
*
|
|
1027
|
+
* A list with no `url` is skipped with a note, since the deep link and QR have nothing to
|
|
1028
|
+
* point at until that target's zone exists.
|
|
1029
|
+
*
|
|
1030
|
+
* @throws `CliError` when a list names a package that does not exist or is not packed.
|
|
1031
|
+
*/
|
|
1032
|
+
async function site(repo, packages) {
|
|
1033
|
+
const bySlug = new Map(packages.map((pkg) => [pkg.slug, pkg]));
|
|
1034
|
+
for (const list of await loadLists(repo)) {
|
|
1035
|
+
const missing = list.sources.filter((slug) => !bySlug.has(slug));
|
|
1036
|
+
if (missing.length > 0) throw new CliError(missing.map((slug) => `list "${list.name}": no package "${slug}"`));
|
|
1037
|
+
if (list.url === void 0) {
|
|
1038
|
+
info(`${list.target}: no url in the list, skipping the page`);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const members = list.sources.map((slug) => bySlug.get(slug));
|
|
1042
|
+
const out = sitePath(repo, list.target);
|
|
1043
|
+
await mkdir(out, { recursive: true });
|
|
1044
|
+
await assets(repo, out, members);
|
|
1045
|
+
await writeFile(join(out, "index.html"), page(list, list.url, members));
|
|
1046
|
+
info(`${list.target}/site/index.html: ${members.length} source(s)`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
//#endregion
|
|
785
1050
|
//#region src/commands/serve.ts
|
|
786
1051
|
/** Content types for what `dist/` holds; anything else is served as octet-stream. */
|
|
787
1052
|
const TYPES = {
|
|
788
1053
|
".json": "application/json",
|
|
789
1054
|
".png": "image/png",
|
|
790
1055
|
".althsource": "application/zip",
|
|
791
|
-
".js": "text/javascript"
|
|
1056
|
+
".js": "text/javascript",
|
|
1057
|
+
".html": "text/html; charset=utf-8"
|
|
792
1058
|
};
|
|
793
1059
|
/** Leading `../` segments left after normalisation, which would escape `dist/`. */
|
|
794
1060
|
const TRAVERSAL = /^(\.\.[/\\])+/;
|
|
@@ -807,6 +1073,7 @@ async function rebuild(repo) {
|
|
|
807
1073
|
const packages = await loadPackages(repo);
|
|
808
1074
|
await pack(repo, packages);
|
|
809
1075
|
await indexes(repo, packages);
|
|
1076
|
+
await site(repo, packages);
|
|
810
1077
|
} catch (error) {
|
|
811
1078
|
report(error);
|
|
812
1079
|
}
|
|
@@ -819,8 +1086,9 @@ function lanAddress() {
|
|
|
819
1086
|
/**
|
|
820
1087
|
* The file under `dist/` a request names, or null when it names nothing servable.
|
|
821
1088
|
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
1089
|
+
* A directory resolves to its `index.html` when it has one, the way Pages will serve the
|
|
1090
|
+
* generated site. Null covers a missing file, a directory without one, a path that would
|
|
1091
|
+
* escape `dist/`, and a URL that does not decode.
|
|
824
1092
|
*/
|
|
825
1093
|
async function resolveFile(dist, url) {
|
|
826
1094
|
let path;
|
|
@@ -829,8 +1097,11 @@ async function resolveFile(dist, url) {
|
|
|
829
1097
|
} catch {
|
|
830
1098
|
return null;
|
|
831
1099
|
}
|
|
832
|
-
const
|
|
833
|
-
|
|
1100
|
+
const target = join(dist, path);
|
|
1101
|
+
if (!(target.startsWith(dist) && existsSync(target))) return null;
|
|
1102
|
+
const file = (await stat(target)).isDirectory() ? join(target, "index.html") : target;
|
|
1103
|
+
if (!(existsSync(file) && (await stat(file)).isFile())) return null;
|
|
1104
|
+
return file;
|
|
834
1105
|
}
|
|
835
1106
|
/** A static server over `dist/` that sends `cache-control: no-store` so the app never caches a dev build. */
|
|
836
1107
|
function fileServer(dist) {
|
|
@@ -860,25 +1131,56 @@ function watchRepo(repo) {
|
|
|
860
1131
|
return [watch(repo.packages, { recursive: true }, trigger), watch(repo.lists, { recursive: true }, trigger)];
|
|
861
1132
|
}
|
|
862
1133
|
/**
|
|
1134
|
+
* Binds the port and reports the one it got, since port 0 picks a free one.
|
|
1135
|
+
*
|
|
1136
|
+
* A busy port is the common way this fails and it arrives as an `error` event rather than a
|
|
1137
|
+
* rejection, so without this it surfaces as an unhandled event and a stack trace.
|
|
1138
|
+
*
|
|
1139
|
+
* @throws `CliError` naming the port when something already holds it.
|
|
1140
|
+
*/
|
|
1141
|
+
async function listen(server, port) {
|
|
1142
|
+
try {
|
|
1143
|
+
await new Promise((resolve, reject) => {
|
|
1144
|
+
server.once("error", reject);
|
|
1145
|
+
server.listen(port, "0.0.0.0", () => {
|
|
1146
|
+
server.removeListener("error", reject);
|
|
1147
|
+
resolve();
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
if (error.code === "EADDRINUSE") throw new CliError([`port ${port} is already in use - pass --port <n> to pick another`], { cause: error });
|
|
1152
|
+
throw error;
|
|
1153
|
+
}
|
|
1154
|
+
const address = server.address();
|
|
1155
|
+
return typeof address === "object" && address !== null ? address.port : port;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
863
1158
|
* The `serve` command: packs, indexes, serves `dist/` on the lan and rebuilds on change.
|
|
864
1159
|
*
|
|
865
1160
|
* Binds every interface and prints the lan URL of each list's `index.json`, which is what
|
|
866
1161
|
* the app's developer list points at. Port 0 binds a free port. Resolves once listening;
|
|
867
1162
|
* the returned `close` stops the watchers and the server.
|
|
868
1163
|
*
|
|
869
|
-
* @throws `CliError` when there is no `lists/` directory,
|
|
1164
|
+
* @throws `CliError` when there is no `lists/` directory, or when the port is taken.
|
|
870
1165
|
*/
|
|
871
1166
|
async function serve(repo, port) {
|
|
872
1167
|
const lists = await loadLists(repo);
|
|
873
1168
|
await rebuild(repo);
|
|
874
1169
|
const server = fileServer(repo.dist);
|
|
875
1170
|
const watchers = watchRepo(repo);
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1171
|
+
let bound;
|
|
1172
|
+
try {
|
|
1173
|
+
bound = await listen(server, port);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
for (const watcher of watchers) watcher.close();
|
|
1176
|
+
throw error;
|
|
1177
|
+
}
|
|
879
1178
|
const url = `http://${lanAddress()}:${bound}/`;
|
|
880
1179
|
info(`serving ${repo.dist} on ${url}`);
|
|
881
|
-
for (const list of lists)
|
|
1180
|
+
for (const list of lists) {
|
|
1181
|
+
info(` ${list.name}: ${url}${list.target}/index.json`);
|
|
1182
|
+
if (existsSync(sitePath(repo, list.target))) info(` ${" ".repeat(list.name.length)} ${url}${list.target}/site/`);
|
|
1183
|
+
}
|
|
882
1184
|
return {
|
|
883
1185
|
url,
|
|
884
1186
|
close: () => {
|
|
@@ -896,6 +1198,7 @@ const USAGE = `usage: aletheia <command> [options]
|
|
|
896
1198
|
check [--only <slug>] typecheck, build, verify exports and run fixtures under JavaScriptCore
|
|
897
1199
|
pack [--only <slug>] build, rasterise the icon and zip to dist/packages/<slug>-v<version>.althsource
|
|
898
1200
|
index write dist/<target>/index.json for every list in lists/
|
|
1201
|
+
site write dist/<target>/site/ - the page a reader adds the list from
|
|
899
1202
|
serve [--port <n>] pack, index, serve dist/ on the lan and rebuild on change
|
|
900
1203
|
new <slug> [--name <n>] scaffold packages/<slug> from the template
|
|
901
1204
|
live <slug> <series|-> [query]
|
|
@@ -949,6 +1252,9 @@ async function main(argv) {
|
|
|
949
1252
|
case "index":
|
|
950
1253
|
await indexes(repo, await loadPackages(repo));
|
|
951
1254
|
return;
|
|
1255
|
+
case "site":
|
|
1256
|
+
await site(repo, await loadPackages(repo));
|
|
1257
|
+
return;
|
|
952
1258
|
case "serve":
|
|
953
1259
|
await serve(repo, Number.parseInt(values.port ?? DEFAULT_PORT, 10));
|
|
954
1260
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aletheia-ios/tools",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The aletheia CLI: build, check, pack, index and serve source packages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,9 +32,10 @@
|
|
|
32
32
|
"prepare": "lefthook install"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@aletheia-ios/sdk": "^0.
|
|
35
|
+
"@aletheia-ios/sdk": "^0.4.0",
|
|
36
36
|
"esbuild": "^0.28.2",
|
|
37
37
|
"fflate": "^0.8.3",
|
|
38
|
+
"qrcode-generator": "^2.0.4",
|
|
38
39
|
"sharp": "^0.35.4",
|
|
39
40
|
"zod": "^4.5.4"
|
|
40
41
|
},
|