create-mithril-lynx 0.0.2 → 0.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.
Files changed (27) hide show
  1. package/README.md +63 -0
  2. package/package.json +2 -2
  3. package/src/index.js +509 -4
  4. package/templates/android/app-scripts/android.mjs +232 -0
  5. package/templates/android/font/AssetFontFaceLoader.kt +39 -0
  6. package/templates/android/host/app/build.gradle.kts +103 -0
  7. package/templates/android/host/app/proguard-rules.pro +51 -0
  8. package/templates/android/host/app/src/main/AndroidManifest.xml +28 -0
  9. package/templates/android/host/app/src/main/java/package-path/App.kt +23 -0
  10. package/templates/android/host/app/src/main/java/package-path/AssetTemplateProvider.kt +27 -0
  11. package/templates/android/host/app/src/main/java/package-path/MainActivity.kt +37 -0
  12. package/templates/android/host/app/src/main/res/drawable/ic_launcher_background.xml +6 -0
  13. package/templates/android/host/app/src/main/res/drawable/ic_launcher_foreground.xml +11 -0
  14. package/templates/android/host/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  15. package/templates/android/host/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
  16. package/templates/android/host/app/src/main/res/values/strings.xml +3 -0
  17. package/templates/android/host/app/src/main/res/values/themes.xml +17 -0
  18. package/templates/android/host/build.gradle.kts +4 -0
  19. package/templates/android/host/gitignore +16 -0
  20. package/templates/android/host/gradle/wrapper/gradle-wrapper.jar +0 -0
  21. package/templates/android/host/gradle/wrapper/gradle-wrapper.properties +7 -0
  22. package/templates/android/host/gradle.properties +3 -0
  23. package/templates/android/host/gradlew +251 -0
  24. package/templates/android/host/gradlew.bat +94 -0
  25. package/templates/android/host/keystore.properties.example +15 -0
  26. package/templates/android/host/local.properties +4 -0
  27. package/templates/android/host/settings.gradle.kts +18 -0
package/README.md CHANGED
@@ -2,6 +2,8 @@
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, each available in TypeScript or JavaScript.
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. That path automates [`mithril-lynx`'s ANDROID_APK_GUIDE.md](https://github.com/carlos-sweb/mithril-lynx/blob/main/ANDROID_APK_GUIDE.md).
6
+
5
7
  ## Usage
6
8
 
7
9
  ```bash
@@ -17,6 +19,61 @@ npx create-mithril-lynx my-app --hello-world --ts
17
19
  npx create-mithril-lynx my-app --basic-activity --js --no-install
18
20
  ```
19
21
 
22
+ ### With the Android host
23
+
24
+ ```bash
25
+ # Scaffold the JS app plus a sibling my-app-android/ Gradle project:
26
+ npx create-mithril-lynx my-app --ts --blank --android
27
+
28
+ cd my-app
29
+ npm install
30
+ npm run android # bundle -> assets -> installDebug -> launch on the device
31
+ ```
32
+
33
+ The interactive prompt offers it too, and the literal `target=android` form works as well:
34
+
35
+ ```bash
36
+ npm create mithril-lynx@latest my-app target=android
37
+ ```
38
+
39
+ Every flag, in one table — `npx create-mithril-lynx --help` prints the same list:
40
+
41
+ | Flag | Effect |
42
+ |---|---|
43
+ | `<name>` (positional) | Project name, and the directory to create. Omit it and you're prompted. |
44
+ | `--hello-world` / `--blank` / `--basic-activity` | Template to scaffold. Omit it and you're prompted. |
45
+ | `--ts` / `--js` | TypeScript or JavaScript variant. Omit it and you're prompted. |
46
+ | `--no-install` | Don't install dependencies (also skips that prompt). |
47
+ | `-h`, `--help` | Print usage and exit. |
48
+ | `--android` / `--target android` / `--target=android` / `target=android` | Also scaffold the sibling `<name>-android/` Gradle project. |
49
+ | `--android-id <id>` | `applicationId` / `namespace` (default `com.example.<name>`). |
50
+ | `--app-name <name>` | Launcher label (default: the project name). |
51
+ | `--with-font <file.ttf>` | Copy the font into the host's assets, generate `AssetFontFaceLoader.kt`, and wire the cold-start `prefetchFont()` hack (Part D of the guide). |
52
+ | `--font-family <name>` | Override the family name derived from the font's file name. Only meaningful with `--with-font`. |
53
+
54
+ Anything not listed — in particular the four Android flags — requires `--android`
55
+ (or one of its aliases); `--with-font` implies it on its own.
56
+
57
+ ### What the generated Android host gives you
58
+
59
+ The sibling `<name>-android/` project is a complete, no-Android-Studio Gradle CLI project pinned to the versions in the guide (AGP 8.5.2, Kotlin 1.9.24, Gradle 8.14.2, Lynx `4.1.0`, `compileSdk`/`targetSdk` 34, `minSdk` 24), including:
60
+
61
+ - the Gradle wrapper (`gradlew`, `gradlew.bat`, `gradle-wrapper.jar`), so nothing needs to be installed besides a JDK 17 and the Android SDK;
62
+ - `local.properties` with `sdk.dir` auto-detected from `ANDROID_HOME`/`ANDROID_SDK_ROOT` (with a written warning if it can't be found);
63
+ - the Kotlin host: the `Application` (log service registered), `MainActivity` (async `AssetTemplateProvider`, splash screen, `XElementBehaviors`), and the font loader when `--with-font` is used;
64
+ - resource files that compile as-is (theme, splash theme, adaptive launcher icon);
65
+ - an opt-in release signing setup driven by a gitignored `keystore.properties`.
66
+
67
+ And in the JS project, a `scripts/android.mjs` bridge plus npm scripts:
68
+
69
+ ```bash
70
+ npm run android # build + sync + installDebug + launch, printing TotalTime
71
+ npm run android:apk # build + sync + assembleDebug
72
+ npm run android:release # build + sync + assembleRelease (signed if keystore.properties exists)
73
+ npm run android:sync # build + sync bundle into app/src/main/assets/, no Gradle
74
+ npm run android:keystore # generate release.keystore + keystore.properties (KEYSTORE_PASSWORD=...)
75
+ ```
76
+
20
77
  ## Templates
21
78
 
22
79
  | Template | What it is |
@@ -54,10 +111,16 @@ create-mithril-lynx/
54
111
  common/src/style.css
55
112
  js/src/index.js
56
113
  ts/src/index.ts
114
+ android/ only used with --android
115
+ host/ the Gradle project -> <name>-android/
116
+ font/AssetFontFaceLoader.kt copied only when --with-font is used
117
+ app-scripts/android.mjs the bridge -> <name>/scripts/android.mjs
57
118
  ```
58
119
 
59
120
  `src/index.js` copies, in order, `templates/_shared/common` → `templates/_shared/<variant>` → `templates/<template>/common` → `templates/<template>/<variant>` into the target directory (later copies never need to overwrite earlier ones — each layer contributes different files), 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`.
60
121
 
122
+ With `--android` it then copies `templates/android/host` into a sibling `<name>-android/` (renaming `package-path` to the real package directory and `App.kt` to the Application class), substitutes `{{PACKAGE_NAME}}`, `{{APP_CLASS}}`, `{{APP_NAME}}`, `{{ANDROID_DIR}}` and `{{SDK_DIR}}` across the tree, and fills in the four font-hack hooks (`{{FONT_LOADER_IMPORT}}`, `{{FONT_LOADER_REGISTRATION}}`, `{{FONT_IMPORT}}`, `{{FONT_PREFETCH}}`) with either the real code or nothing at all. Binary files (the Gradle wrapper jar, the `.ttf`) are never text-substituted.
123
+
61
124
  Every template's `src/index.{js,ts}` exports the same shape (`{ root }`), so `_shared/{js,ts}/src/main-thread.{js,ts}` — the actual `__RenderPage` wiring — is 100% shared and never duplicated per template.
62
125
 
63
126
  ## License
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-mithril-lynx",
3
- "version": "0.0.2",
4
- "description": "Scaffolds a new mithril-lynx app — Hello World, Blank, or Basic Activity (with navigation), in JavaScript or TypeScript.",
3
+ "version": "0.1.0",
4
+ "description": "Scaffolds a new mithril-lynx app — Hello World, Blank, or Basic Activity (with navigation), in JavaScript or TypeScript — and optionally the native Android host that packages it into an APK.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "repository": {
package/src/index.js CHANGED
@@ -10,9 +10,14 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
10
  const packageRoot = path.join(scriptDir, "..");
11
11
  const cwd = process.cwd();
12
12
 
13
- // 0.0.2+ — "basic-activity" needs mithril-lynx/navigation, added after 0.0.1
14
- // was published.
15
- const MITHRIL_LYNX_VERSION = "0.0.2";
13
+ // The mithril-lynx version pinned in the generated package.json.
14
+ //
15
+ // This used to be "0.0.2" (what was needed when the "basic-activity" template
16
+ // was added, since that one needs mithril-lynx/navigation). It now tracks the
17
+ // current release: a fresh scaffold shouldn't start five releases behind.
18
+ // Verified by generating with --ts --blank and --js --basic-activity,
19
+ // installing, and building the APK (debug and release).
20
+ const MITHRIL_LYNX_VERSION = "0.0.7";
16
21
 
17
22
  const TEMPLATES = [
18
23
  { value: "hello-world", label: "Hello World", hint: "recommended" },
@@ -21,6 +26,11 @@ const TEMPLATES = [
21
26
  ];
22
27
  const TEMPLATE_VALUES = TEMPLATES.map((t) => t.value);
23
28
 
29
+ const ANDROID_TEMPLATE_ROOT = path.join(packageRoot, "templates", "android");
30
+
31
+ // Files that text substitution must never touch (binaries).
32
+ const BINARY_EXTENSIONS = new Set([".jar", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".ttf", ".otf", ".ttc", ".keystore", ".jks", ".so"]);
33
+
24
34
  function isValidPackageName(name) {
25
35
  return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name);
26
36
  }
@@ -35,6 +45,27 @@ function copyDir(from, to) {
35
45
  }
36
46
  }
37
47
 
48
+ /** copyDir, but able to rename entries (files or directories) on the way. */
49
+ function copyTree(from, to, rename = (name) => name) {
50
+ fs.mkdirSync(to, { recursive: true });
51
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
52
+ const src = path.join(from, entry.name);
53
+ const dest = path.join(to, rename(entry.name));
54
+ if (entry.isDirectory()) copyTree(src, dest, rename);
55
+ else fs.copyFileSync(src, dest);
56
+ }
57
+ }
58
+
59
+ function walkFiles(dir) {
60
+ const out = [];
61
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
62
+ const full = path.join(dir, entry.name);
63
+ if (entry.isDirectory()) out.push(...walkFiles(full));
64
+ else out.push(full);
65
+ }
66
+ return out;
67
+ }
68
+
38
69
  function replaceInFile(filePath, replacements) {
39
70
  if (!fs.existsSync(filePath)) return;
40
71
  let content = fs.readFileSync(filePath, "utf8");
@@ -42,21 +73,452 @@ function replaceInFile(filePath, replacements) {
42
73
  fs.writeFileSync(filePath, content);
43
74
  }
44
75
 
76
+ /** Applies substitutions across a whole tree, skipping binaries. */
77
+ function replaceInTree(dir, replacements, { onlyExtensions } = {}) {
78
+ for (const file of walkFiles(dir)) {
79
+ const ext = path.extname(file).toLowerCase();
80
+ if (BINARY_EXTENSIONS.has(ext)) continue;
81
+ if (onlyExtensions && !onlyExtensions.has(path.basename(file)) && !onlyExtensions.has(ext)) continue;
82
+ replaceInFile(file, replacements);
83
+ }
84
+ }
85
+
45
86
  function targetDirHasConflict(value) {
46
87
  const targetDir = path.join(cwd, value);
47
88
  return fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0;
48
89
  }
49
90
 
91
+ // ---------------------------------------------------------------------------
92
+ // Android host derivations
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function pascalCase(value) {
96
+ const joined = value
97
+ .split(/[^a-zA-Z0-9]+/)
98
+ .filter(Boolean)
99
+ .map((part) => part[0].toUpperCase() + part.slice(1))
100
+ .join("")
101
+ .replace(/[^a-zA-Z0-9]/g, "");
102
+ if (joined === "") return "MithrilApp";
103
+ return /^[0-9]/.test(joined) ? `App${joined}` : joined;
104
+ }
105
+
106
+ function deriveAndroidId(projectName) {
107
+ const base = projectName.replace(/^@[^/]+\//, "").replace(/[^a-zA-Z0-9]/g, "").toLowerCase() || "app";
108
+ const segment = /^[0-9]/.test(base) ? `app${base}` : base;
109
+ return `com.example.${segment}`;
110
+ }
111
+
112
+ function isValidAndroidId(id) {
113
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(id)) return false;
114
+ const javaKeywords = new Set([
115
+ "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const",
116
+ "continue", "default", "do", "double", "else", "enum", "extends", "final", "finally", "float",
117
+ "for", "goto", "if", "implements", "import", "instanceof", "int", "interface", "long", "native",
118
+ "new", "package", "private", "protected", "public", "return", "short", "static", "strictfp",
119
+ "super", "switch", "synchronized", "this", "throw", "throws", "transient", "try", "void",
120
+ "volatile", "while",
121
+ ]);
122
+ return id.split(".").every((segment) => !javaKeywords.has(segment));
123
+ }
124
+
125
+ function appClassNameFor(rawName) {
126
+ const base = path.basename(rawName.replace(/^@[^/]+\//, ""));
127
+ const name = pascalCase(base);
128
+ // Must not collide with the template's fixed file names.
129
+ return name === "MainActivity" ? `${name}App` : name;
130
+ }
131
+
132
+ function fontFamilyFor(filePath) {
133
+ return path
134
+ .basename(filePath, path.extname(filePath))
135
+ .split(/[_\-.]+/)
136
+ .filter(Boolean)
137
+ .map((word) => word[0].toUpperCase() + word.slice(1))
138
+ .join(" ");
139
+ }
140
+
141
+ function xmlEscape(value) {
142
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
143
+ }
144
+
145
+ function findAndroidSdk() {
146
+ for (const candidate of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
147
+ if (candidate && fs.existsSync(candidate)) return candidate;
148
+ }
149
+ for (const candidate of [
150
+ path.join(process.env.HOME ?? "", "android-sdk"),
151
+ path.join(process.env.HOME ?? "", "Android/Sdk"),
152
+ "/usr/lib/android-sdk",
153
+ ]) {
154
+ if (candidate && fs.existsSync(path.join(candidate, "platform-tools"))) return candidate;
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Parseo de argumentos
161
+ // ---------------------------------------------------------------------------
162
+
163
+ function readOption(args, name) {
164
+ const withEquals = args.find((a) => a.startsWith(`--${name}=`));
165
+ if (withEquals) return withEquals.slice(name.length + 3);
166
+ const index = args.indexOf(`--${name}`);
167
+ if (index !== -1 && args[index + 1] != null && !args[index + 1].startsWith("-")) return args[index + 1];
168
+ return undefined;
169
+ }
170
+
171
+ function parseAndroidArgs(args) {
172
+ // Accepts `--android`, `--target android`, `--target=android` and the
173
+ // literal `target=android`, which is how the README documents it.
174
+ const target = readOption(args, "target");
175
+ const requested = args.includes("--android") || target === "android" || args.includes("target=android");
176
+ return {
177
+ requested,
178
+ id: readOption(args, "android-id"),
179
+ appName: readOption(args, "app-name"),
180
+ fontPath: readOption(args, "with-font"),
181
+ fontFamily: readOption(args, "font-family"),
182
+ };
183
+ }
184
+
185
+ // Options that consume the following argument, so that value is never mistaken
186
+ // for the project name (e.g. `--with-font ./UbuntuMono.ttf`).
187
+ const OPTIONS_WITH_VALUE = new Set(["--target", "--android-id", "--app-name", "--with-font", "--font-family"]);
188
+
189
+ function findPositional(args) {
190
+ for (let i = 0; i < args.length; i++) {
191
+ const arg = args[i];
192
+ if (arg.startsWith("-")) {
193
+ if (OPTIONS_WITH_VALUE.has(arg)) i += 1;
194
+ continue;
195
+ }
196
+ if (arg === "target=android") continue;
197
+ if (/^(?:target|android-id|app-name|with-font|font-family)=/.test(arg)) continue;
198
+ return arg;
199
+ }
200
+ return undefined;
201
+ }
202
+
203
+ /** Indents every non-empty line of a generated block. */
204
+ function indent(block, spaces) {
205
+ const pad = " ".repeat(spaces);
206
+ return block
207
+ .split("\n")
208
+ .map((line) => (line === "" ? line : pad + line))
209
+ .join("\n");
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Android host scaffolding
214
+ // ---------------------------------------------------------------------------
215
+
216
+ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt }) {
217
+ let androidId = android.id;
218
+ if (androidId == null && canPrompt) {
219
+ androidId = await text({
220
+ message: "Android application ID (applicationId / namespace)",
221
+ placeholder: deriveAndroidId(projectName),
222
+ defaultValue: deriveAndroidId(projectName),
223
+ validate(value) {
224
+ // clack validates the raw input and only applies defaultValue
225
+ // afterwards (in its "finalize" handler), so an empty Enter has
226
+ // to count as valid or the default is never used.
227
+ if (value == null || value === "") return;
228
+ if (!isValidAndroidId(value)) return "Must be a valid Java package, e.g. com.example.myapp";
229
+ },
230
+ });
231
+ if (isCancel(androidId)) return null;
232
+ }
233
+ androidId = androidId ?? deriveAndroidId(projectName);
234
+ if (!isValidAndroidId(androidId)) {
235
+ cancel(`"${androidId}" is not a valid Android application ID (e.g. com.example.myapp).`);
236
+ process.exit(1);
237
+ }
238
+
239
+ let appName = android.appName;
240
+ if (appName == null && canPrompt) {
241
+ appName = await text({
242
+ message: "App display name (the one shown in the launcher)",
243
+ placeholder: path.basename(rawName),
244
+ defaultValue: path.basename(rawName),
245
+ validate(value) {
246
+ // See the note on the applicationId prompt: empty means "use the default".
247
+ if (value == null || value === "") return;
248
+ if (value.trim() === "") return "Enter a name.";
249
+ },
250
+ });
251
+ if (isCancel(appName)) return null;
252
+ }
253
+ appName = appName ?? path.basename(rawName);
254
+
255
+ let font = null;
256
+ if (android.fontPath != null) {
257
+ const resolved = path.resolve(cwd, android.fontPath);
258
+ if (!fs.existsSync(resolved)) {
259
+ cancel(`Font file not found: ${android.fontPath}`);
260
+ process.exit(1);
261
+ }
262
+ if (![".ttf", ".otf", ".ttc"].includes(path.extname(resolved).toLowerCase())) {
263
+ cancel(`"${android.fontPath}" does not look like a font (.ttf/.otf/.ttc).`);
264
+ process.exit(1);
265
+ }
266
+ font = {
267
+ sourcePath: resolved,
268
+ file: path.basename(resolved),
269
+ family: android.fontFamily ?? fontFamilyFor(resolved),
270
+ };
271
+ } else if (android.fontFamily != null) {
272
+ cancel("--font-family only makes sense together with --with-font.");
273
+ process.exit(1);
274
+ }
275
+
276
+ return { androidId, appName, font };
277
+ }
278
+
279
+ function scaffoldAndroid({ targetDir, rawName, options }) {
280
+ const { androidId, appName, font } = options;
281
+
282
+ const androidDirName = `${path.basename(rawName.replace(/^@[^/]+\//, ""))}-android`;
283
+ const androidDir = path.join(path.dirname(targetDir), androidDirName);
284
+ const appClass = appClassNameFor(rawName);
285
+ const packagePath = androidId.split(".").join(path.sep);
286
+ const packageDir = path.join(androidDir, "app", "src", "main", "java", packagePath);
287
+
288
+ // 1. Copy the Gradle skeleton and the Kotlin app. `package-path` expands to
289
+ // the real package path, and App.kt is renamed after its class.
290
+ copyTree(path.join(ANDROID_TEMPLATE_ROOT, "host"), androidDir, (name) => {
291
+ if (name === "package-path") return packagePath;
292
+ if (name === "App.kt") return `${appClass}.kt`;
293
+ if (name === "gitignore") return ".gitignore";
294
+ return name;
295
+ });
296
+
297
+ fs.mkdirSync(path.join(androidDir, "app", "src", "main", "assets"), { recursive: true });
298
+ // On Windows chmod only handles the write bit; if it fails, the wrapper is
299
+ // still invoked through gradlew.bat.
300
+ try {
301
+ fs.chmodSync(path.join(androidDir, "gradlew"), 0o755);
302
+ } catch {
303
+ // best-effort
304
+ }
305
+
306
+ // 2. The .ttf (Part D of the guide) is opt-in: without it neither the
307
+ // loader nor the prefetch is generated, so no dead code is left behind.
308
+ if (font != null) {
309
+ const fontsDir = path.join(androidDir, "app", "src", "main", "assets", "fonts");
310
+ fs.mkdirSync(fontsDir, { recursive: true });
311
+ fs.copyFileSync(font.sourcePath, path.join(fontsDir, font.file));
312
+
313
+ fs.copyFileSync(
314
+ path.join(ANDROID_TEMPLATE_ROOT, "font", "AssetFontFaceLoader.kt"),
315
+ path.join(packageDir, "AssetFontFaceLoader.kt"),
316
+ );
317
+ }
318
+
319
+ const sdkDir = findAndroidSdk();
320
+
321
+ // 3. Text substitutions across the whole Android host (skipping the
322
+ // gradle-wrapper.jar, the .ttf and any other binary).
323
+ replaceInTree(androidDir, [
324
+ ["{{PACKAGE_NAME}}", androidId],
325
+ ["{{APP_CLASS}}", appClass],
326
+ ["{{APP_NAME}}", xmlEscape(appName)],
327
+ ["{{ANDROID_DIR}}", androidDirName],
328
+ [
329
+ "{{SDK_DIR}}",
330
+ sdkDir != null
331
+ ? `sdk.dir=${sdkDir}`
332
+ : "# Android SDK not found: export ANDROID_HOME (or ANDROID_SDK_ROOT) and\n# replace the line below, or set sdk.dir by hand.\n# sdk.dir=/path/to/your/android-sdk",
333
+ ],
334
+ ]);
335
+
336
+ // 4. The four font-hack hooks: either the real code, or nothing at all.
337
+ const sourceUri = font != null ? `asset:///fonts/${font.file}` : undefined;
338
+ const fontLoaderImport = font != null ? "import com.lynx.tasm.loader.LynxFontFaceLoader\n" : "";
339
+ const fontLoaderRegistration =
340
+ font != null
341
+ ? `${indent(
342
+ [
343
+ "// Must run BEFORE LynxEnv.inst().init(): this is what makes",
344
+ '// "asset:///" resolvable, both for prefetchFont() and for the',
345
+ "// real @font-face resolution during the first layout.",
346
+ "LynxFontFaceLoader.setLoader(AssetFontFaceLoader)",
347
+ ].join("\n"),
348
+ 8,
349
+ )}\n\n`
350
+ : "";
351
+ const fontImport = font != null ? "import com.lynx.tasm.fontface.FontFaceManager\n" : "";
352
+ const fontPrefetch =
353
+ font != null
354
+ ? `${indent(
355
+ [
356
+ "// Warms the Typeface on Lynx's own IO thread pool, before",
357
+ "// renderTemplateUrl() gives the bundle's CSS a chance to resolve",
358
+ "// @font-face during the first layout. FontFaceManager caches by the",
359
+ "// exact src string, so this URI has to be identical to the",
360
+ '// url("...") of the @font-face in src/style.css — see Part D of',
361
+ "// ANDROID_APK_GUIDE.md.",
362
+ "FontFaceManager.getInstance().prefetchFont(",
363
+ " lynxView.lynxContext,",
364
+ ` "${sourceUri}",`,
365
+ " null,",
366
+ " object : FontFaceManager.FontFacePrefetchListener {",
367
+ " override fun onComplete(code: Int, msg: String) {}",
368
+ " },",
369
+ ")",
370
+ ].join("\n"),
371
+ 8,
372
+ )}\n\n`
373
+ : "";
374
+
375
+ for (const kt of walkFiles(packageDir)) {
376
+ replaceInFile(kt, [
377
+ ["// {{FONT_LOADER_IMPORT}}\n", fontLoaderImport],
378
+ [" // {{FONT_LOADER_REGISTRATION}}\n", fontLoaderRegistration],
379
+ ["// {{FONT_IMPORT}}\n", fontImport],
380
+ [" // {{FONT_PREFETCH}}\n", fontPrefetch],
381
+ ]);
382
+ }
383
+
384
+ // 5. The script that joins the two halves, inside the JS project.
385
+ const scriptsDir = path.join(targetDir, "scripts");
386
+ fs.mkdirSync(scriptsDir, { recursive: true });
387
+ fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android.mjs"), path.join(scriptsDir, "android.mjs"));
388
+
389
+ const androidRelDir = path.relative(targetDir, androidDir).split(path.sep).join("/");
390
+ replaceInFile(path.join(scriptsDir, "android.mjs"), [
391
+ ["{{ANDROID_REL_DIR}}", androidRelDir],
392
+ ["{{ANDROID_DIR}}", androidDirName],
393
+ ["{{PACKAGE_NAME}}", androidId],
394
+ // A name with quotes would break keytool's -dname=CN=...
395
+ ["{{APP_NAME}}", appName.replace(/["\\]/g, "")],
396
+ ]);
397
+
398
+ return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, font, sdkDir };
399
+ }
400
+
401
+ function patchJsProject({ targetDir, android }) {
402
+ // package.json: the Android scripts.
403
+ const pkgPath = path.join(targetDir, "package.json");
404
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
405
+ pkg.scripts = {
406
+ ...pkg.scripts,
407
+ android: "node scripts/android.mjs",
408
+ "android:apk": "node scripts/android.mjs --apk",
409
+ "android:release": "node scripts/android.mjs --release",
410
+ "android:sync": "node scripts/android.mjs --sync-only",
411
+ "android:keystore": "node scripts/android.mjs --keystore",
412
+ };
413
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
414
+
415
+ // style.css: the @font-face that has to match the Android host's
416
+ // prefetchFont() byte for byte.
417
+ if (android.font != null) {
418
+ const cssPath = path.join(targetDir, "src", "style.css");
419
+ const block = [
420
+ "/* Part D of ANDROID_APK_GUIDE.md: the font lives in the Android host's",
421
+ " assets (app/src/main/assets/fonts/), not in the bundle. The asset:///",
422
+ " string has to be identical to the one in MainActivity.kt's prefetchFont()",
423
+ " or Lynx won't find the warmed Typeface. */",
424
+ "@font-face {",
425
+ ` font-family: "${android.font.family}";`,
426
+ ` src: url("asset:///fonts/${android.font.file}");`,
427
+ "}",
428
+ "",
429
+ ].join("\n");
430
+ const existing = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, "utf8") : "";
431
+ fs.writeFileSync(cssPath, `${block}\n${existing}`);
432
+ }
433
+
434
+ // README: how the two halves are used together.
435
+ const readmePath = path.join(targetDir, "README.md");
436
+ if (fs.existsSync(readmePath)) {
437
+ const section = [
438
+ "",
439
+ "## Android (native APK)",
440
+ "",
441
+ `The Android host lives in \`../${android.androidDirName}/\` and packages the bundle this project produces.`,
442
+ "",
443
+ "```bash",
444
+ "npm run android # build the bundle, copy it to assets, install and launch on the device",
445
+ "npm run android:apk # build the debug APK only",
446
+ "npm run android:sync # bundle -> assets only, no Gradle",
447
+ "",
448
+ "# Signed release APK (generate the keystore once):",
449
+ "KEYSTORE_PASSWORD='...' npm run android:keystore",
450
+ "npm run android:release",
451
+ "```",
452
+ "",
453
+ `- Application ID: \`${android.androidId}\``,
454
+ `- Application class: \`${android.appClass}\` · Activity: \`MainActivity\``,
455
+ ...(android.font != null
456
+ ? [`- Font \`${android.font.family}\` prefetched from \`asset:///fonts/${android.font.file}\` (the cold-start hack, Part D of the guide).`]
457
+ : []),
458
+ "",
459
+ "The full procedure — including why each Gradle dependency is there and how to measure",
460
+ "cold start — is in [`mithril-lynx`'s ANDROID_APK_GUIDE.md](https://github.com/carlos-sweb/mithril-lynx/blob/main/ANDROID_APK_GUIDE.md).",
461
+ "",
462
+ ].join("\n");
463
+ fs.appendFileSync(readmePath, section);
464
+ }
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+
469
+ const USAGE = `
470
+ create-mithril-lynx — scaffold a mithril-lynx app (and, optionally, its Android host)
471
+
472
+ Usage:
473
+ npm create mithril-lynx@latest [name] [options]
474
+ npx create-mithril-lynx <name> --ts --blank --android
475
+
476
+ Template (prompted for if omitted):
477
+ --hello-world | --blank | --basic-activity
478
+
479
+ Variant:
480
+ --ts | --js
481
+
482
+ Android host (see mithril-lynx's ANDROID_APK_GUIDE.md):
483
+ --android, --target android, target=android
484
+ scaffold the sibling Gradle project <name>-android/
485
+ --android-id <id> applicationId / namespace (default com.example.<name>)
486
+ --app-name <name> launcher label (default: the project name)
487
+ --with-font <file.ttf> copy the font into the host's assets, generate
488
+ AssetFontFaceLoader.kt, and wire up the prefetchFont()
489
+ call that avoids the slow cold start
490
+ --font-family <name> override the family name derived from the file name
491
+
492
+ Other:
493
+ --no-install don't install dependencies
494
+ -h, --help print this
495
+ `.trim();
496
+
50
497
  async function main() {
51
498
  // Non-interactive escape hatch for scripting/CI:
52
499
  // create-mithril-lynx my-app --ts --hello-world
53
500
  // create-mithril-lynx my-app --js --basic-activity --no-install
501
+ // create-mithril-lynx my-app --ts --blank --android --android-id com.acme.miapp
502
+ // create-mithril-lynx my-app --ts --blank --with-font ./UbuntuMono-Regular.ttf
54
503
  const args = process.argv.slice(2);
55
- const positional = args.find((a) => !a.startsWith("-"));
504
+
505
+ if (args.includes("--help") || args.includes("-h")) {
506
+ console.log(USAGE);
507
+ return;
508
+ }
509
+
510
+ const positional = findPositional(args);
56
511
  const variantFlag = args.includes("--ts") ? "ts" : args.includes("--js") ? "js" : undefined;
57
512
  const templateFlag = TEMPLATE_VALUES.find((t) => args.includes(`--${t}`));
58
513
  const noInstall = args.includes("--no-install");
59
514
  const nonInteractive = positional != null && variantFlag != null && templateFlag != null;
515
+ const android = parseAndroidArgs(args);
516
+
517
+ // --with-font/--font-family only make sense with an Android host: imply it
518
+ // rather than ignoring them silently.
519
+ if (android.fontPath != null || android.fontFamily != null) {
520
+ android.requested = true;
521
+ }
60
522
 
61
523
  intro("create-mithril-lynx");
62
524
 
@@ -104,6 +566,21 @@ async function main() {
104
566
  if (isCancel(variant)) return bail();
105
567
  }
106
568
 
569
+ let withAndroid = android.requested;
570
+ if (!withAndroid && !nonInteractive) {
571
+ withAndroid = await confirm({
572
+ message: "Also generate the Android host (Gradle APK)?",
573
+ initialValue: false,
574
+ });
575
+ if (isCancel(withAndroid)) return bail();
576
+ }
577
+
578
+ let androidOptions = null;
579
+ if (withAndroid) {
580
+ androidOptions = await resolveAndroidOptions(android, { rawName, projectName, canPrompt: !nonInteractive });
581
+ if (androidOptions == null) return bail();
582
+ }
583
+
107
584
  const targetDir = path.join(cwd, rawName);
108
585
  fs.mkdirSync(targetDir, { recursive: true });
109
586
 
@@ -126,6 +603,12 @@ async function main() {
126
603
  ]);
127
604
  replaceInFile(path.join(targetDir, "README.md"), [["{{PROJECT_NAME}}", projectName]]);
128
605
 
606
+ let androidResult = null;
607
+ if (withAndroid) {
608
+ androidResult = scaffoldAndroid({ targetDir, rawName, options: androidOptions });
609
+ patchJsProject({ targetDir, android: androidResult });
610
+ }
611
+
129
612
  let shouldInstall = !noInstall;
130
613
  if (!nonInteractive && !noInstall) {
131
614
  shouldInstall = await confirm({
@@ -151,6 +634,28 @@ async function main() {
151
634
  ...(shouldInstall ? [] : ["npm install"]),
152
635
  "npm run dev",
153
636
  ];
637
+
638
+ if (androidResult != null) {
639
+ const { androidDirName, sdkDir, font } = androidResult;
640
+ const notes = [
641
+ `Android host generated in ${androidDirName}/ (Application ID ${androidOptions.androidId}).`,
642
+ "",
643
+ "To build/install the APK on the connected device:",
644
+ "",
645
+ ` cd ${relativeDir} && npm run android`,
646
+ "",
647
+ sdkDir == null
648
+ ? "⚠ Android SDK not found: export ANDROID_HOME and edit " +
649
+ `${androidDirName}/local.properties (sdk.dir=...).`
650
+ : `Android SDK found at ${sdkDir}.`,
651
+ font != null
652
+ ? `Font "${font.family}" prefetched from asset:///fonts/${font.file} (the cold-start hack).`
653
+ : "No custom font — pass --with-font <file.ttf> to include the prefetch hack.",
654
+ ];
655
+ outro(`Done! Next steps:\n\n ${steps.join("\n ")}\n\n${notes.join("\n")}`);
656
+ return;
657
+ }
658
+
154
659
  outro(`Done! Next steps:\n\n ${steps.join("\n ")}\n\nThen scan the printed QR code with LynxExplorer.`);
155
660
  }
156
661