create-mithril-lynx 2.1.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 +48 -6
- package/package.json +1 -1
- package/src/fonts-wire.js +318 -0
- package/src/index.js +251 -80
- package/templates/_shared/ts/src/background.ts +5 -1
- package/templates/android/app-scripts/android.mjs +21 -0
- package/templates/android/host/app/src/main/java/package-path/App.kt +7 -0
- package/templates/android/host/app/src/main/java/package-path/AssetFontFaceLoader.kt +34 -0
- package/templates/android/host/app/src/main/java/package-path/MainActivity.kt +10 -0
- package/templates/android/host/app/src/main/java/package-path/NoopGenericResourceFetcher.kt +47 -0
package/README.md
CHANGED
|
@@ -49,13 +49,51 @@ 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()
|
|
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
|
|
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
|
+
| `--find-font <term>` | Search [Fontsource](https://fontsource.org)'s catalog for `<term>`, prompt you to pick a family and a weight/style, download that one `.ttf`, and bundle it exactly like `--with-font`. Needs a real terminal (the picking is inherently interactive — use `--with-font <file.ttf>` in scripts/CI). Mutually exclusive with `--with-font`. Comma-separate terms for more than one — quote the whole thing if any term has a space. |
|
|
54
|
+
| `--font-family <name>` | Override the family name derived from the font's file name (or from Fontsource, with `--find-font`). Only valid with exactly one font. |
|
|
55
55
|
|
|
56
56
|
Anything not listed — in particular the four Android flags — requires `--android`
|
|
57
57
|
(or one of its aliases); `--with-font`/`--find-font` imply it on their own.
|
|
58
58
|
|
|
59
|
+
### More than one font
|
|
60
|
+
|
|
61
|
+
Both flags take a comma-separated list — useful for a body font plus a
|
|
62
|
+
monospace one for code, for example:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx create-mithril-lynx my-app --blank --android --find-font "Inter,JetBrains Mono"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Each font 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.:
|
|
75
|
+
|
|
76
|
+
```css
|
|
77
|
+
.code {
|
|
78
|
+
font-family: "JetBrains Mono", monospace;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
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
|
+
|
|
59
97
|
### `--find-font`, in more detail
|
|
60
98
|
|
|
61
99
|
Fontsource's own API (`api.fontsource.org`) has no free-text search — only
|
|
@@ -78,7 +116,7 @@ The sibling `<name>-android/` project is a complete, no-Android-Studio Gradle CL
|
|
|
78
116
|
|
|
79
117
|
- the Gradle wrapper (`gradlew`, `gradlew.bat`, `gradle-wrapper.jar`), so nothing needs to be installed besides a JDK and the Android SDK;
|
|
80
118
|
- `local.properties` with `sdk.dir` auto-detected from `ANDROID_HOME`/`ANDROID_SDK_ROOT` (with a written warning if it can't be found);
|
|
81
|
-
- the Kotlin host: the `Application
|
|
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));
|
|
82
120
|
- resource files that compile as-is (theme, splash theme, adaptive launcher icon);
|
|
83
121
|
- an opt-in release signing setup driven by a gitignored `keystore.properties`.
|
|
84
122
|
|
|
@@ -106,8 +144,9 @@ This part of the tool is framework-agnostic — it just wraps whatever bundle `r
|
|
|
106
144
|
|
|
107
145
|
```
|
|
108
146
|
create-mithril-lynx/
|
|
109
|
-
src/index.js the CLI itself
|
|
147
|
+
src/index.js the CLI itself (+ add-font subcommand)
|
|
110
148
|
src/fontsource.js --find-font: local search over Fontsource's catalog
|
|
149
|
+
src/fonts-wire.js FONTS block builder + stock-template detect/patch
|
|
111
150
|
templates/
|
|
112
151
|
_shared/
|
|
113
152
|
common/ gitignore, project README — shared by every template
|
|
@@ -125,12 +164,15 @@ create-mithril-lynx/
|
|
|
125
164
|
ts/src/{app-bar.ts, background.ts, screens/{home,detail}.ts}
|
|
126
165
|
android/ only used with --android
|
|
127
166
|
host/ the Gradle project -> <name>-android/
|
|
167
|
+
(includes AssetFontFaceLoader +
|
|
168
|
+
NoopGenericResourceFetcher for lynx#9431)
|
|
128
169
|
app-scripts/android.mjs the bridge -> <name>/scripts/android.mjs
|
|
170
|
+
(syncs bundle + src/assets/fonts/ → assets/fonts/)
|
|
129
171
|
```
|
|
130
172
|
|
|
131
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`.
|
|
132
174
|
|
|
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`,
|
|
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`.
|
|
134
176
|
|
|
135
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.
|
|
136
178
|
|
package/package.json
CHANGED
|
@@ -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, "..");
|
|
@@ -135,14 +146,31 @@ function fontFamilyFor(filePath) {
|
|
|
135
146
|
.join(" ");
|
|
136
147
|
}
|
|
137
148
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
.
|
|
144
|
-
|
|
145
|
-
|
|
149
|
+
/** `--with-font`/`--find-font` accept a comma-separated list, for bundling
|
|
150
|
+
* more than one font (e.g. a body font and a monospace one for code). */
|
|
151
|
+
function splitList(value) {
|
|
152
|
+
return value
|
|
153
|
+
.split(",")
|
|
154
|
+
.map((v) => v.trim())
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Renames `file` on any font past the first with the same basename, so
|
|
159
|
+
* e.g. two different "Inter" downloads (different weights) don't collide
|
|
160
|
+
* once copied into src/assets/fonts/. */
|
|
161
|
+
function dedupeFontFiles(fonts) {
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
for (const font of fonts) {
|
|
164
|
+
let file = font.file;
|
|
165
|
+
let n = 2;
|
|
166
|
+
while (seen.has(file)) {
|
|
167
|
+
const ext = path.extname(font.file);
|
|
168
|
+
file = `${path.basename(font.file, ext)}-${n}${ext}`;
|
|
169
|
+
n += 1;
|
|
170
|
+
}
|
|
171
|
+
seen.add(file);
|
|
172
|
+
font.file = file;
|
|
173
|
+
}
|
|
146
174
|
}
|
|
147
175
|
|
|
148
176
|
function xmlEscape(value) {
|
|
@@ -366,45 +394,182 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
|
|
|
366
394
|
}
|
|
367
395
|
appName = appName ?? path.basename(rawName);
|
|
368
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 }) {
|
|
369
410
|
if (android.fontPath != null && android.findFontTerm != null) {
|
|
370
411
|
cancel("--with-font and --find-font are mutually exclusive — pick one.");
|
|
371
412
|
process.exit(1);
|
|
372
413
|
}
|
|
373
414
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
415
|
+
const fontPaths = android.fontPath != null ? splitList(android.fontPath) : [];
|
|
416
|
+
const findFontTerms = android.findFontTerm != null ? splitList(android.findFontTerm) : [];
|
|
417
|
+
const fontCount = fontPaths.length + findFontTerms.length;
|
|
418
|
+
|
|
419
|
+
if (android.fontFamily != null && fontCount > 1) {
|
|
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
|
+
);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
if (android.fontFamily != null && fontCount === 0) {
|
|
426
|
+
cancel("--font-family only makes sense together with --with-font or --find-font.");
|
|
427
|
+
process.exit(1);
|
|
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
|
+
}
|
|
436
|
+
|
|
437
|
+
const fonts = [];
|
|
438
|
+
for (const term of findFontTerms) {
|
|
439
|
+
const found = await findFontInteractively(term);
|
|
377
440
|
if (found == null) return null;
|
|
378
|
-
|
|
441
|
+
fonts.push({
|
|
379
442
|
sourcePath: found.sourcePath,
|
|
380
443
|
file: path.basename(found.sourcePath),
|
|
381
444
|
family: android.fontFamily ?? found.family,
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
for (const rawPath of fontPaths) {
|
|
448
|
+
const resolved = path.resolve(baseDir, rawPath);
|
|
385
449
|
if (!fs.existsSync(resolved)) {
|
|
386
|
-
cancel(`Font file not found: ${
|
|
450
|
+
cancel(`Font file not found: ${rawPath}`);
|
|
387
451
|
process.exit(1);
|
|
388
452
|
}
|
|
389
453
|
if (![".ttf", ".otf", ".ttc"].includes(path.extname(resolved).toLowerCase())) {
|
|
390
|
-
cancel(`"${
|
|
454
|
+
cancel(`"${rawPath}" does not look like a font (.ttf/.otf/.ttc).`);
|
|
391
455
|
process.exit(1);
|
|
392
456
|
}
|
|
393
|
-
|
|
457
|
+
fonts.push({
|
|
394
458
|
sourcePath: resolved,
|
|
395
459
|
file: path.basename(resolved),
|
|
396
460
|
family: android.fontFamily ?? fontFamilyFor(resolved),
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
dedupeFontFiles(fonts);
|
|
464
|
+
return fonts;
|
|
465
|
+
}
|
|
466
|
+
|
|
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
|
+
);
|
|
400
498
|
process.exit(1);
|
|
401
499
|
}
|
|
402
500
|
|
|
403
|
-
|
|
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
|
+
);
|
|
404
569
|
}
|
|
405
570
|
|
|
406
571
|
function scaffoldAndroid({ targetDir, rawName, options }) {
|
|
407
|
-
const { androidId, appName,
|
|
572
|
+
const { androidId, appName, fonts } = options;
|
|
408
573
|
|
|
409
574
|
const androidDirName = `${path.basename(rawName.replace(/^@[^/]+\//, ""))}-android`;
|
|
410
575
|
const androidDir = path.join(path.dirname(targetDir), androidDirName);
|
|
@@ -429,15 +594,11 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
|
|
|
429
594
|
// best-effort
|
|
430
595
|
}
|
|
431
596
|
|
|
432
|
-
// 2. Fonts
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
// data:
|
|
436
|
-
//
|
|
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).
|
|
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.
|
|
441
602
|
const sdkDir = findAndroidSdk();
|
|
442
603
|
|
|
443
604
|
// 3. Text substitutions across the whole Android host (skipping the
|
|
@@ -455,7 +616,17 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
|
|
|
455
616
|
],
|
|
456
617
|
]);
|
|
457
618
|
|
|
458
|
-
// 4.
|
|
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.
|
|
459
630
|
const scriptsDir = path.join(targetDir, "scripts");
|
|
460
631
|
fs.mkdirSync(scriptsDir, { recursive: true });
|
|
461
632
|
fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android.mjs"), path.join(scriptsDir, "android.mjs"));
|
|
@@ -469,7 +640,7 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
|
|
|
469
640
|
["{{APP_NAME}}", appName.replace(/["\\]/g, "")],
|
|
470
641
|
]);
|
|
471
642
|
|
|
472
|
-
return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass,
|
|
643
|
+
return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, fonts, sdkDir };
|
|
473
644
|
}
|
|
474
645
|
|
|
475
646
|
function patchJsProject({ targetDir, android }) {
|
|
@@ -486,46 +657,22 @@ function patchJsProject({ targetDir, android }) {
|
|
|
486
657
|
};
|
|
487
658
|
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
488
659
|
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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).
|
|
504
|
-
if (android.font != null) {
|
|
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);
|
|
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).
|
|
663
|
+
if (android.fonts.length > 0) {
|
|
664
|
+
copyFontFiles({
|
|
665
|
+
projectRoot: targetDir,
|
|
666
|
+
fonts: android.fonts,
|
|
667
|
+
androidDir: android.androidDir,
|
|
668
|
+
});
|
|
511
669
|
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
|
-
"",
|
|
520
|
-
"",
|
|
521
|
-
].join("\n");
|
|
522
670
|
const existingBg = fs.existsSync(bgPath) ? fs.readFileSync(bgPath, "utf8") : "";
|
|
523
|
-
fs.writeFileSync(bgPath, `${
|
|
671
|
+
fs.writeFileSync(bgPath, `${buildFontBlock(android.fonts)}${existingBg}`);
|
|
524
672
|
|
|
525
673
|
const cssPath = path.join(targetDir, "src", "style.css");
|
|
526
|
-
const cssBlock = [`text {`, ` font-family: "${family}", sans-serif;`, `}`, ""].join("\n");
|
|
527
674
|
const existingCss = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, "utf8") : "";
|
|
528
|
-
fs.writeFileSync(cssPath, `${
|
|
675
|
+
fs.writeFileSync(cssPath, `${buildCssFontBlock(android.fonts[0].family)}${existingCss}`);
|
|
529
676
|
}
|
|
530
677
|
|
|
531
678
|
// README: how the two halves are used together.
|
|
@@ -549,9 +696,10 @@ function patchJsProject({ targetDir, android }) {
|
|
|
549
696
|
"",
|
|
550
697
|
`- Application ID: \`${android.androidId}\``,
|
|
551
698
|
`- Application class: \`${android.appClass}\` · Activity: \`MainActivity\``,
|
|
552
|
-
...
|
|
553
|
-
|
|
554
|
-
|
|
699
|
+
...android.fonts.map(
|
|
700
|
+
(f) =>
|
|
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)).`,
|
|
702
|
+
),
|
|
555
703
|
"",
|
|
556
704
|
].join("\n");
|
|
557
705
|
fs.appendFileSync(readmePath, section);
|
|
@@ -566,6 +714,8 @@ create-mithril-lynx — scaffold a mithril-lynx app (and, optionally, its Androi
|
|
|
566
714
|
Usage:
|
|
567
715
|
npm create mithril-lynx@latest [name] [options]
|
|
568
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
|
|
569
719
|
|
|
570
720
|
Template (prompted for if omitted):
|
|
571
721
|
--hello-world | --blank | --basic-activity
|
|
@@ -576,12 +726,27 @@ Android host:
|
|
|
576
726
|
--android-id <id> applicationId / namespace (default com.example.<name>)
|
|
577
727
|
--app-name <name> launcher label (default: the project name)
|
|
578
728
|
--with-font <file.ttf> bundle the font into the JS project and register it
|
|
579
|
-
with lynx.addFont() (
|
|
580
|
-
|
|
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
|
|
581
733
|
--find-font <term> search Fontsource (fontsource.org) for a font,
|
|
582
734
|
pick a family and a weight/style interactively,
|
|
583
|
-
and bundle it the same way as --with-font
|
|
584
|
-
|
|
735
|
+
and bundle it the same way as --with-font.
|
|
736
|
+
Comma-separate terms for more than one — quote
|
|
737
|
+
the whole thing if any term has a space, e.g.
|
|
738
|
+
--find-font "Inter,JetBrains Mono"
|
|
739
|
+
--font-family <name> override the family name derived from the file
|
|
740
|
+
name (only valid with exactly one font)
|
|
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.
|
|
585
750
|
|
|
586
751
|
Other:
|
|
587
752
|
--no-install don't install dependencies
|
|
@@ -594,6 +759,7 @@ async function main() {
|
|
|
594
759
|
// create-mithril-lynx my-app --basic-activity --no-install
|
|
595
760
|
// create-mithril-lynx my-app --blank --android --android-id com.acme.miapp
|
|
596
761
|
// create-mithril-lynx my-app --blank --with-font ./UbuntuMono-Regular.ttf
|
|
762
|
+
// create-mithril-lynx add-font --with-font ./Extra.ttf
|
|
597
763
|
const args = process.argv.slice(2);
|
|
598
764
|
|
|
599
765
|
if (args.includes("--help") || args.includes("-h")) {
|
|
@@ -601,6 +767,11 @@ async function main() {
|
|
|
601
767
|
return;
|
|
602
768
|
}
|
|
603
769
|
|
|
770
|
+
if (args[0] === "add-font") {
|
|
771
|
+
await runAddFont(args.slice(1));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
604
775
|
const positional = findPositional(args);
|
|
605
776
|
const templateFlag = TEMPLATE_VALUES.find((t) => args.includes(`--${t}`));
|
|
606
777
|
const noInstall = args.includes("--no-install");
|
|
@@ -720,7 +891,7 @@ async function main() {
|
|
|
720
891
|
];
|
|
721
892
|
|
|
722
893
|
if (androidResult != null) {
|
|
723
|
-
const { androidDirName, sdkDir,
|
|
894
|
+
const { androidDirName, sdkDir, fonts } = androidResult;
|
|
724
895
|
const notes = [
|
|
725
896
|
`Android host generated in ${androidDirName}/ (Application ID ${androidOptions.androidId}).`,
|
|
726
897
|
"",
|
|
@@ -732,9 +903,9 @@ async function main() {
|
|
|
732
903
|
? "⚠ Android SDK not found: export ANDROID_HOME and edit " +
|
|
733
904
|
`${androidDirName}/local.properties (sdk.dir=...).`
|
|
734
905
|
: `Android SDK found at ${sdkDir}.`,
|
|
735
|
-
|
|
736
|
-
? `Font "${
|
|
737
|
-
: "No custom font — pass --with-font <file.ttf> to bundle one.",
|
|
906
|
+
fonts.length > 0
|
|
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.`
|
|
908
|
+
: "No custom font — pass --with-font <file.ttf> (or --find-font <term>) to bundle one.",
|
|
738
909
|
];
|
|
739
910
|
outro(`Done! Next steps:\n\n ${steps.join("\n ")}\n\n${notes.join("\n")}`);
|
|
740
911
|
return;
|
|
@@ -25,7 +25,11 @@ declare const module: {
|
|
|
25
25
|
accept(path: string, callback: () => void): void;
|
|
26
26
|
};
|
|
27
27
|
};
|
|
28
|
-
|
|
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
|
+
}
|