@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.
Files changed (3) hide show
  1. package/README.md +13 -0
  2. package/dist/cli.js +343 -37
  3. 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, slug) {
124
- const dir = join(repo.packages, slug);
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 !== slug) problems.push(`${slug}/source.json slug: "${manifestResult.data.slug}" must match the folder name`);
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.slug);
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.slug}: main.js bundles zod - a source must not import @aletheia-ios/sdk/schemas`]);
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.slug}: main.js ${kb(one.bytes)}`);
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.slug}: typescript is not installed in this repository, skipping typecheck`);
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.slug);
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.slug}: main.js failed to evaluate in JavaScriptCore\n${exported.output}`];
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.slug}: missing export ${name}()`);
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.slug}: ok [${capabilities.join(", ") || "base"}]`;
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.slug}: no fixtures/smoke.json - exports verified, calls not exercised`);
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.slug}: smoke failed in JavaScriptCore\n${result.output}`];
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.slug}: not packed - run pack first`]);
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.includes(host)) throw new Error(`host not allowed by source.json: ${host}`);
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: lowercase letters, digits and hyphens. */
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 - lowercase letters, digits and hyphens only`]);
711
- const dir = join(repo.packages, slug);
712
- if (existsSync(dir)) throw new CliError([`packages/${slug} already exists`]);
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/${slug} - it passes \`aletheia check\` as an offline source; replace src/ with the real one`);
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.slug), pkg.slug);
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.slug}: ${out.slice(repo.root.length + 1)} ${kb(zip.byteLength)} ${sha256.slice(0, SHORT_HASH)}`);
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
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
- * Null covers a missing file, a directory, a path that would escape `dist/`, and a URL
823
- * that does not decode.
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 file = join(dist, path);
833
- return file.startsWith(dist) && existsSync(file) && (await stat(file)).isFile() ? file : null;
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, before any port is bound.
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
- 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;
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) info(` ${list.name}: ${url}${list.target}/index.json`);
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.0",
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.1.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
  },