create-mithril-lynx 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,11 +49,28 @@ 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). |
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`. |
54
+ | `--font-family <name>` | Override the family name derived from the font's file name (or from Fontsource, with `--find-font`). Only meaningful with `--with-font`/`--find-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
+ ### `--find-font`, in more detail
60
+
61
+ Fontsource's own API (`api.fontsource.org`) has no free-text search — only
62
+ exact `id`/`family` filters — so `--find-font` downloads the whole font list
63
+ once (~2100 fonts, ~540KB as of 2026-09), caches it for 24h in the OS temp
64
+ directory, and filters client-side on `family`/`id` substrings. Picking a
65
+ family fetches that font's own detail (its real weight/style/subset `.ttf`
66
+ URLs), lets you pick one variant (defaulting to 400/normal if available),
67
+ downloads it to a temp file, and hands it to the same code path
68
+ `--with-font <file>` uses — there's no separate font-loading mechanism to
69
+ maintain.
70
+
71
+ ```bash
72
+ npx create-mithril-lynx my-app --blank --android --find-font Inter
73
+ ```
57
74
 
58
75
  ### What the generated Android host gives you
59
76
 
@@ -61,7 +78,7 @@ The sibling `<name>-android/` project is a complete, no-Android-Studio Gradle CL
61
78
 
62
79
  - the Gradle wrapper (`gradlew`, `gradlew.bat`, `gradle-wrapper.jar`), so nothing needs to be installed besides a JDK and the Android SDK;
63
80
  - `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;
81
+ - the Kotlin host: the `Application`, `MainActivity` (async `AssetTemplateProvider`, splash screen) no font-specific code needed, `--with-font` is entirely JS-side;
65
82
  - resource files that compile as-is (theme, splash theme, adaptive launcher icon);
66
83
  - an opt-in release signing setup driven by a gitignored `keystore.properties`.
67
84
 
@@ -90,6 +107,7 @@ This part of the tool is framework-agnostic — it just wraps whatever bundle `r
90
107
  ```
91
108
  create-mithril-lynx/
92
109
  src/index.js the CLI itself
110
+ src/fontsource.js --find-font: local search over Fontsource's catalog
93
111
  templates/
94
112
  _shared/
95
113
  common/ gitignore, project README — shared by every template
@@ -107,13 +125,12 @@ create-mithril-lynx/
107
125
  ts/src/{app-bar.ts, background.ts, screens/{home,detail}.ts}
108
126
  android/ only used with --android
109
127
  host/ the Gradle project -> <name>-android/
110
- font/AssetFontFaceLoader.kt copied only when --with-font is used
111
128
  app-scripts/android.mjs the bridge -> <name>/scripts/android.mjs
112
129
  ```
113
130
 
114
131
  `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
132
 
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.
133
+ 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
134
 
118
135
  **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
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mithril-lynx",
3
- "version": "2.0.1",
3
+ "version": "2.1.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,16 @@ function fontFamilyFor(filePath) {
132
135
  .join(" ");
133
136
  }
134
137
 
138
+ /** A valid JS identifier (camelCase, "Font" suffix) for a font-family string. */
139
+ function jsIdentifierFor(family) {
140
+ const words = family.split(/[^a-zA-Z0-9]+/).filter(Boolean);
141
+ const camel = words
142
+ .map((word, i) => (i === 0 ? word[0].toLowerCase() + word.slice(1) : word[0].toUpperCase() + word.slice(1)))
143
+ .join("");
144
+ const safe = /^[0-9]/.test(camel) ? `f${camel}` : camel;
145
+ return `${safe || "font"}Font`;
146
+ }
147
+
135
148
  function xmlEscape(value) {
136
149
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
137
150
  }
@@ -173,12 +186,20 @@ function parseAndroidArgs(args) {
173
186
  appName: readOption(args, "app-name"),
174
187
  fontPath: readOption(args, "with-font"),
175
188
  fontFamily: readOption(args, "font-family"),
189
+ findFontTerm: readOption(args, "find-font"),
176
190
  };
177
191
  }
178
192
 
179
193
  // Options that consume the following argument, so that value is never mistaken
180
194
  // 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"]);
195
+ const OPTIONS_WITH_VALUE = new Set([
196
+ "--target",
197
+ "--android-id",
198
+ "--app-name",
199
+ "--with-font",
200
+ "--font-family",
201
+ "--find-font",
202
+ ]);
182
203
 
183
204
  function findPositional(args) {
184
205
  for (let i = 0; i < args.length; i++) {
@@ -188,19 +209,118 @@ function findPositional(args) {
188
209
  continue;
189
210
  }
190
211
  if (arg === "target=android") continue;
191
- if (/^(?:target|android-id|app-name|with-font|font-family)=/.test(arg)) continue;
212
+ if (/^(?:target|android-id|app-name|with-font|font-family|find-font)=/.test(arg)) continue;
192
213
  return arg;
193
214
  }
194
215
  return undefined;
195
216
  }
196
217
 
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");
218
+ const WEIGHT_NAMES = {
219
+ 100: "Thin",
220
+ 200: "Extra Light",
221
+ 300: "Light",
222
+ 400: "Regular",
223
+ 500: "Medium",
224
+ 600: "Semi Bold",
225
+ 700: "Bold",
226
+ 800: "Extra Bold",
227
+ 900: "Black",
228
+ };
229
+
230
+ function variantLabel(variant) {
231
+ const weightName = WEIGHT_NAMES[variant.weight] ?? String(variant.weight);
232
+ const style = variant.style === "italic" ? " Italic" : "";
233
+ return `${weightName}${style} (${variant.weight} ${variant.style})`;
234
+ }
235
+
236
+ /**
237
+ * Interactive search-download flow for `--find-font <term>`: queries
238
+ * Fontsource's catalog locally (see src/fontsource.js — the API itself has
239
+ * no free-text search), lets the user pick a family and one weight/style,
240
+ * downloads that .ttf to a temp file, and returns it in the same shape
241
+ * `--with-font <file>` expects, so the rest of the pipeline (entirely
242
+ * JS-side — see patchJsProject()) doesn't need to know which path was used.
243
+ */
244
+ async function findFontInteractively(term) {
245
+ // Unlike the other prompts in this file, this one isn't skippable by
246
+ // supplying enough flags up front — picking a family and a variant out
247
+ // of a search result is inherently interactive. Gate on the terminal
248
+ // itself, not on whether name/template were also given.
249
+ if (!process.stdin.isTTY) {
250
+ cancel("--find-font needs an interactive terminal to pick a family and variant — use --with-font <file.ttf> in scripts/CI.");
251
+ process.exit(1);
252
+ }
253
+
254
+ const s = spinner();
255
+ s.start(`Searching Fontsource for "${term}"…`);
256
+ let list;
257
+ try {
258
+ list = await fetchFontList();
259
+ } catch (error) {
260
+ s.stop("Search failed.");
261
+ cancel(error instanceof Error ? error.message : String(error));
262
+ process.exit(1);
263
+ }
264
+ const matches = searchFonts(list, term);
265
+ s.stop(`${matches.length} match(es) for "${term}".`);
266
+
267
+ if (matches.length === 0) {
268
+ cancel(`No Fontsource font matches "${term}". Try a different search, or use --with-font <file.ttf> for a font you already have.`);
269
+ process.exit(1);
270
+ }
271
+
272
+ const chosenId = await select({
273
+ message: "Which font?",
274
+ options: matches.map((f) => ({
275
+ value: f.id,
276
+ label: f.family,
277
+ hint: `${f.category}${f.variable ? ", variable" : ""} · ${f.license}`,
278
+ })),
279
+ });
280
+ if (isCancel(chosenId)) return null;
281
+
282
+ const s2 = spinner();
283
+ s2.start("Fetching variants…");
284
+ let detail;
285
+ try {
286
+ detail = await fetchFontDetail(chosenId);
287
+ } catch (error) {
288
+ s2.stop("Failed.");
289
+ cancel(error instanceof Error ? error.message : String(error));
290
+ process.exit(1);
291
+ }
292
+ const allVariants = listVariants(detail);
293
+ const variants = allVariants.filter((v) => v.subset === "latin");
294
+ s2.stop(`${variants.length || allVariants.length} variant(s) available.`);
295
+
296
+ const pickFrom = variants.length > 0 ? variants : allVariants;
297
+ const chosenVariant = await select({
298
+ message: "Which weight/style?",
299
+ initialValue: pickFrom.find((v) => v.weight === 400 && v.style === "normal") ?? pickFrom[0],
300
+ options: pickFrom.map((v) => ({ value: v, label: variantLabel(v) })),
301
+ });
302
+ if (isCancel(chosenVariant)) return null;
303
+
304
+ const s3 = spinner();
305
+ s3.start(`Downloading ${detail.family} ${variantLabel(chosenVariant)}…`);
306
+ let bytes;
307
+ try {
308
+ const response = await fetch(chosenVariant.url);
309
+ if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
310
+ bytes = Buffer.from(await response.arrayBuffer());
311
+ } catch (error) {
312
+ s3.stop("Download failed.");
313
+ cancel(error instanceof Error ? error.message : String(error));
314
+ process.exit(1);
315
+ }
316
+ const tempFile = path.join(
317
+ os.tmpdir(),
318
+ `${chosenId}-${chosenVariant.weight}-${chosenVariant.style}-${chosenVariant.subset}.ttf`,
319
+ );
320
+ fs.writeFileSync(tempFile, bytes);
321
+ s3.stop(`Downloaded ${(bytes.length / 1024).toFixed(1)} kB.`);
322
+
323
+ return { sourcePath: tempFile, family: detail.family };
204
324
  }
205
325
 
206
326
  // ---------------------------------------------------------------------------
@@ -246,8 +366,21 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
246
366
  }
247
367
  appName = appName ?? path.basename(rawName);
248
368
 
369
+ if (android.fontPath != null && android.findFontTerm != null) {
370
+ cancel("--with-font and --find-font are mutually exclusive — pick one.");
371
+ process.exit(1);
372
+ }
373
+
249
374
  let font = null;
250
- if (android.fontPath != null) {
375
+ if (android.findFontTerm != null) {
376
+ const found = await findFontInteractively(android.findFontTerm);
377
+ if (found == null) return null;
378
+ font = {
379
+ sourcePath: found.sourcePath,
380
+ file: path.basename(found.sourcePath),
381
+ family: android.fontFamily ?? found.family,
382
+ };
383
+ } else if (android.fontPath != null) {
251
384
  const resolved = path.resolve(cwd, android.fontPath);
252
385
  if (!fs.existsSync(resolved)) {
253
386
  cancel(`Font file not found: ${android.fontPath}`);
@@ -263,7 +396,7 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
263
396
  family: android.fontFamily ?? fontFamilyFor(resolved),
264
397
  };
265
398
  } else if (android.fontFamily != null) {
266
- cancel("--font-family only makes sense together with --with-font.");
399
+ cancel("--font-family only makes sense together with --with-font or --find-font.");
267
400
  process.exit(1);
268
401
  }
269
402
 
@@ -277,7 +410,6 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
277
410
  const androidDir = path.join(path.dirname(targetDir), androidDirName);
278
411
  const appClass = appClassNameFor(rawName);
279
412
  const packagePath = androidId.split(".").join(path.sep);
280
- const packageDir = path.join(androidDir, "app", "src", "main", "java", packagePath);
281
413
 
282
414
  // 1. Copy the Gradle skeleton and the Kotlin app. `package-path` expands to
283
415
  // the real package path, and App.kt is renamed after its class.
@@ -297,19 +429,15 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
297
429
  // best-effort
298
430
  }
299
431
 
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
-
432
+ // 2. Fonts need no native code at all: lynx.config.ts's dataUriLimit:
433
+ // Infinity (see templates/_shared/ts/lynx.config.ts) already inlines
434
+ // any imported .ttf as a data: URI, and lynx.addFont() resolves a
435
+ // data: URI on every host with no registration — confirmed on device
436
+ // (a stock generated Android host, with no custom Loader/fetcher of
437
+ // any kind, renders the font correctly) and with no cold-start cost
438
+ // (three-run A/B on the same device: 724-745ms with the font vs.
439
+ // 715-826ms without — indistinguishable). See patchJsProject() for
440
+ // where the font actually gets wired up (entirely JS-side).
313
441
  const sdkDir = findAndroidSdk();
314
442
 
315
443
  // 3. Text substitutions across the whole Android host (skipping the
@@ -327,54 +455,7 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
327
455
  ],
328
456
  ]);
329
457
 
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.
458
+ // 4. The script that joins the two halves, inside the JS project.
378
459
  const scriptsDir = path.join(targetDir, "scripts");
379
460
  fs.mkdirSync(scriptsDir, { recursive: true });
380
461
  fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android.mjs"), path.join(scriptsDir, "android.mjs"));
@@ -405,23 +486,46 @@ function patchJsProject({ targetDir, android }) {
405
486
  };
406
487
  fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
407
488
 
408
- // style.css: the @font-face that has to match the Android host's
409
- // prefetchFont() byte for byte.
489
+ // The font: entirely JS-side, no native code, no @font-face.
490
+ //
491
+ // - The .ttf goes into the JS project itself (src/assets/fonts/), so the
492
+ // bundler can import it. templates/_shared/ts/lynx.config.ts already
493
+ // sets `dataUriLimit: Infinity`, which inlines that import as a
494
+ // `data:font/ttf;base64,...` URI directly in the bundle — no separate
495
+ // file, no asset-path puzzle to solve for a packaged native host.
496
+ // - lynx.addFont() (not @font-face) registers it, called from
497
+ // background.ts. A data: URI resolves the same way on every host —
498
+ // confirmed on a real device with a stock generated Android host (no
499
+ // AssetFontFaceLoader, no custom resource fetcher) and with LynxExplorer/
500
+ // Lynx Go, with no cold-start cost (a three-run A/B on the same device
501
+ // measured 724-745ms with the font vs. 715-826ms without —
502
+ // indistinguishable; the old +1-2s regression was specific to
503
+ // @font-face's forced synchronous resolution, which doesn't apply here).
410
504
  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
- "}",
505
+ const { family, sourcePath, file } = android.font;
506
+ const fontsDir = path.join(targetDir, "src", "assets", "fonts");
507
+ fs.mkdirSync(fontsDir, { recursive: true });
508
+ fs.copyFileSync(sourcePath, path.join(fontsDir, file));
509
+
510
+ const varName = jsIdentifierFor(family);
511
+ const bgPath = path.join(targetDir, "src", "background.ts");
512
+ const fontBlock = [
513
+ `// Font: "${family}", bundled from src/assets/fonts/${file}. lynx.addFont()`,
514
+ "// registers it directly — dataUriLimit: Infinity (lynx.config.ts) inlines",
515
+ "// the import as a data: URI, which resolves the same way on every host,",
516
+ "// no native code needed.",
517
+ `import ${varName} from "./assets/fonts/${file}";`,
518
+ `lynx.addFont({ "font-family": "${family}", src: \`url("\${${varName}}")\` }, () => {});`,
519
+ "",
421
520
  "",
422
521
  ].join("\n");
423
- const existing = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, "utf8") : "";
424
- fs.writeFileSync(cssPath, `${block}\n${existing}`);
522
+ const existingBg = fs.existsSync(bgPath) ? fs.readFileSync(bgPath, "utf8") : "";
523
+ fs.writeFileSync(bgPath, `${fontBlock}${existingBg}`);
524
+
525
+ const cssPath = path.join(targetDir, "src", "style.css");
526
+ const cssBlock = [`text {`, ` font-family: "${family}", sans-serif;`, `}`, ""].join("\n");
527
+ const existingCss = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, "utf8") : "";
528
+ fs.writeFileSync(cssPath, `${cssBlock}\n${existingCss}`);
425
529
  }
426
530
 
427
531
  // README: how the two halves are used together.
@@ -446,7 +550,7 @@ function patchJsProject({ targetDir, android }) {
446
550
  `- Application ID: \`${android.androidId}\``,
447
551
  `- Application class: \`${android.appClass}\` · Activity: \`MainActivity\``,
448
552
  ...(android.font != null
449
- ? [`- Font \`${android.font.family}\` prefetched from \`asset:///fonts/${android.font.file}\` (the cold-start hack).`]
553
+ ? [`- Font \`${android.font.family}\` loaded via \`lynx.addFont()\` from \`src/assets/fonts/${android.font.file}\` (bundled as a data: URI — no native code involved).`]
450
554
  : []),
451
555
  "",
452
556
  ].join("\n");
@@ -471,9 +575,12 @@ Android host:
471
575
  scaffold the sibling Gradle project <name>-android/
472
576
  --android-id <id> applicationId / namespace (default com.example.<name>)
473
577
  --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
578
+ --with-font <file.ttf> bundle the font into the JS project and register it
579
+ with lynx.addFont() (works on every host, no native
580
+ code see README)
581
+ --find-font <term> search Fontsource (fontsource.org) for a font,
582
+ pick a family and a weight/style interactively,
583
+ and bundle it the same way as --with-font
477
584
  --font-family <name> override the family name derived from the file name
478
585
 
479
586
  Other:
@@ -500,9 +607,9 @@ async function main() {
500
607
  const nonInteractive = positional != null && templateFlag != null;
501
608
  const android = parseAndroidArgs(args);
502
609
 
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) {
610
+ // --with-font/--find-font/--font-family only make sense with an Android
611
+ // host: imply it rather than ignoring them silently.
612
+ if (android.fontPath != null || android.findFontTerm != null || android.fontFamily != null) {
506
613
  android.requested = true;
507
614
  }
508
615
 
@@ -626,8 +733,8 @@ async function main() {
626
733
  `${androidDirName}/local.properties (sdk.dir=...).`
627
734
  : `Android SDK found at ${sdkDir}.`,
628
735
  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.",
736
+ ? `Font "${font.family}" bundled and registered via lynx.addFont() works on every host.`
737
+ : "No custom font — pass --with-font <file.ttf> to bundle one.",
631
738
  ];
632
739
  outro(`Done! Next steps:\n\n ${steps.join("\n ")}\n\n${notes.join("\n")}`);
633
740
  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
- }