create-mithril-lynx 2.0.0 → 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 +24 -7
- package/package.json +1 -1
- package/src/fontsource.js +102 -0
- package/src/index.js +204 -97
- package/templates/android/host/app/src/main/java/package-path/App.kt +0 -2
- package/templates/android/host/app/src/main/java/package-path/MainActivity.kt +0 -2
- package/templates/android/font/AssetFontFaceLoader.kt +0 -39
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Scaffolds a new [`mithril-lynx`](https://github.com/carlos-sweb/mithril-lynx) app. Three templates, matching the "Hello World" / "Blank" / "Basic Activity" naming most native app scaffolds already use.
|
|
4
4
|
|
|
5
|
-
It can also generate the **native Android host** — the whole Gradle project that wraps the bundle into an installable APK — so you never hand-write the ~20 Kotlin/Gradle/XML files that takes.
|
|
5
|
+
It can also generate the **native Android host** — the whole Gradle project that wraps the bundle into an installable APK — so you never hand-write the ~20 Kotlin/Gradle/XML files that takes. That path automates [`mithril-lynx`'s ANDROID_APK_GUIDE.md](https://github.com/carlos-sweb/mithril-lynx/blob/main/ANDROID_APK_GUIDE.md).
|
|
6
6
|
|
|
7
7
|
> **Note:** this is a complete rewrite of the previous `create-mithril-lynx`, matching [`mithril-lynx`'s own rewrite](https://github.com/carlos-sweb/mithril-lynx). The old tool's **Basic Activity** template used `mithril-lynx/navigation` (a stack-based, in-memory navigator) — that module doesn't exist in the new `mithril-lynx`, so this template was rewritten from scratch around [`mithril-lynx/route`](https://github.com/carlos-sweb/mithril-lynx/blob/main/ROUTE.md) instead. Everything else (Hello World, Blank, the Android host) ports over unchanged, since none of it depended on the part of the framework that got rewritten.
|
|
8
8
|
|
|
@@ -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>` |
|
|
53
|
-
| `--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`
|
|
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)
|
|
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)
|
|
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
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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([
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
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.
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
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
|
-
//
|
|
409
|
-
//
|
|
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
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
424
|
-
fs.writeFileSync(
|
|
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}\`
|
|
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>
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
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}"
|
|
630
|
-
: "No custom font — pass --with-font <file.ttf> to
|
|
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
|
-
}
|