create-mithril-lynx 2.2.0 → 2.3.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,7 +49,7 @@ 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>` | 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. |
52
+ | `--with-font <file.ttf>` | Bundle the font into `src/assets/fonts/` and register it with `lynx.addFont()` in a loop. **DEV** (`npm run dev` / Lynx Go): `require()` inlines a `data:` URI so the font shows up in Explorer. **PROD** (`npm run build` / APK): `asset:///fonts/<file>` resolved by `AssetFontFaceLoader` on the host (keeps the bundle small embedding `data:` URIs in the APK re-introduces the cold-start cost tracked in [lynx#9431](https://github.com/lynx-family/lynx/issues/9431)). Comma-separate for more than one file. |
53
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
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. |
55
55
 
@@ -65,11 +65,13 @@ monospace one for code, for example:
65
65
  npx create-mithril-lynx my-app --blank --android --find-font "Inter,JetBrains Mono"
66
66
  ```
67
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.:
68
+ Each font is one entry in a `FONTS` array in `src/background.ts`, registered
69
+ with a single `for…of` + `lynx.addFont()` loop (DEV `require` / PROD
70
+ `asset:///` see the `--with-font` row above). Only the **first** one gets
71
+ the automatic `text { font-family: ...; }` rule in `src/style.css` (there's
72
+ no way for the CLI to guess which elements should use which font past that)
73
+ — the rest are registered and ready to use, but you assign them to your own
74
+ classes by hand, e.g.:
73
75
 
74
76
  ```css
75
77
  .code {
@@ -77,6 +79,21 @@ but you assign them to your own classes by hand, e.g.:
77
79
  }
78
80
  ```
79
81
 
82
+ ### Adding fonts after the project exists
83
+
84
+ ```bash
85
+ cd my-app
86
+ npx create-mithril-lynx add-font --with-font ./Another.ttf
87
+ npx create-mithril-lynx add-font --find-font "Roboto Mono"
88
+ ```
89
+
90
+ Copying the `.ttf` into `src/assets/fonts/` (and the Android host's
91
+ `assets/fonts/` when present) is always safe. Wiring `background.ts` /
92
+ `style.css` is automatic **only while those files still match the stock
93
+ template** (including a previously generated `FONTS` block on top of it). If
94
+ you've edited them, the command prints the exact block to paste by hand and
95
+ leaves your files untouched.
96
+
80
97
  ### `--find-font`, in more detail
81
98
 
82
99
  Fontsource's own API (`api.fontsource.org`) has no free-text search — only
@@ -99,7 +116,7 @@ The sibling `<name>-android/` project is a complete, no-Android-Studio Gradle CL
99
116
 
100
117
  - the Gradle wrapper (`gradlew`, `gradlew.bat`, `gradle-wrapper.jar`), so nothing needs to be installed besides a JDK and the Android SDK;
101
118
  - `local.properties` with `sdk.dir` auto-detected from `ANDROID_HOME`/`ANDROID_SDK_ROOT` (with a written warning if it can't be found);
102
- - the Kotlin host: the `Application`, `MainActivity` (async `AssetTemplateProvider`, splash screen) no font-specific code needed, `--with-font` is entirely JS-side;
119
+ - the Kotlin host: the `Application` (registers `AssetFontFaceLoader`), `MainActivity` (async `AssetTemplateProvider`, splash screen, `NoopGenericResourceFetcher` for the fast `data:` font path [lynx#9431](https://github.com/lynx-family/lynx/issues/9431));
103
120
  - resource files that compile as-is (theme, splash theme, adaptive launcher icon);
104
121
  - an opt-in release signing setup driven by a gitignored `keystore.properties`.
105
122
 
@@ -127,8 +144,9 @@ This part of the tool is framework-agnostic — it just wraps whatever bundle `r
127
144
 
128
145
  ```
129
146
  create-mithril-lynx/
130
- src/index.js the CLI itself
147
+ src/index.js the CLI itself (+ add-font subcommand)
131
148
  src/fontsource.js --find-font: local search over Fontsource's catalog
149
+ src/fonts-wire.js FONTS block builder + stock-template detect/patch
132
150
  templates/
133
151
  _shared/
134
152
  common/ gitignore, project README — shared by every template
@@ -146,12 +164,15 @@ create-mithril-lynx/
146
164
  ts/src/{app-bar.ts, background.ts, screens/{home,detail}.ts}
147
165
  android/ only used with --android
148
166
  host/ the Gradle project -> <name>-android/
167
+ (includes AssetFontFaceLoader +
168
+ NoopGenericResourceFetcher for lynx#9431)
149
169
  app-scripts/android.mjs the bridge -> <name>/scripts/android.mjs
170
+ (syncs bundle + src/assets/fonts/ → assets/fonts/)
150
171
  ```
151
172
 
152
173
  `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`.
153
174
 
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).
175
+ 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`, each font is copied into the JS project (`src/assets/fonts/`) *and* seeded into the Android host's `assets/fonts/`, wired up with a `FONTS` loop + `lynx.addFont()` in `src/background.ts` plus a `text { font-family: ... }` rule in `src/style.css` for the first font. `scripts/android.mjs` keeps `assets/fonts/` in sync on every `npm run android` / `android:sync`.
155
176
 
156
177
  **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.
157
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mithril-lynx",
3
- "version": "2.2.0",
3
+ "version": "2.3.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,318 @@
1
+ /**
2
+ * Font registration wiring shared by scaffold (`--with-font` / `--find-font`)
3
+ * and the post-init `add-font` subcommand.
4
+ *
5
+ * Strategy for patching an existing project (add-font):
6
+ * 1. Always copy .ttf/.otf/.ttc into src/assets/fonts/ (and the Android
7
+ * host's assets/fonts/ when present) — that part is always safe.
8
+ * 2. If src/background.ts still matches a stock template (optionally with
9
+ * our generated FONTS block on top), rewrite the font block in place.
10
+ * 3. Same idea for src/style.css's automatic `text { font-family }` rule.
11
+ * 4. Otherwise print the exact lines the user must paste by hand.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
18
+ const packageRoot = path.join(scriptDir, "..");
19
+
20
+ const FONT_EXT = /\.(ttf|otf|ttc)$/i;
21
+
22
+ /** Normalize for equality checks (EOL + trailing whitespace per line). */
23
+ export function normalizeSource(text) {
24
+ return (
25
+ text
26
+ .replace(/\r\n/g, "\n")
27
+ .split("\n")
28
+ .map((line) => line.replace(/\s+$/u, ""))
29
+ .join("\n")
30
+ .replace(/\n+$/u, "") + "\n"
31
+ );
32
+ }
33
+
34
+ export function buildFontBlock(fonts) {
35
+ const defaultFamily = fonts[0].family;
36
+ const extraFamilies = fonts.slice(1).map((f) => f.family);
37
+ const requireEntries = fonts
38
+ .map(
39
+ ({ family, file }) =>
40
+ `\t\t{ family: "${family}", file: "${file}", url: require("./assets/fonts/${file}") },`,
41
+ )
42
+ .join("\n");
43
+ const assetEntries = fonts
44
+ .map(
45
+ ({ family, file }) =>
46
+ `\t\t{ family: "${family}", file: "${file}", url: "asset:///fonts/${file}" },`,
47
+ )
48
+ .join("\n");
49
+
50
+ return [
51
+ fonts.length === 1
52
+ ? `// Font: "${defaultFamily}", from src/assets/fonts/${fonts[0].file}.`
53
+ : `// Fonts: ${fonts.map((f) => `"${f.family}"`).join(", ")}, from src/assets/fonts/.`,
54
+ "// Registered in a loop via lynx.addFont(). Split by build mode:",
55
+ "// • DEV (Lynx Go / `npm run dev`): require() → data: URI inline",
56
+ "// • PROD (Android APK / `npm run build`): asset:///fonts/<file>",
57
+ "// resolved by AssetFontFaceLoader (lynx#9431 workaround).",
58
+ ...(extraFamilies.length > 0
59
+ ? [
60
+ `// Only "${defaultFamily}" got the automatic text { font-family: ... }`,
61
+ `// rule below — assign ${extraFamilies.map((f) => `"${f}"`).join(" / ")} to your own`,
62
+ '// classes in src/style.css, e.g. `.code { font-family: "' + extraFamilies[0] + '"; }`.',
63
+ ]
64
+ : []),
65
+ "type FontEntry = { family: string; file: string; url: string };",
66
+ "const FONTS: FontEntry[] = import.meta.env.DEV",
67
+ "\t? [",
68
+ requireEntries,
69
+ "\t ]",
70
+ "\t: [",
71
+ assetEntries,
72
+ "\t ];",
73
+ "for (const { family, url } of FONTS) {",
74
+ '\tlynx.addFont({ "font-family": family, src: `url("${url}")` }, () => {});',
75
+ "}",
76
+ "",
77
+ "",
78
+ ].join("\n");
79
+ }
80
+
81
+ export function buildCssFontBlock(family) {
82
+ return [`text {`, ` font-family: "${family}", sans-serif;`, `}`, "", ""].join("\n");
83
+ }
84
+
85
+ /** Match our generated font block at the top of background.ts (if any). */
86
+ const FONT_BLOCK_RE =
87
+ /^(?:\/\/ Font[^\n]*\n)(?:\/\/[^\n]*\n)*type FontEntry = \{ family: string; file: string; url: string \};\nconst FONTS: FontEntry\[\] = import\.meta\.env\.DEV\n\t\? \[[\s\S]*?\t \]\n\t: \[[\s\S]*?\t \];\nfor \(const \{ family, url \} of FONTS\) \{\n\tlynx\.addFont\(\{ "font-family": family, src: `url\("\$\{url\}"\)` \}, \(\) => \{\}\);\n\}\n\n/;
88
+
89
+ const CSS_FONT_RE = /^text \{\n font-family: "[^"]+", sans-serif;\n\}\n\n?/;
90
+
91
+ export function stripFontBlock(backgroundSource) {
92
+ const normalized = normalizeSource(backgroundSource);
93
+ const match = normalized.match(FONT_BLOCK_RE);
94
+ if (!match) return { block: null, rest: normalized, fonts: [] };
95
+ return {
96
+ block: match[0],
97
+ rest: normalized.slice(match[0].length),
98
+ fonts: parseFontsFromBlock(match[0]),
99
+ };
100
+ }
101
+
102
+ export function parseFontsFromBlock(block) {
103
+ const fonts = [];
104
+ const seen = new Set();
105
+ for (const match of block.matchAll(/\{\s*family:\s*"([^"]+)",\s*file:\s*"([^"]+)"/g)) {
106
+ const [, family, file] = match;
107
+ if (seen.has(file)) continue;
108
+ seen.add(file);
109
+ fonts.push({ family, file });
110
+ }
111
+ return fonts;
112
+ }
113
+
114
+ export function stripCssFontBlock(cssSource) {
115
+ const normalized = normalizeSource(cssSource);
116
+ const match = normalized.match(CSS_FONT_RE);
117
+ if (!match) return { block: null, rest: normalized, family: null };
118
+ const familyMatch = match[0].match(/font-family: "([^"]+)"/);
119
+ return {
120
+ block: match[0],
121
+ rest: normalized.slice(match[0].length),
122
+ family: familyMatch ? familyMatch[1] : null,
123
+ };
124
+ }
125
+
126
+ function loadBaseBackgrounds() {
127
+ return [
128
+ normalizeSource(fs.readFileSync(path.join(packageRoot, "templates/_shared/ts/src/background.ts"), "utf8")),
129
+ normalizeSource(
130
+ fs.readFileSync(path.join(packageRoot, "templates/basic-activity/ts/src/background.ts"), "utf8"),
131
+ ),
132
+ ];
133
+ }
134
+
135
+ function loadBaseStyles() {
136
+ const out = [];
137
+ for (const rel of [
138
+ "templates/blank/common/src/style.css",
139
+ "templates/hello-world/common/src/style.css",
140
+ "templates/basic-activity/common/src/style.css",
141
+ ]) {
142
+ out.push(normalizeSource(fs.readFileSync(path.join(packageRoot, rel), "utf8")));
143
+ }
144
+ return out;
145
+ }
146
+
147
+ export function isStockBackground(restSource) {
148
+ const normalized = normalizeSource(restSource);
149
+ return loadBaseBackgrounds().some((base) => base === normalized);
150
+ }
151
+
152
+ export function isStockCss(restSource) {
153
+ const normalized = normalizeSource(restSource);
154
+ return loadBaseStyles().some((base) => base === normalized);
155
+ }
156
+
157
+ /**
158
+ * Copy font files into the JS project (and Android host assets when found).
159
+ * Always safe — does not touch background.ts / style.css.
160
+ */
161
+ export function copyFontFiles({ projectRoot, fonts, androidDir = null }) {
162
+ const jsFontsDir = path.join(projectRoot, "src", "assets", "fonts");
163
+ fs.mkdirSync(jsFontsDir, { recursive: true });
164
+ const copied = [];
165
+
166
+ for (const font of fonts) {
167
+ const dest = path.join(jsFontsDir, font.file);
168
+ fs.copyFileSync(font.sourcePath, dest);
169
+ copied.push({ role: "js", path: dest });
170
+ }
171
+
172
+ if (androidDir != null) {
173
+ const androidFontsDir = path.join(androidDir, "app", "src", "main", "assets", "fonts");
174
+ fs.mkdirSync(androidFontsDir, { recursive: true });
175
+ for (const font of fonts) {
176
+ const dest = path.join(androidFontsDir, font.file);
177
+ fs.copyFileSync(font.sourcePath, dest);
178
+ copied.push({ role: "android", path: dest });
179
+ }
180
+ }
181
+
182
+ return copied;
183
+ }
184
+
185
+ /**
186
+ * Merge newly requested fonts with ones already registered in background.ts
187
+ * (or already sitting in src/assets/fonts/). `dedupeFontFiles` mutates `.file`.
188
+ */
189
+ export function mergeFontLists(existing, incoming, dedupeFontFiles) {
190
+ const merged = [];
191
+ const byFile = new Set();
192
+ for (const font of existing) {
193
+ if (byFile.has(font.file)) continue;
194
+ byFile.add(font.file);
195
+ merged.push({ ...font });
196
+ }
197
+ for (const font of incoming) {
198
+ merged.push({
199
+ sourcePath: font.sourcePath,
200
+ file: font.file,
201
+ family: font.family,
202
+ });
203
+ }
204
+ dedupeFontFiles(merged);
205
+ return merged;
206
+ }
207
+
208
+ /**
209
+ * Try to rewrite background.ts. Returns { ok, reason?, manualBlock? }.
210
+ */
211
+ export function applyBackgroundFonts(projectRoot, fonts) {
212
+ const bgPath = path.join(projectRoot, "src", "background.ts");
213
+ if (!fs.existsSync(bgPath)) {
214
+ return {
215
+ ok: false,
216
+ reason: "src/background.ts not found",
217
+ manualBlock: buildFontBlock(fonts),
218
+ };
219
+ }
220
+ const raw = fs.readFileSync(bgPath, "utf8");
221
+ const { rest } = stripFontBlock(raw);
222
+ if (!isStockBackground(rest)) {
223
+ return {
224
+ ok: false,
225
+ reason: "src/background.ts no longer matches the stock template",
226
+ manualBlock: buildFontBlock(fonts),
227
+ };
228
+ }
229
+ fs.writeFileSync(bgPath, `${buildFontBlock(fonts)}${rest}`);
230
+ return { ok: true };
231
+ }
232
+
233
+ /**
234
+ * Try to ensure the automatic text { font-family } rule for `defaultFamily`.
235
+ * Extra fonts are never written into CSS (same policy as scaffold).
236
+ * Returns { ok, skipped?, reason?, manualBlock? }.
237
+ */
238
+ export function applyCssDefaultFont(projectRoot, defaultFamily, { hadExistingFonts }) {
239
+ const cssPath = path.join(projectRoot, "src", "style.css");
240
+ if (!fs.existsSync(cssPath)) {
241
+ return {
242
+ ok: false,
243
+ reason: "src/style.css not found",
244
+ manualBlock: buildCssFontBlock(defaultFamily),
245
+ };
246
+ }
247
+ const raw = fs.readFileSync(cssPath, "utf8");
248
+ const { rest, family: existingFamily } = stripCssFontBlock(raw);
249
+
250
+ // Already has our rule and we were only appending more fonts — leave it.
251
+ if (hadExistingFonts && existingFamily != null) {
252
+ return { ok: true, skipped: true };
253
+ }
254
+
255
+ if (!isStockCss(rest)) {
256
+ return {
257
+ ok: false,
258
+ reason: "src/style.css no longer matches the stock template",
259
+ manualBlock: buildCssFontBlock(defaultFamily),
260
+ };
261
+ }
262
+
263
+ fs.writeFileSync(cssPath, `${buildCssFontBlock(defaultFamily)}${rest}`);
264
+ return { ok: true };
265
+ }
266
+
267
+ /** Resolve sibling Android host: scripts/android.mjs ANDROID_DIR, or <name>-android/. */
268
+ export function findAndroidDir(projectRoot) {
269
+ const scriptPath = path.join(projectRoot, "scripts", "android.mjs");
270
+ if (fs.existsSync(scriptPath)) {
271
+ const src = fs.readFileSync(scriptPath, "utf8");
272
+ const match = src.match(/path\.resolve\(projectRoot,\s*"([^"]+)"\)/);
273
+ if (match) {
274
+ const resolved = path.resolve(projectRoot, match[1]);
275
+ if (fs.existsSync(path.join(resolved, "settings.gradle.kts"))) return resolved;
276
+ }
277
+ }
278
+ const parent = path.dirname(projectRoot);
279
+ const guess = path.join(parent, `${path.basename(projectRoot)}-android`);
280
+ if (fs.existsSync(path.join(guess, "settings.gradle.kts"))) return guess;
281
+ return null;
282
+ }
283
+
284
+ /** Walk up from cwd looking for a mithril-lynx app (lynx.config.ts + src/). */
285
+ export function findProjectRoot(startDir) {
286
+ let dir = path.resolve(startDir);
287
+ for (;;) {
288
+ if (
289
+ fs.existsSync(path.join(dir, "lynx.config.ts")) &&
290
+ fs.existsSync(path.join(dir, "src", "background.ts"))
291
+ ) {
292
+ return dir;
293
+ }
294
+ const parent = path.dirname(dir);
295
+ if (parent === dir) return null;
296
+ dir = parent;
297
+ }
298
+ }
299
+
300
+ export function listFontsOnDisk(projectRoot) {
301
+ const fontsDir = path.join(projectRoot, "src", "assets", "fonts");
302
+ if (!fs.existsSync(fontsDir)) return [];
303
+ return fs
304
+ .readdirSync(fontsDir)
305
+ .filter((name) => FONT_EXT.test(name))
306
+ .map((file) => ({
307
+ file,
308
+ family: file
309
+ .replace(FONT_EXT, "")
310
+ .split(/[_\-.]+/)
311
+ .filter(Boolean)
312
+ .map((word) => word[0].toUpperCase() + word.slice(1))
313
+ .join(" "),
314
+ sourcePath: path.join(fontsDir, file),
315
+ }));
316
+ }
317
+
318
+ export { FONT_EXT };
package/src/index.js CHANGED
@@ -8,6 +8,17 @@ import { execSync } from "node:child_process";
8
8
  import { cancel, confirm, intro, isCancel, outro, select, spinner, text } from "@clack/prompts";
9
9
 
10
10
  import { fetchFontDetail, fetchFontList, listVariants, searchFonts } from "./fontsource.js";
11
+ import {
12
+ applyBackgroundFonts,
13
+ applyCssDefaultFont,
14
+ buildCssFontBlock,
15
+ buildFontBlock,
16
+ copyFontFiles,
17
+ findAndroidDir,
18
+ findProjectRoot,
19
+ mergeFontLists,
20
+ stripFontBlock,
21
+ } from "./fonts-wire.js";
11
22
 
12
23
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
13
24
  const packageRoot = path.join(scriptDir, "..");
@@ -162,16 +173,6 @@ function dedupeFontFiles(fonts) {
162
173
  }
163
174
  }
164
175
 
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
-
175
176
  function xmlEscape(value) {
176
177
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
177
178
  }
@@ -393,25 +394,45 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
393
394
  }
394
395
  appName = appName ?? path.basename(rawName);
395
396
 
397
+ const fonts = await resolveFontsFromFlags(android, { baseDir: cwd, requireFonts: false });
398
+ if (fonts == null) return null;
399
+
400
+ return { androidId, appName, fonts };
401
+ }
402
+
403
+ /**
404
+ * Shared by scaffold and `add-font`: resolve --with-font / --find-font /
405
+ * --font-family into `{ sourcePath, file, family }[]`. Returns null if the
406
+ * interactive --find-font flow was cancelled; exits on hard errors.
407
+ * When `requireFonts` is false (scaffold without --with-font), returns [].
408
+ */
409
+ async function resolveFontsFromFlags(android, { baseDir, requireFonts }) {
396
410
  if (android.fontPath != null && android.findFontTerm != null) {
397
411
  cancel("--with-font and --find-font are mutually exclusive — pick one.");
398
412
  process.exit(1);
399
413
  }
400
414
 
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
415
  const fontPaths = android.fontPath != null ? splitList(android.fontPath) : [];
404
416
  const findFontTerms = android.findFontTerm != null ? splitList(android.findFontTerm) : [];
405
417
  const fontCount = fontPaths.length + findFontTerms.length;
406
418
 
407
419
  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.");
420
+ cancel(
421
+ "--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.",
422
+ );
409
423
  process.exit(1);
410
424
  }
411
425
  if (android.fontFamily != null && fontCount === 0) {
412
426
  cancel("--font-family only makes sense together with --with-font or --find-font.");
413
427
  process.exit(1);
414
428
  }
429
+ if (fontCount === 0) {
430
+ if (requireFonts) {
431
+ cancel("Pass --with-font <file.ttf> or --find-font <term> (comma-separate for more than one).");
432
+ process.exit(1);
433
+ }
434
+ return [];
435
+ }
415
436
 
416
437
  const fonts = [];
417
438
  for (const term of findFontTerms) {
@@ -424,7 +445,7 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
424
445
  });
425
446
  }
426
447
  for (const rawPath of fontPaths) {
427
- const resolved = path.resolve(cwd, rawPath);
448
+ const resolved = path.resolve(baseDir, rawPath);
428
449
  if (!fs.existsSync(resolved)) {
429
450
  cancel(`Font file not found: ${rawPath}`);
430
451
  process.exit(1);
@@ -440,8 +461,111 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
440
461
  });
441
462
  }
442
463
  dedupeFontFiles(fonts);
464
+ return fonts;
465
+ }
443
466
 
444
- return { androidId, appName, fonts };
467
+ function printManualBlock(title, block) {
468
+ console.log("");
469
+ console.log(` ${title}`);
470
+ console.log(" ────────────────────────────────────────");
471
+ for (const line of block.replace(/\n$/, "").split("\n")) {
472
+ console.log(` ${line}`);
473
+ }
474
+ console.log(" ────────────────────────────────────────");
475
+ console.log("");
476
+ }
477
+
478
+ /**
479
+ * Post-init: add one or more fonts to an existing mithril-lynx project.
480
+ * Always copies the files; patches background.ts / style.css only when they
481
+ * still match the stock template (optionally with our FONTS block on top).
482
+ */
483
+ async function runAddFont(args) {
484
+ intro("create-mithril-lynx add-font");
485
+
486
+ const android = parseAndroidArgs(args);
487
+ if (android.fontPath == null && android.findFontTerm == null) {
488
+ cancel("Pass --with-font <file.ttf> or --find-font <term>.");
489
+ process.exit(1);
490
+ }
491
+
492
+ const projectRoot = findProjectRoot(cwd);
493
+ if (projectRoot == null) {
494
+ cancel(
495
+ "No mithril-lynx project found here (need lynx.config.ts + src/background.ts).\n" +
496
+ " cd into the app directory and try again.",
497
+ );
498
+ process.exit(1);
499
+ }
500
+
501
+ const incoming = await resolveFontsFromFlags(android, { baseDir: cwd, requireFonts: true });
502
+ if (incoming == null) return;
503
+
504
+ const bgPath = path.join(projectRoot, "src", "background.ts");
505
+ const { fonts: existingInBg } = stripFontBlock(
506
+ fs.existsSync(bgPath) ? fs.readFileSync(bgPath, "utf8") : "",
507
+ );
508
+ const hadExistingFonts = existingInBg.length > 0;
509
+ const merged = mergeFontLists(existingInBg, incoming, dedupeFontFiles);
510
+
511
+ const androidDir = findAndroidDir(projectRoot);
512
+ const copied = copyFontFiles({
513
+ projectRoot,
514
+ fonts: incoming,
515
+ androidDir,
516
+ });
517
+
518
+ for (const item of copied) {
519
+ const rel = path.relative(cwd, item.path);
520
+ console.log(` → ${rel}${item.role === "android" ? " (android host)" : ""}`);
521
+ }
522
+ if (androidDir == null) {
523
+ console.log(
524
+ " ℹ No Android host found next to this app — fonts were only copied into src/assets/fonts/.\n" +
525
+ " (PROD asset:/// registration needs the host's AssetFontFaceLoader.)",
526
+ );
527
+ }
528
+
529
+ const bgResult = applyBackgroundFonts(projectRoot, merged);
530
+ if (bgResult.ok) {
531
+ console.log(" ✔ Updated src/background.ts");
532
+ } else {
533
+ console.log(` ✖ Could not auto-edit src/background.ts (${bgResult.reason}).`);
534
+ console.log(" Paste this block at the top of src/background.ts:");
535
+ printManualBlock("src/background.ts", bgResult.manualBlock);
536
+ }
537
+
538
+ const cssResult = applyCssDefaultFont(projectRoot, merged[0].family, { hadExistingFonts });
539
+ if (cssResult.ok && cssResult.skipped) {
540
+ console.log(
541
+ ` · src/style.css already has text { font-family: … } — assign extra fonts in your own classes.`,
542
+ );
543
+ } else if (cssResult.ok) {
544
+ console.log(" ✔ Updated src/style.css");
545
+ } else {
546
+ console.log(` ✖ Could not auto-edit src/style.css (${cssResult.reason}).`);
547
+ console.log(" Paste this at the top of src/style.css (or assign the family on your own classes):");
548
+ printManualBlock("src/style.css", cssResult.manualBlock);
549
+ }
550
+
551
+ const extras = merged.slice(hadExistingFonts ? existingInBg.length : 1).map((f) => f.family);
552
+ // When we had no prior fonts, slice(1) are extras beyond the CSS default.
553
+ // When we had prior fonts, newly added ones after existingInBg.length need a note.
554
+ const newlyAddedFamilies = incoming.map((f) => f.family);
555
+ if (hadExistingFonts && newlyAddedFamilies.length > 0) {
556
+ console.log(
557
+ ` · New font${newlyAddedFamilies.length > 1 ? "s" : ""} ${newlyAddedFamilies.map((f) => `"${f}"`).join(", ")} — set font-family on your own CSS classes.`,
558
+ );
559
+ } else if (!hadExistingFonts && extras.length > 0) {
560
+ console.log(
561
+ ` · Extra font${extras.length > 1 ? "s" : ""} ${extras.map((f) => `"${f}"`).join(", ")} — set font-family on your own CSS classes.`,
562
+ );
563
+ }
564
+
565
+ outro(
566
+ `Done. Font${merged.length > 1 ? "s" : ""} ${merged.map((f) => `"${f.family}"`).join(", ")} ` +
567
+ (bgResult.ok ? "registered." : "copied — finish registration with the lines above."),
568
+ );
445
569
  }
446
570
 
447
571
  function scaffoldAndroid({ targetDir, rawName, options }) {
@@ -470,15 +594,11 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
470
594
  // best-effort
471
595
  }
472
596
 
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).
597
+ // 2. Fonts: the host always ships AssetFontFaceLoader +
598
+ // NoopGenericResourceFetcher (lynx#9431 workaround). Production
599
+ // bundles register fonts as asset:///fonts/<file>; Lynx Go / dev
600
+ // uses inlined data: URIs. See patchJsProject() for the JS wiring
601
+ // and templates/android/host/.../NoopGenericResourceFetcher.kt.
482
602
  const sdkDir = findAndroidSdk();
483
603
 
484
604
  // 3. Text substitutions across the whole Android host (skipping the
@@ -496,7 +616,17 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
496
616
  ],
497
617
  ]);
498
618
 
499
- // 4. The script that joins the two halves, inside the JS project.
619
+ // 4. Seed assets/fonts/ so the first APK build works even before
620
+ // scripts/android.mjs syncs (it also keeps them in sync later).
621
+ if (fonts.length > 0) {
622
+ const androidFontsDir = path.join(androidDir, "app", "src", "main", "assets", "fonts");
623
+ fs.mkdirSync(androidFontsDir, { recursive: true });
624
+ for (const { sourcePath, file } of fonts) {
625
+ fs.copyFileSync(sourcePath, path.join(androidFontsDir, file));
626
+ }
627
+ }
628
+
629
+ // 5. The script that joins the two halves, inside the JS project.
500
630
  const scriptsDir = path.join(targetDir, "scripts");
501
631
  fs.mkdirSync(scriptsDir, { recursive: true });
502
632
  fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android.mjs"), path.join(scriptsDir, "android.mjs"));
@@ -527,76 +657,22 @@ function patchJsProject({ targetDir, android }) {
527
657
  };
528
658
  fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
529
659
 
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).
660
+ // Fonts: copy + register via lynx.addFont() loop (DEV data: / PROD
661
+ // asset:///) — see src/fonts-wire.js and the Android host's
662
+ // AssetFontFaceLoader / NoopGenericResourceFetcher (lynx#9431).
545
663
  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
-
664
+ copyFontFiles({
665
+ projectRoot: targetDir,
666
+ fonts: android.fonts,
667
+ androidDir: android.androidDir,
668
+ });
573
669
  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
- "",
591
- "",
592
- ].join("\n");
593
670
  const existingBg = fs.existsSync(bgPath) ? fs.readFileSync(bgPath, "utf8") : "";
594
- fs.writeFileSync(bgPath, `${fontBlock}${existingBg}`);
671
+ fs.writeFileSync(bgPath, `${buildFontBlock(android.fonts)}${existingBg}`);
595
672
 
596
673
  const cssPath = path.join(targetDir, "src", "style.css");
597
- const cssBlock = [`text {`, ` font-family: "${defaultFamily}", sans-serif;`, `}`, ""].join("\n");
598
674
  const existingCss = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, "utf8") : "";
599
- fs.writeFileSync(cssPath, `${cssBlock}\n${existingCss}`);
675
+ fs.writeFileSync(cssPath, `${buildCssFontBlock(android.fonts[0].family)}${existingCss}`);
600
676
  }
601
677
 
602
678
  // README: how the two halves are used together.
@@ -622,7 +698,7 @@ function patchJsProject({ targetDir, android }) {
622
698
  `- Application class: \`${android.appClass}\` · Activity: \`MainActivity\``,
623
699
  ...android.fonts.map(
624
700
  (f) =>
625
- `- Font \`${f.family}\` loaded via \`lynx.addFont()\` from \`src/assets/fonts/${f.file}\` (bundled as a data: URI no native code involved).`,
701
+ `- Font \`${f.family}\` (\`src/assets/fonts/${f.file}\`): \`lynx.addFont()\` DEV inlines a \`data:\` URI for Lynx Go; PROD uses \`asset:///fonts/${f.file}\` via \`AssetFontFaceLoader\` ([lynx#9431](https://github.com/lynx-family/lynx/issues/9431)).`,
626
702
  ),
627
703
  "",
628
704
  ].join("\n");
@@ -638,6 +714,8 @@ create-mithril-lynx — scaffold a mithril-lynx app (and, optionally, its Androi
638
714
  Usage:
639
715
  npm create mithril-lynx@latest [name] [options]
640
716
  npx create-mithril-lynx <name> --blank --android
717
+ npx create-mithril-lynx add-font --with-font ./Foo.ttf
718
+ npx create-mithril-lynx add-font --find-font Inter
641
719
 
642
720
  Template (prompted for if omitted):
643
721
  --hello-world | --blank | --basic-activity
@@ -648,9 +726,10 @@ Android host:
648
726
  --android-id <id> applicationId / namespace (default com.example.<name>)
649
727
  --app-name <name> launcher label (default: the project name)
650
728
  --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
729
+ with lynx.addFont() (DEV: data: URI for Lynx Go;
730
+ PROD: asset:/// + AssetFontFaceLoader — see README).
731
+ Comma-separate for more than one,
732
+ e.g. --with-font a.ttf,b.ttf
654
733
  --find-font <term> search Fontsource (fontsource.org) for a font,
655
734
  pick a family and a weight/style interactively,
656
735
  and bundle it the same way as --with-font.
@@ -660,6 +739,15 @@ Android host:
660
739
  --font-family <name> override the family name derived from the file
661
740
  name (only valid with exactly one font)
662
741
 
742
+ Post-init (run inside an existing app directory):
743
+ add-font copy font file(s) into src/assets/fonts/ (and the
744
+ Android host's assets/fonts/ when present). If
745
+ background.ts / style.css still match the stock
746
+ template, wire them up automatically; otherwise
747
+ print the lines to paste by hand.
748
+ Same --with-font / --find-font / --font-family
749
+ flags as above.
750
+
663
751
  Other:
664
752
  --no-install don't install dependencies
665
753
  -h, --help print this
@@ -671,6 +759,7 @@ async function main() {
671
759
  // create-mithril-lynx my-app --basic-activity --no-install
672
760
  // create-mithril-lynx my-app --blank --android --android-id com.acme.miapp
673
761
  // create-mithril-lynx my-app --blank --with-font ./UbuntuMono-Regular.ttf
762
+ // create-mithril-lynx add-font --with-font ./Extra.ttf
674
763
  const args = process.argv.slice(2);
675
764
 
676
765
  if (args.includes("--help") || args.includes("-h")) {
@@ -678,6 +767,11 @@ async function main() {
678
767
  return;
679
768
  }
680
769
 
770
+ if (args[0] === "add-font") {
771
+ await runAddFont(args.slice(1));
772
+ return;
773
+ }
774
+
681
775
  const positional = findPositional(args);
682
776
  const templateFlag = TEMPLATE_VALUES.find((t) => args.includes(`--${t}`));
683
777
  const noInstall = args.includes("--no-install");
@@ -810,7 +904,7 @@ async function main() {
810
904
  `${androidDirName}/local.properties (sdk.dir=...).`
811
905
  : `Android SDK found at ${sdkDir}.`,
812
906
  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.`
907
+ ? `Font${fonts.length > 1 ? "s" : ""} ${fonts.map((f) => `"${f.family}"`).join(", ")} registered via lynx.addFont() — Lynx Go (data:) and the APK (asset:///) both covered.`
814
908
  : "No custom font — pass --with-font <file.ttf> (or --find-font <term>) to bundle one.",
815
909
  ];
816
910
  outro(`Done! Next steps:\n\n ${steps.join("\n ")}\n\n${notes.join("\n")}`);
@@ -25,7 +25,11 @@ declare const module: {
25
25
  accept(path: string, callback: () => void): void;
26
26
  };
27
27
  };
28
- declare const require: (id: string) => typeof indexModule;
28
+ // Overloads: HMR reloads index.js; DEV font registration (prepended by
29
+ // create-mithril-lynx when --with-font/--find-font is used) requires .ttf
30
+ // paths as string URLs.
31
+ declare function require(id: "./index.js"): typeof indexModule;
32
+ declare function require(id: string): string;
29
33
 
30
34
  if (module.hot) {
31
35
  module.hot.accept("./index.js", () => {
@@ -34,6 +34,13 @@ const BUNDLE_NAME = "main-thread.bundle";
34
34
  const BUNDLE_SRC = path.join(projectRoot, "dist", BUNDLE_NAME);
35
35
  const BUNDLE_DEST = path.join(ANDROID_DIR, "app", "src", "main", "assets", BUNDLE_NAME);
36
36
 
37
+ // Custom fonts (src/assets/fonts/*.ttf) — production bundles resolve them via
38
+ // asset:///fonts/<file> (AssetFontFaceLoader in the Android host). Keep the
39
+ // host's assets/fonts/ in sync with the JS project on every android sync.
40
+ const FONTS_SRC = path.join(projectRoot, "src", "assets", "fonts");
41
+ const FONTS_DEST = path.join(ANDROID_DIR, "app", "src", "main", "assets", "fonts");
42
+ const FONT_EXT = /\.(ttf|otf|ttc)$/i;
43
+
37
44
  const USAGE = `
38
45
  Usage: npm run android -- [flags] [-- gradle-args]
39
46
 
@@ -97,6 +104,19 @@ function requireAndroidDir() {
97
104
  }
98
105
  }
99
106
 
107
+ function syncFonts() {
108
+ if (!fs.existsSync(FONTS_SRC)) return;
109
+ const files = fs.readdirSync(FONTS_SRC).filter((name) => FONT_EXT.test(name));
110
+ if (files.length === 0) return;
111
+ fs.mkdirSync(FONTS_DEST, { recursive: true });
112
+ for (const name of files) {
113
+ const dest = path.join(FONTS_DEST, name);
114
+ fs.copyFileSync(path.join(FONTS_SRC, name), dest);
115
+ const kb = (fs.statSync(dest).size / 1024).toFixed(1);
116
+ console.log(` → ${path.relative(projectRoot, dest)} (${kb} kB)`);
117
+ }
118
+ }
119
+
100
120
  function syncBundle() {
101
121
  if (!fs.existsSync(BUNDLE_SRC)) {
102
122
  fail(`${path.relative(projectRoot, BUNDLE_SRC)} doesn't exist. Run "npm run build" first (or drop --no-build).`);
@@ -106,6 +126,7 @@ function syncBundle() {
106
126
  fs.copyFileSync(BUNDLE_SRC, BUNDLE_DEST);
107
127
  const kb = (fs.statSync(BUNDLE_DEST).size / 1024).toFixed(1);
108
128
  console.log(` → ${path.relative(projectRoot, BUNDLE_DEST)} (${kb} kB)`);
129
+ syncFonts();
109
130
  }
110
131
 
111
132
  function generateKeystore() {
@@ -3,6 +3,7 @@ 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
+ import com.lynx.tasm.loader.LynxFontFaceLoader
6
7
  import com.lynx.tasm.service.LynxServiceCenter
7
8
 
8
9
  class {{APP_CLASS}} : Application() {
@@ -16,6 +17,12 @@ class {{APP_CLASS}} : Application() {
16
17
  LynxServiceCenter.inst().registerService(LynxLogService)
17
18
  LynxLogService.switchLogToSystem(true)
18
19
 
20
+ // Lets FontFaceManager resolve "asset:///" (production fonts under
21
+ // assets/fonts/) — see AssetFontFaceLoader. Pairs with
22
+ // NoopGenericResourceFetcher in MainActivity for the fast @font-face
23
+ // path (https://github.com/lynx-family/lynx/issues/9431).
24
+ LynxFontFaceLoader.setLoader(AssetFontFaceLoader)
25
+
19
26
  LynxEnv.inst().init(this, null, null, null)
20
27
  }
21
28
  }
@@ -0,0 +1,34 @@
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
+ // Public Lynx extension point (com.lynx.tasm.loader.LynxFontFaceLoader, in the
9
+ // core `lynx` artifact) for resolving custom @font-face / addFont src schemes.
10
+ // Without a registered Loader, Lynx's default never resolves "asset:///" —
11
+ // FontFaceManager only handles asset:/// inline when a "FONT"
12
+ // LynxResourceProvider is registered (we don't), and prefetchFont()'s
13
+ // non-http/non-data: branch goes ONLY through this Loader.
14
+ //
15
+ // Production builds register fonts as asset:///fonts/<file> (see the generated
16
+ // background.ts) so the APK stays small and cold-start stays fast; Lynx Go /
17
+ // `rspeedy dev` still uses inlined data: URIs via NoopGenericResourceFetcher.
18
+ // See https://github.com/lynx-family/lynx/issues/9431
19
+ object AssetFontFaceLoader : LynxFontFaceLoader.Loader() {
20
+ private const val ASSET_PREFIX = "asset:///"
21
+
22
+ override fun onLoadFontFace(
23
+ context: LynxContext,
24
+ type: FontFace.TYPE,
25
+ src: String,
26
+ ): Typeface? {
27
+ if (!src.startsWith(ASSET_PREFIX)) return null
28
+ return try {
29
+ Typeface.createFromAsset(context.context.assets, src.removePrefix(ASSET_PREFIX))
30
+ } catch (e: Exception) {
31
+ null
32
+ }
33
+ }
34
+ }
@@ -3,6 +3,7 @@ package {{PACKAGE_NAME}}
3
3
  import android.os.Bundle
4
4
  import androidx.appcompat.app.AppCompatActivity
5
5
  import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
6
+ import com.lynx.tasm.LynxBooleanOption
6
7
  import com.lynx.tasm.LynxViewBuilder
7
8
  import com.lynx.tasm.ThreadStrategyForRendering
8
9
  import com.lynx.xelement.XElementBehaviors
@@ -19,6 +20,15 @@ class MainActivity : AppCompatActivity() {
19
20
  // keyboard. Remove this line (and the xelement dependencies) if you
20
21
  // don't use them.
21
22
  builder.addBehaviors(XElementBehaviors().create())
23
+ // lynx-family/lynx's own explorer/android registers a
24
+ // GenericResourceFetcher unconditionally (LynxViewShellActivity —
25
+ // "used inside LynxEngine for resource loading capabilities of
26
+ // components such as Text"). Without one, @font-face / addFont
27
+ // data: URIs fall back to a ~1.4-1.5s legacy path — see
28
+ // NoopGenericResourceFetcher and
29
+ // https://github.com/lynx-family/lynx/issues/9431
30
+ builder.setEnableGenericResourceFetcher(LynxBooleanOption.TRUE)
31
+ builder.setGenericResourceFetcher(NoopGenericResourceFetcher())
22
32
  builder.setThreadStrategyForRendering(ThreadStrategyForRendering.ALL_ON_UI)
23
33
  // Reads the bundle from assets on a separate thread — see
24
34
  // AssetTemplateProvider's comment: reading it synchronously here cost
@@ -0,0 +1,47 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.util.Base64
4
+ import com.lynx.tasm.resourceprovider.LynxResourceCallback
5
+ import com.lynx.tasm.resourceprovider.LynxResourceRequest
6
+ import com.lynx.tasm.resourceprovider.LynxResourceResponse
7
+ import com.lynx.tasm.resourceprovider.generic.LynxGenericResourceFetcher
8
+
9
+ /**
10
+ * Decodes `data:` URIs for @font-face / lynx.addFont() src resolution —
11
+ * nothing else (this host never fetches real remote resources this way).
12
+ *
13
+ * Root cause of the ~1.4-1.5s cold-start cost custom fonts added, found by
14
+ * diffing LynxExplorer's logcat while it loaded a @font-face-heavy bundle
15
+ * fast (2026-09-10): it logs `FontFaceManager: Try to loadTypeface with
16
+ * GenericLynxResourceFetcher` → success in ~13ms. @font-face src resolution
17
+ * — even for a `data:` URI — routes through
18
+ * LynxGenericResourceFetcher.fetchResource(); WITHOUT one registered, that
19
+ * fast path fails and the engine falls back to a much slower legacy
20
+ * font-loading path (~1.4-1.5s fixed cost).
21
+ *
22
+ * Tracked upstream: https://github.com/lynx-family/lynx/issues/9431
23
+ */
24
+ class NoopGenericResourceFetcher : LynxGenericResourceFetcher() {
25
+ @Suppress("UNCHECKED_CAST")
26
+ override fun fetchResource(request: LynxResourceRequest, callback: LynxResourceCallback<ByteArray>) {
27
+ val url = request.url
28
+ val commaIndex = url.indexOf(',')
29
+ if (url.startsWith("data:") && commaIndex != -1) {
30
+ try {
31
+ val bytes = Base64.decode(url.substring(commaIndex + 1), Base64.DEFAULT)
32
+ callback.onResponse(LynxResourceResponse.onSuccess(bytes))
33
+ return
34
+ } catch (e: IllegalArgumentException) {
35
+ // fall through to failure below
36
+ }
37
+ }
38
+ val response = LynxResourceResponse.onFailed(Throwable("not supported: $url")) as LynxResourceResponse<ByteArray>
39
+ callback.onResponse(response)
40
+ }
41
+
42
+ @Suppress("UNCHECKED_CAST")
43
+ override fun fetchResourcePath(request: LynxResourceRequest, callback: LynxResourceCallback<String>) {
44
+ val response = LynxResourceResponse.onFailed(Throwable("not supported")) as LynxResourceResponse<String>
45
+ callback.onResponse(response)
46
+ }
47
+ }