create-mithril-lynx 2.0.1 → 2.2.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/README.md CHANGED
@@ -49,11 +49,49 @@ Every flag, in one table — `npx create-mithril-lynx --help` prints the same li
49
49
  | `--android` / `--target android` / `--target=android` / `target=android` | Also scaffold the sibling `<name>-android/` Gradle project. |
50
50
  | `--android-id <id>` | `applicationId` / `namespace` (default `com.example.<name>`). |
51
51
  | `--app-name <name>` | Launcher label (default: the project name). |
52
- | `--with-font <file.ttf>` | Copy the font into the host's assets, generate `AssetFontFaceLoader.kt`, and wire the cold-start `prefetchFont()` hack (Part D of the guide). |
53
- | `--font-family <name>` | Override the family name derived from the font's file name. Only meaningful with `--with-font`. |
52
+ | `--with-font <file.ttf>` | Bundle the font into `src/assets/fonts/` and register it with `lynx.addFont()`. No native code: the import is inlined as a `data:` URI (`dataUriLimit: Infinity` in `lynx.config.ts`), which resolves the same way on every host — the real APK, LynxExplorer, or Lynx Go. Confirmed on device: no cold-start cost either (~730ms with the font vs. ~770ms without, on the same device). Comma-separate for more than one file. |
53
+ | `--find-font <term>` | Search [Fontsource](https://fontsource.org)'s catalog for `<term>`, prompt you to pick a family and a weight/style, download that one `.ttf`, and bundle it exactly like `--with-font`. Needs a real terminal (the picking is inherently interactive — use `--with-font <file.ttf>` in scripts/CI). Mutually exclusive with `--with-font`. Comma-separate terms for more than one — quote the whole thing if any term has a space. |
54
+ | `--font-family <name>` | Override the family name derived from the font's file name (or from Fontsource, with `--find-font`). Only valid with exactly one font. |
54
55
 
55
56
  Anything not listed — in particular the four Android flags — requires `--android`
56
- (or one of its aliases); `--with-font` implies it on its own.
57
+ (or one of its aliases); `--with-font`/`--find-font` imply it on their own.
58
+
59
+ ### More than one font
60
+
61
+ Both flags take a comma-separated list — useful for a body font plus a
62
+ monospace one for code, for example:
63
+
64
+ ```bash
65
+ npx create-mithril-lynx my-app --blank --android --find-font "Inter,JetBrains Mono"
66
+ ```
67
+
68
+ Each font gets its own `import`/`lynx.addFont()` call in `src/background.ts`.
69
+ Only the **first** one gets the automatic `text { font-family: ...; }` rule
70
+ in `src/style.css` (there's no way for the CLI to guess which elements
71
+ should use which font past that) — the rest are registered and ready to use,
72
+ but you assign them to your own classes by hand, e.g.:
73
+
74
+ ```css
75
+ .code {
76
+ font-family: "JetBrains Mono", monospace;
77
+ }
78
+ ```
79
+
80
+ ### `--find-font`, in more detail
81
+
82
+ Fontsource's own API (`api.fontsource.org`) has no free-text search — only
83
+ exact `id`/`family` filters — so `--find-font` downloads the whole font list
84
+ once (~2100 fonts, ~540KB as of 2026-09), caches it for 24h in the OS temp
85
+ directory, and filters client-side on `family`/`id` substrings. Picking a
86
+ family fetches that font's own detail (its real weight/style/subset `.ttf`
87
+ URLs), lets you pick one variant (defaulting to 400/normal if available),
88
+ downloads it to a temp file, and hands it to the same code path
89
+ `--with-font <file>` uses — there's no separate font-loading mechanism to
90
+ maintain.
91
+
92
+ ```bash
93
+ npx create-mithril-lynx my-app --blank --android --find-font Inter
94
+ ```
57
95
 
58
96
  ### What the generated Android host gives you
59
97
 
@@ -61,7 +99,7 @@ The sibling `<name>-android/` project is a complete, no-Android-Studio Gradle CL
61
99
 
62
100
  - the Gradle wrapper (`gradlew`, `gradlew.bat`, `gradle-wrapper.jar`), so nothing needs to be installed besides a JDK and the Android SDK;
63
101
  - `local.properties` with `sdk.dir` auto-detected from `ANDROID_HOME`/`ANDROID_SDK_ROOT` (with a written warning if it can't be found);
64
- - the Kotlin host: the `Application`, `MainActivity` (async `AssetTemplateProvider`, splash screen), and the font loader when `--with-font` is used;
102
+ - the Kotlin host: the `Application`, `MainActivity` (async `AssetTemplateProvider`, splash screen) no font-specific code needed, `--with-font` is entirely JS-side;
65
103
  - resource files that compile as-is (theme, splash theme, adaptive launcher icon);
66
104
  - an opt-in release signing setup driven by a gitignored `keystore.properties`.
67
105
 
@@ -90,6 +128,7 @@ This part of the tool is framework-agnostic — it just wraps whatever bundle `r
90
128
  ```
91
129
  create-mithril-lynx/
92
130
  src/index.js the CLI itself
131
+ src/fontsource.js --find-font: local search over Fontsource's catalog
93
132
  templates/
94
133
  _shared/
95
134
  common/ gitignore, project README — shared by every template
@@ -107,13 +146,12 @@ create-mithril-lynx/
107
146
  ts/src/{app-bar.ts, background.ts, screens/{home,detail}.ts}
108
147
  android/ only used with --android
109
148
  host/ the Gradle project -> <name>-android/
110
- font/AssetFontFaceLoader.kt copied only when --with-font is used
111
149
  app-scripts/android.mjs the bridge -> <name>/scripts/android.mjs
112
150
  ```
113
151
 
114
152
  `src/index.js` copies, in order, `templates/_shared/common` → `templates/_shared/ts` → `templates/<template>/common` → `templates/<template>/ts` into the target directory — later copies overwrite same-named files from earlier ones, which is exactly how **Basic Activity**'s own routing-based `src/background.ts` replaces `_shared/ts`'s generic single-view one (Hello World and Blank don't ship their own, so they keep the shared file). It then renames `gitignore` to `.gitignore` (npm doesn't publish dotfiles reliably otherwise) and replaces `{{PROJECT_NAME}}`/`{{MITHRIL_LYNX_VERSION}}` placeholders in `package.json` and `README.md`.
115
153
 
116
- With `--android` it then copies `templates/android/host` into a sibling `<name>-android/` (renaming `package-path` to the real package directory and `App.kt` to the Application class), substitutes `{{PACKAGE_NAME}}`, `{{APP_CLASS}}`, `{{APP_NAME}}`, `{{ANDROID_DIR}}` and `{{SDK_DIR}}` across the tree, and fills in the four font-hack hooks (`{{FONT_LOADER_IMPORT}}`, `{{FONT_LOADER_REGISTRATION}}`, `{{FONT_IMPORT}}`, `{{FONT_PREFETCH}}`) with either the real code or nothing at all. Binary files (the Gradle wrapper jar, the `.ttf`) are never text-substituted.
154
+ With `--android` it then copies `templates/android/host` into a sibling `<name>-android/` (renaming `package-path` to the real package directory and `App.kt` to the Application class) and substitutes `{{PACKAGE_NAME}}`, `{{APP_CLASS}}`, `{{APP_NAME}}`, `{{ANDROID_DIR}}` and `{{SDK_DIR}}` across the tree. Binary files (the Gradle wrapper jar, a `--with-font` `.ttf`) are never text-substituted. With `--with-font`, the font itself is copied into the *JS* project (`src/assets/fonts/`) and wired up with `lynx.addFont()` in `src/background.ts` plus a `text { font-family: ... }` rule in `src/style.css` — the Android host needs no font-specific code at all (see the `--with-font` row above).
117
155
 
118
156
  **Not carried over from v1 (yet)**: the JavaScript variant. The old tool generated either TypeScript or JavaScript for every template; this rewrite ships TypeScript only for now — doubling every template for a parallel JS copy wasn't part of this pass.
119
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mithril-lynx",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Scaffolds a new mithril-lynx app (and, optionally, its Android host).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,102 @@
1
+ // src/fontsource.js
2
+ //
3
+ // Local search over Fontsource's font catalog (https://fontsource.org). The
4
+ // public API (https://api.fontsource.org/v1/fonts) has no free-text search —
5
+ // only exact `id`/`family` filters (confirmed against its own docs) — so
6
+ // this fetches the whole list once (~2100 fonts, ~540KB as of 2026-09) and
7
+ // filters client-side. Cached to disk (24h TTL) so repeated searches in one
8
+ // session, or across nearby `npx`/`bunx` invocations, don't re-download it
9
+ // every time.
10
+
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+
15
+ const FONTS_LIST_URL = "https://api.fontsource.org/v1/fonts";
16
+ const FONT_DETAIL_URL = (id) => `https://api.fontsource.org/v1/fonts/${id}`;
17
+ const CACHE_PATH = path.join(os.tmpdir(), "create-mithril-lynx-fontsource-cache.json");
18
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
19
+
20
+ /**
21
+ * @returns {Promise<Array<{id: string, family: string, category: string,
22
+ * variable: boolean, license: string, weights: number[], styles: string[]}>>}
23
+ */
24
+ export async function fetchFontList({ forceRefresh = false } = {}) {
25
+ if (!forceRefresh) {
26
+ try {
27
+ const stat = fs.statSync(CACHE_PATH);
28
+ if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) {
29
+ return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
30
+ }
31
+ } catch {
32
+ // No cache yet, or unreadable — fetch fresh below.
33
+ }
34
+ }
35
+
36
+ const response = await fetch(FONTS_LIST_URL);
37
+ if (!response.ok) {
38
+ throw new Error(`Fontsource API returned ${response.status} fetching the font list.`);
39
+ }
40
+ const list = await response.json();
41
+
42
+ try {
43
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(list));
44
+ } catch {
45
+ // Best-effort — a failed cache write just means the next call re-fetches.
46
+ }
47
+
48
+ return list;
49
+ }
50
+
51
+ /**
52
+ * Case-insensitive substring match on `family` (primarily) and `id`.
53
+ * Exact matches sort first, then alphabetically by family.
54
+ */
55
+ export function searchFonts(list, query) {
56
+ const q = query.trim().toLowerCase();
57
+ if (q === "") return [];
58
+
59
+ const matches = list.filter(
60
+ (font) => font.family.toLowerCase().includes(q) || font.id.toLowerCase().includes(q),
61
+ );
62
+
63
+ return matches.sort((a, b) => {
64
+ const aExact = a.family.toLowerCase() === q ? 0 : 1;
65
+ const bExact = b.family.toLowerCase() === q ? 0 : 1;
66
+ if (aExact !== bExact) return aExact - bExact;
67
+ return a.family.localeCompare(b.family);
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Full detail for one font — includes `variants[weight][style][subset]`,
73
+ * each holding `{ url: { woff2, woff, ttf } }`. Confirmed against Fontsource's
74
+ * own docs (https://fontsource.org/docs/api/font-id) and a real fetch.
75
+ */
76
+ export async function fetchFontDetail(id) {
77
+ const response = await fetch(FONT_DETAIL_URL(id));
78
+ if (!response.ok) {
79
+ throw new Error(`Fontsource API returned ${response.status} fetching "${id}".`);
80
+ }
81
+ return response.json();
82
+ }
83
+
84
+ /** Flattens a font's `variants` object into a flat, pickable list. */
85
+ export function listVariants(detail) {
86
+ const out = [];
87
+ for (const [weight, byStyle] of Object.entries(detail.variants ?? {})) {
88
+ for (const [style, bySubset] of Object.entries(byStyle)) {
89
+ for (const [subset, files] of Object.entries(bySubset)) {
90
+ if (files?.url?.ttf) {
91
+ out.push({ weight: Number(weight), style, subset, url: files.url.ttf });
92
+ }
93
+ }
94
+ }
95
+ }
96
+ // Latin first (most common default), then by weight, then style.
97
+ return out.sort((a, b) => {
98
+ if (a.subset !== b.subset) return a.subset === "latin" ? -1 : b.subset === "latin" ? 1 : a.subset.localeCompare(b.subset);
99
+ if (a.weight !== b.weight) return a.weight - b.weight;
100
+ return a.style.localeCompare(b.style);
101
+ });
102
+ }
package/src/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { execSync } from "node:child_process";
6
7
 
7
- import { cancel, confirm, intro, isCancel, outro, select, text } from "@clack/prompts";
8
+ import { cancel, confirm, intro, isCancel, outro, select, spinner, text } from "@clack/prompts";
9
+
10
+ import { fetchFontDetail, fetchFontList, listVariants, searchFonts } from "./fontsource.js";
8
11
 
9
12
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
13
  const packageRoot = path.join(scriptDir, "..");
@@ -132,6 +135,43 @@ function fontFamilyFor(filePath) {
132
135
  .join(" ");
133
136
  }
134
137
 
138
+ /** `--with-font`/`--find-font` accept a comma-separated list, for bundling
139
+ * more than one font (e.g. a body font and a monospace one for code). */
140
+ function splitList(value) {
141
+ return value
142
+ .split(",")
143
+ .map((v) => v.trim())
144
+ .filter(Boolean);
145
+ }
146
+
147
+ /** Renames `file` on any font past the first with the same basename, so
148
+ * e.g. two different "Inter" downloads (different weights) don't collide
149
+ * once copied into src/assets/fonts/. */
150
+ function dedupeFontFiles(fonts) {
151
+ const seen = new Set();
152
+ for (const font of fonts) {
153
+ let file = font.file;
154
+ let n = 2;
155
+ while (seen.has(file)) {
156
+ const ext = path.extname(font.file);
157
+ file = `${path.basename(font.file, ext)}-${n}${ext}`;
158
+ n += 1;
159
+ }
160
+ seen.add(file);
161
+ font.file = file;
162
+ }
163
+ }
164
+
165
+ /** A valid JS identifier (camelCase, "Font" suffix) for a font-family string. */
166
+ function jsIdentifierFor(family) {
167
+ const words = family.split(/[^a-zA-Z0-9]+/).filter(Boolean);
168
+ const camel = words
169
+ .map((word, i) => (i === 0 ? word[0].toLowerCase() + word.slice(1) : word[0].toUpperCase() + word.slice(1)))
170
+ .join("");
171
+ const safe = /^[0-9]/.test(camel) ? `f${camel}` : camel;
172
+ return `${safe || "font"}Font`;
173
+ }
174
+
135
175
  function xmlEscape(value) {
136
176
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
137
177
  }
@@ -173,12 +213,20 @@ function parseAndroidArgs(args) {
173
213
  appName: readOption(args, "app-name"),
174
214
  fontPath: readOption(args, "with-font"),
175
215
  fontFamily: readOption(args, "font-family"),
216
+ findFontTerm: readOption(args, "find-font"),
176
217
  };
177
218
  }
178
219
 
179
220
  // Options that consume the following argument, so that value is never mistaken
180
221
  // for the project name (e.g. `--with-font ./UbuntuMono.ttf`).
181
- const OPTIONS_WITH_VALUE = new Set(["--target", "--android-id", "--app-name", "--with-font", "--font-family"]);
222
+ const OPTIONS_WITH_VALUE = new Set([
223
+ "--target",
224
+ "--android-id",
225
+ "--app-name",
226
+ "--with-font",
227
+ "--font-family",
228
+ "--find-font",
229
+ ]);
182
230
 
183
231
  function findPositional(args) {
184
232
  for (let i = 0; i < args.length; i++) {
@@ -188,19 +236,118 @@ function findPositional(args) {
188
236
  continue;
189
237
  }
190
238
  if (arg === "target=android") continue;
191
- if (/^(?:target|android-id|app-name|with-font|font-family)=/.test(arg)) continue;
239
+ if (/^(?:target|android-id|app-name|with-font|font-family|find-font)=/.test(arg)) continue;
192
240
  return arg;
193
241
  }
194
242
  return undefined;
195
243
  }
196
244
 
197
- /** Indents every non-empty line of a generated block. */
198
- function indent(block, spaces) {
199
- const pad = " ".repeat(spaces);
200
- return block
201
- .split("\n")
202
- .map((line) => (line === "" ? line : pad + line))
203
- .join("\n");
245
+ const WEIGHT_NAMES = {
246
+ 100: "Thin",
247
+ 200: "Extra Light",
248
+ 300: "Light",
249
+ 400: "Regular",
250
+ 500: "Medium",
251
+ 600: "Semi Bold",
252
+ 700: "Bold",
253
+ 800: "Extra Bold",
254
+ 900: "Black",
255
+ };
256
+
257
+ function variantLabel(variant) {
258
+ const weightName = WEIGHT_NAMES[variant.weight] ?? String(variant.weight);
259
+ const style = variant.style === "italic" ? " Italic" : "";
260
+ return `${weightName}${style} (${variant.weight} ${variant.style})`;
261
+ }
262
+
263
+ /**
264
+ * Interactive search-download flow for `--find-font <term>`: queries
265
+ * Fontsource's catalog locally (see src/fontsource.js — the API itself has
266
+ * no free-text search), lets the user pick a family and one weight/style,
267
+ * downloads that .ttf to a temp file, and returns it in the same shape
268
+ * `--with-font <file>` expects, so the rest of the pipeline (entirely
269
+ * JS-side — see patchJsProject()) doesn't need to know which path was used.
270
+ */
271
+ async function findFontInteractively(term) {
272
+ // Unlike the other prompts in this file, this one isn't skippable by
273
+ // supplying enough flags up front — picking a family and a variant out
274
+ // of a search result is inherently interactive. Gate on the terminal
275
+ // itself, not on whether name/template were also given.
276
+ if (!process.stdin.isTTY) {
277
+ cancel("--find-font needs an interactive terminal to pick a family and variant — use --with-font <file.ttf> in scripts/CI.");
278
+ process.exit(1);
279
+ }
280
+
281
+ const s = spinner();
282
+ s.start(`Searching Fontsource for "${term}"…`);
283
+ let list;
284
+ try {
285
+ list = await fetchFontList();
286
+ } catch (error) {
287
+ s.stop("Search failed.");
288
+ cancel(error instanceof Error ? error.message : String(error));
289
+ process.exit(1);
290
+ }
291
+ const matches = searchFonts(list, term);
292
+ s.stop(`${matches.length} match(es) for "${term}".`);
293
+
294
+ if (matches.length === 0) {
295
+ cancel(`No Fontsource font matches "${term}". Try a different search, or use --with-font <file.ttf> for a font you already have.`);
296
+ process.exit(1);
297
+ }
298
+
299
+ const chosenId = await select({
300
+ message: "Which font?",
301
+ options: matches.map((f) => ({
302
+ value: f.id,
303
+ label: f.family,
304
+ hint: `${f.category}${f.variable ? ", variable" : ""} · ${f.license}`,
305
+ })),
306
+ });
307
+ if (isCancel(chosenId)) return null;
308
+
309
+ const s2 = spinner();
310
+ s2.start("Fetching variants…");
311
+ let detail;
312
+ try {
313
+ detail = await fetchFontDetail(chosenId);
314
+ } catch (error) {
315
+ s2.stop("Failed.");
316
+ cancel(error instanceof Error ? error.message : String(error));
317
+ process.exit(1);
318
+ }
319
+ const allVariants = listVariants(detail);
320
+ const variants = allVariants.filter((v) => v.subset === "latin");
321
+ s2.stop(`${variants.length || allVariants.length} variant(s) available.`);
322
+
323
+ const pickFrom = variants.length > 0 ? variants : allVariants;
324
+ const chosenVariant = await select({
325
+ message: "Which weight/style?",
326
+ initialValue: pickFrom.find((v) => v.weight === 400 && v.style === "normal") ?? pickFrom[0],
327
+ options: pickFrom.map((v) => ({ value: v, label: variantLabel(v) })),
328
+ });
329
+ if (isCancel(chosenVariant)) return null;
330
+
331
+ const s3 = spinner();
332
+ s3.start(`Downloading ${detail.family} ${variantLabel(chosenVariant)}…`);
333
+ let bytes;
334
+ try {
335
+ const response = await fetch(chosenVariant.url);
336
+ if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
337
+ bytes = Buffer.from(await response.arrayBuffer());
338
+ } catch (error) {
339
+ s3.stop("Download failed.");
340
+ cancel(error instanceof Error ? error.message : String(error));
341
+ process.exit(1);
342
+ }
343
+ const tempFile = path.join(
344
+ os.tmpdir(),
345
+ `${chosenId}-${chosenVariant.weight}-${chosenVariant.style}-${chosenVariant.subset}.ttf`,
346
+ );
347
+ fs.writeFileSync(tempFile, bytes);
348
+ s3.stop(`Downloaded ${(bytes.length / 1024).toFixed(1)} kB.`);
349
+
350
+ return { sourcePath: tempFile, family: detail.family };
204
351
  }
205
352
 
206
353
  // ---------------------------------------------------------------------------
@@ -246,38 +393,64 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
246
393
  }
247
394
  appName = appName ?? path.basename(rawName);
248
395
 
249
- let font = null;
250
- if (android.fontPath != null) {
251
- const resolved = path.resolve(cwd, android.fontPath);
396
+ if (android.fontPath != null && android.findFontTerm != null) {
397
+ cancel("--with-font and --find-font are mutually exclusive — pick one.");
398
+ process.exit(1);
399
+ }
400
+
401
+ // Both flags accept a comma-separated list, so more than one font (e.g.
402
+ // a body font and a monospace one for code) can be bundled in one run.
403
+ const fontPaths = android.fontPath != null ? splitList(android.fontPath) : [];
404
+ const findFontTerms = android.findFontTerm != null ? splitList(android.findFontTerm) : [];
405
+ const fontCount = fontPaths.length + findFontTerms.length;
406
+
407
+ if (android.fontFamily != null && fontCount > 1) {
408
+ cancel("--font-family only makes sense with exactly one font — omit it when bundling more than one, or edit src/style.css/background.ts by hand afterwards.");
409
+ process.exit(1);
410
+ }
411
+ if (android.fontFamily != null && fontCount === 0) {
412
+ cancel("--font-family only makes sense together with --with-font or --find-font.");
413
+ process.exit(1);
414
+ }
415
+
416
+ const fonts = [];
417
+ for (const term of findFontTerms) {
418
+ const found = await findFontInteractively(term);
419
+ if (found == null) return null;
420
+ fonts.push({
421
+ sourcePath: found.sourcePath,
422
+ file: path.basename(found.sourcePath),
423
+ family: android.fontFamily ?? found.family,
424
+ });
425
+ }
426
+ for (const rawPath of fontPaths) {
427
+ const resolved = path.resolve(cwd, rawPath);
252
428
  if (!fs.existsSync(resolved)) {
253
- cancel(`Font file not found: ${android.fontPath}`);
429
+ cancel(`Font file not found: ${rawPath}`);
254
430
  process.exit(1);
255
431
  }
256
432
  if (![".ttf", ".otf", ".ttc"].includes(path.extname(resolved).toLowerCase())) {
257
- cancel(`"${android.fontPath}" does not look like a font (.ttf/.otf/.ttc).`);
433
+ cancel(`"${rawPath}" does not look like a font (.ttf/.otf/.ttc).`);
258
434
  process.exit(1);
259
435
  }
260
- font = {
436
+ fonts.push({
261
437
  sourcePath: resolved,
262
438
  file: path.basename(resolved),
263
439
  family: android.fontFamily ?? fontFamilyFor(resolved),
264
- };
265
- } else if (android.fontFamily != null) {
266
- cancel("--font-family only makes sense together with --with-font.");
267
- process.exit(1);
440
+ });
268
441
  }
442
+ dedupeFontFiles(fonts);
269
443
 
270
- return { androidId, appName, font };
444
+ return { androidId, appName, fonts };
271
445
  }
272
446
 
273
447
  function scaffoldAndroid({ targetDir, rawName, options }) {
274
- const { androidId, appName, font } = options;
448
+ const { androidId, appName, fonts } = options;
275
449
 
276
450
  const androidDirName = `${path.basename(rawName.replace(/^@[^/]+\//, ""))}-android`;
277
451
  const androidDir = path.join(path.dirname(targetDir), androidDirName);
278
452
  const appClass = appClassNameFor(rawName);
279
453
  const packagePath = androidId.split(".").join(path.sep);
280
- const packageDir = path.join(androidDir, "app", "src", "main", "java", packagePath);
281
454
 
282
455
  // 1. Copy the Gradle skeleton and the Kotlin app. `package-path` expands to
283
456
  // the real package path, and App.kt is renamed after its class.
@@ -297,19 +470,15 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
297
470
  // best-effort
298
471
  }
299
472
 
300
- // 2. The .ttf is opt-in: without it neither the loader nor the prefetch is
301
- // generated, so no dead code is left behind.
302
- if (font != null) {
303
- const fontsDir = path.join(androidDir, "app", "src", "main", "assets", "fonts");
304
- fs.mkdirSync(fontsDir, { recursive: true });
305
- fs.copyFileSync(font.sourcePath, path.join(fontsDir, font.file));
306
-
307
- fs.copyFileSync(
308
- path.join(ANDROID_TEMPLATE_ROOT, "font", "AssetFontFaceLoader.kt"),
309
- path.join(packageDir, "AssetFontFaceLoader.kt"),
310
- );
311
- }
312
-
473
+ // 2. Fonts need no native code at all: lynx.config.ts's dataUriLimit:
474
+ // Infinity (see templates/_shared/ts/lynx.config.ts) already inlines
475
+ // any imported .ttf as a data: URI, and lynx.addFont() resolves a
476
+ // data: URI on every host with no registration — confirmed on device
477
+ // (a stock generated Android host, with no custom Loader/fetcher of
478
+ // any kind, renders the font correctly) and with no cold-start cost
479
+ // (three-run A/B on the same device: 724-745ms with the font vs.
480
+ // 715-826ms without — indistinguishable). See patchJsProject() for
481
+ // where the font actually gets wired up (entirely JS-side).
313
482
  const sdkDir = findAndroidSdk();
314
483
 
315
484
  // 3. Text substitutions across the whole Android host (skipping the
@@ -327,54 +496,7 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
327
496
  ],
328
497
  ]);
329
498
 
330
- // 4. The four font-hack hooks: either the real code, or nothing at all.
331
- const sourceUri = font != null ? `asset:///fonts/${font.file}` : undefined;
332
- const fontLoaderImport = font != null ? "import com.lynx.tasm.loader.LynxFontFaceLoader\n" : "";
333
- const fontLoaderRegistration =
334
- font != null
335
- ? `${indent(
336
- [
337
- "// Must run BEFORE LynxEnv.inst().init(): this is what makes",
338
- '// "asset:///" resolvable, both for prefetchFont() and for the',
339
- "// real @font-face resolution during the first layout.",
340
- "LynxFontFaceLoader.setLoader(AssetFontFaceLoader)",
341
- ].join("\n"),
342
- 8,
343
- )}\n\n`
344
- : "";
345
- const fontImport = font != null ? "import com.lynx.tasm.fontface.FontFaceManager\n" : "";
346
- const fontPrefetch =
347
- font != null
348
- ? `${indent(
349
- [
350
- "// Warms the Typeface on Lynx's own IO thread pool, before",
351
- "// renderTemplateUrl() gives the bundle's CSS a chance to resolve",
352
- "// @font-face during the first layout. FontFaceManager caches by the",
353
- "// exact src string, so this URI has to be identical to the",
354
- '// url("...") of the @font-face in src/style.css.',
355
- "FontFaceManager.getInstance().prefetchFont(",
356
- " lynxView.lynxContext,",
357
- ` "${sourceUri}",`,
358
- " null,",
359
- " object : FontFaceManager.FontFacePrefetchListener {",
360
- " override fun onComplete(code: Int, msg: String) {}",
361
- " },",
362
- ")",
363
- ].join("\n"),
364
- 8,
365
- )}\n\n`
366
- : "";
367
-
368
- for (const kt of walkFiles(packageDir)) {
369
- replaceInFile(kt, [
370
- ["// {{FONT_LOADER_IMPORT}}\n", fontLoaderImport],
371
- [" // {{FONT_LOADER_REGISTRATION}}\n", fontLoaderRegistration],
372
- ["// {{FONT_IMPORT}}\n", fontImport],
373
- [" // {{FONT_PREFETCH}}\n", fontPrefetch],
374
- ]);
375
- }
376
-
377
- // 5. The script that joins the two halves, inside the JS project.
499
+ // 4. The script that joins the two halves, inside the JS project.
378
500
  const scriptsDir = path.join(targetDir, "scripts");
379
501
  fs.mkdirSync(scriptsDir, { recursive: true });
380
502
  fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android.mjs"), path.join(scriptsDir, "android.mjs"));
@@ -388,7 +510,7 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
388
510
  ["{{APP_NAME}}", appName.replace(/["\\]/g, "")],
389
511
  ]);
390
512
 
391
- return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, font, sdkDir };
513
+ return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, fonts, sdkDir };
392
514
  }
393
515
 
394
516
  function patchJsProject({ targetDir, android }) {
@@ -405,23 +527,76 @@ function patchJsProject({ targetDir, android }) {
405
527
  };
406
528
  fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
407
529
 
408
- // style.css: the @font-face that has to match the Android host's
409
- // prefetchFont() byte for byte.
410
- if (android.font != null) {
411
- const cssPath = path.join(targetDir, "src", "style.css");
412
- const block = [
413
- "/* The font lives in the Android host's assets",
414
- " (app/src/main/assets/fonts/), not in the bundle. The asset:///",
415
- " string has to be identical to the one in MainActivity.kt's prefetchFont()",
416
- " or Lynx won't find the warmed Typeface. */",
417
- "@font-face {",
418
- ` font-family: "${android.font.family}";`,
419
- ` src: url("asset:///fonts/${android.font.file}");`,
420
- "}",
530
+ // The font: entirely JS-side, no native code, no @font-face.
531
+ //
532
+ // - The .ttf goes into the JS project itself (src/assets/fonts/), so the
533
+ // bundler can import it. templates/_shared/ts/lynx.config.ts already
534
+ // sets `dataUriLimit: Infinity`, which inlines that import as a
535
+ // `data:font/ttf;base64,...` URI directly in the bundle no separate
536
+ // file, no asset-path puzzle to solve for a packaged native host.
537
+ // - lynx.addFont() (not @font-face) registers it, called from
538
+ // background.ts. A data: URI resolves the same way on every host —
539
+ // confirmed on a real device with a stock generated Android host (no
540
+ // AssetFontFaceLoader, no custom resource fetcher) and with LynxExplorer/
541
+ // Lynx Go, with no cold-start cost (a three-run A/B on the same device
542
+ // measured 724-745ms with the font vs. 715-826ms without —
543
+ // indistinguishable; the old +1-2s regression was specific to
544
+ // @font-face's forced synchronous resolution, which doesn't apply here).
545
+ if (android.fonts.length > 0) {
546
+ const fontsDir = path.join(targetDir, "src", "assets", "fonts");
547
+ fs.mkdirSync(fontsDir, { recursive: true });
548
+
549
+ const usedVarNames = new Set();
550
+ const importLines = [];
551
+ const addFontLines = [];
552
+ for (const { family, sourcePath, file } of android.fonts) {
553
+ fs.copyFileSync(sourcePath, path.join(fontsDir, file));
554
+
555
+ let varName = jsIdentifierFor(family);
556
+ while (usedVarNames.has(varName)) varName = `${varName}2`;
557
+ usedVarNames.add(varName);
558
+
559
+ importLines.push(`import ${varName} from "./assets/fonts/${file}";`);
560
+ addFontLines.push(
561
+ `lynx.addFont({ "font-family": "${family}", src: \`url("\${${varName}}")\` }, () => {});`,
562
+ );
563
+ }
564
+
565
+ // Only the first font gets an automatic CSS rule (the app-wide
566
+ // default, matching --with-font's single-font behavior exactly) —
567
+ // with more than one, there's no way to guess which elements should
568
+ // use which, so the rest are just registered via lynx.addFont() and
569
+ // left for you to assign in your own CSS classes.
570
+ const defaultFamily = android.fonts[0].family;
571
+ const extraFamilies = android.fonts.slice(1).map((f) => f.family);
572
+
573
+ const bgPath = path.join(targetDir, "src", "background.ts");
574
+ const fontBlock = [
575
+ android.fonts.length === 1
576
+ ? `// Font: "${defaultFamily}", bundled from src/assets/fonts/${android.fonts[0].file}. lynx.addFont()`
577
+ : `// Fonts: ${android.fonts.map((f) => `"${f.family}"`).join(", ")}, bundled from src/assets/fonts/. lynx.addFont()`,
578
+ "// registers each directly — dataUriLimit: Infinity (lynx.config.ts) inlines",
579
+ "// every import as a data: URI, which resolves the same way on every host,",
580
+ "// no native code needed.",
581
+ ...(extraFamilies.length > 0
582
+ ? [
583
+ `// Only "${defaultFamily}" got the automatic text { font-family: ... }`,
584
+ `// rule below — assign ${extraFamilies.map((f) => `"${f}"`).join(" / ")} to your own`,
585
+ "// classes in src/style.css, e.g. `.code { font-family: \"" + extraFamilies[0] + "\"; }`.",
586
+ ]
587
+ : []),
588
+ ...importLines,
589
+ ...addFontLines,
590
+ "",
421
591
  "",
422
592
  ].join("\n");
423
- const existing = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, "utf8") : "";
424
- fs.writeFileSync(cssPath, `${block}\n${existing}`);
593
+ const existingBg = fs.existsSync(bgPath) ? fs.readFileSync(bgPath, "utf8") : "";
594
+ fs.writeFileSync(bgPath, `${fontBlock}${existingBg}`);
595
+
596
+ const cssPath = path.join(targetDir, "src", "style.css");
597
+ const cssBlock = [`text {`, ` font-family: "${defaultFamily}", sans-serif;`, `}`, ""].join("\n");
598
+ const existingCss = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, "utf8") : "";
599
+ fs.writeFileSync(cssPath, `${cssBlock}\n${existingCss}`);
425
600
  }
426
601
 
427
602
  // README: how the two halves are used together.
@@ -445,9 +620,10 @@ function patchJsProject({ targetDir, android }) {
445
620
  "",
446
621
  `- Application ID: \`${android.androidId}\``,
447
622
  `- Application class: \`${android.appClass}\` · Activity: \`MainActivity\``,
448
- ...(android.font != null
449
- ? [`- Font \`${android.font.family}\` prefetched from \`asset:///fonts/${android.font.file}\` (the cold-start hack).`]
450
- : []),
623
+ ...android.fonts.map(
624
+ (f) =>
625
+ `- Font \`${f.family}\` loaded via \`lynx.addFont()\` from \`src/assets/fonts/${f.file}\` (bundled as a data: URI — no native code involved).`,
626
+ ),
451
627
  "",
452
628
  ].join("\n");
453
629
  fs.appendFileSync(readmePath, section);
@@ -471,10 +647,18 @@ Android host:
471
647
  scaffold the sibling Gradle project <name>-android/
472
648
  --android-id <id> applicationId / namespace (default com.example.<name>)
473
649
  --app-name <name> launcher label (default: the project name)
474
- --with-font <file.ttf> copy the font into the host's assets, generate
475
- AssetFontFaceLoader.kt, and wire up the prefetchFont()
476
- call that avoids the slow cold start
477
- --font-family <name> override the family name derived from the file name
650
+ --with-font <file.ttf> bundle the font into the JS project and register it
651
+ with lynx.addFont() (works on every host, no native
652
+ code see README). Comma-separate for more than
653
+ one, e.g. --with-font a.ttf,b.ttf
654
+ --find-font <term> search Fontsource (fontsource.org) for a font,
655
+ pick a family and a weight/style interactively,
656
+ and bundle it the same way as --with-font.
657
+ Comma-separate terms for more than one — quote
658
+ the whole thing if any term has a space, e.g.
659
+ --find-font "Inter,JetBrains Mono"
660
+ --font-family <name> override the family name derived from the file
661
+ name (only valid with exactly one font)
478
662
 
479
663
  Other:
480
664
  --no-install don't install dependencies
@@ -500,9 +684,9 @@ async function main() {
500
684
  const nonInteractive = positional != null && templateFlag != null;
501
685
  const android = parseAndroidArgs(args);
502
686
 
503
- // --with-font/--font-family only make sense with an Android host: imply it
504
- // rather than ignoring them silently.
505
- if (android.fontPath != null || android.fontFamily != null) {
687
+ // --with-font/--find-font/--font-family only make sense with an Android
688
+ // host: imply it rather than ignoring them silently.
689
+ if (android.fontPath != null || android.findFontTerm != null || android.fontFamily != null) {
506
690
  android.requested = true;
507
691
  }
508
692
 
@@ -613,7 +797,7 @@ async function main() {
613
797
  ];
614
798
 
615
799
  if (androidResult != null) {
616
- const { androidDirName, sdkDir, font } = androidResult;
800
+ const { androidDirName, sdkDir, fonts } = androidResult;
617
801
  const notes = [
618
802
  `Android host generated in ${androidDirName}/ (Application ID ${androidOptions.androidId}).`,
619
803
  "",
@@ -625,9 +809,9 @@ async function main() {
625
809
  ? "⚠ Android SDK not found: export ANDROID_HOME and edit " +
626
810
  `${androidDirName}/local.properties (sdk.dir=...).`
627
811
  : `Android SDK found at ${sdkDir}.`,
628
- font != null
629
- ? `Font "${font.family}" prefetched from asset:///fonts/${font.file} (the cold-start hack).`
630
- : "No custom font — pass --with-font <file.ttf> to include the prefetch hack.",
812
+ fonts.length > 0
813
+ ? `Font${fonts.length > 1 ? "s" : ""} ${fonts.map((f) => `"${f.family}"`).join(", ")} bundled and registered via lynx.addFont() works on every host.`
814
+ : "No custom font — pass --with-font <file.ttf> (or --find-font <term>) to bundle one.",
631
815
  ];
632
816
  outro(`Done! Next steps:\n\n ${steps.join("\n ")}\n\n${notes.join("\n")}`);
633
817
  return;
@@ -3,7 +3,6 @@ package {{PACKAGE_NAME}}
3
3
  import android.app.Application
4
4
  import com.lynx.service.log.LynxLogService
5
5
  import com.lynx.tasm.LynxEnv
6
- // {{FONT_LOADER_IMPORT}}
7
6
  import com.lynx.tasm.service.LynxServiceCenter
8
7
 
9
8
  class {{APP_CLASS}} : Application() {
@@ -17,7 +16,6 @@ class {{APP_CLASS}} : Application() {
17
16
  LynxServiceCenter.inst().registerService(LynxLogService)
18
17
  LynxLogService.switchLogToSystem(true)
19
18
 
20
- // {{FONT_LOADER_REGISTRATION}}
21
19
  LynxEnv.inst().init(this, null, null, null)
22
20
  }
23
21
  }
@@ -5,7 +5,6 @@ import androidx.appcompat.app.AppCompatActivity
5
5
  import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
6
6
  import com.lynx.tasm.LynxViewBuilder
7
7
  import com.lynx.tasm.ThreadStrategyForRendering
8
- // {{FONT_IMPORT}}
9
8
  import com.lynx.xelement.XElementBehaviors
10
9
 
11
10
  class MainActivity : AppCompatActivity() {
@@ -28,7 +27,6 @@ class MainActivity : AppCompatActivity() {
28
27
  val lynxView = builder.build(this)
29
28
  setContentView(lynxView)
30
29
 
31
- // {{FONT_PREFETCH}}
32
30
  // The bundle lives in app/src/main/assets/main-thread.bundle, copied
33
31
  // there by `npm run android` (scripts/android.mjs) from the JS
34
32
  // project's dist/. The name has to match exactly.
@@ -1,39 +0,0 @@
1
- package {{PACKAGE_NAME}}
2
-
3
- import android.graphics.Typeface
4
- import com.lynx.tasm.behavior.LynxContext
5
- import com.lynx.tasm.fontface.FontFace
6
- import com.lynx.tasm.loader.LynxFontFaceLoader
7
-
8
- /**
9
- * Resolves `asset:///` in `@font-face`, both for the prefetch and for the real
10
- * lookup.
11
- *
12
- * Lynx's default loader (LynxFontFaceLoader$1, decompiled from
13
- * lynx-4.1.0.aar) is a no-op that never resolves `asset:///`: in
14
- * FontFaceManager that scheme is only handled inline inside loadTypeface()
15
- * when a FONT-type LynxResourceProvider is registered, and the non-http/
16
- * non-data: branch of prefetchFont() (prefetchFontWithLoader) goes through this
17
- * Loader only, with no fallback of its own. Without registering this,
18
- * `asset:///` resolves no way at all.
19
- *
20
- * Trade-off to keep in mind: FontFaceManager/LynxFontFaceLoader are public
21
- * classes (not @RestrictTo) but are not documented for this specific use —
22
- * they could change without notice in a major Lynx SDK release.
23
- */
24
- object AssetFontFaceLoader : LynxFontFaceLoader.Loader() {
25
- private const val ASSET_PREFIX = "asset:///"
26
-
27
- override fun onLoadFontFace(
28
- context: LynxContext,
29
- type: FontFace.TYPE,
30
- src: String,
31
- ): Typeface? {
32
- if (!src.startsWith(ASSET_PREFIX)) return null
33
- return try {
34
- Typeface.createFromAsset(context.context.assets, src.removePrefix(ASSET_PREFIX))
35
- } catch (e: Exception) {
36
- null
37
- }
38
- }
39
- }