create-mithril-lynx 0.0.1 → 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 (51) hide show
  1. package/README.md +97 -12
  2. package/package.json +3 -5
  3. package/src/index.js +536 -7
  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
  28. package/templates/basic-activity/common/src/style.css +59 -0
  29. package/templates/basic-activity/js/src/index.js +43 -0
  30. package/templates/basic-activity/ts/src/index.ts +48 -0
  31. package/templates/blank/common/src/style.css +16 -0
  32. package/templates/blank/js/src/index.js +11 -0
  33. package/templates/blank/ts/src/index.ts +11 -0
  34. /package/{template-js → templates/hello-world/js}/src/index.js +0 -0
  35. /package/{template-common → templates/_shared/common}/README.md +0 -0
  36. /package/{template-common → templates/_shared/common}/gitignore +0 -0
  37. /package/{template-js → templates/_shared/js}/lynx.config.js +0 -0
  38. /package/{template-js → templates/_shared/js}/package.json +0 -0
  39. /package/{template-js → templates/_shared/js}/src/main-thread.js +0 -0
  40. /package/{template-ts → templates/_shared/ts}/lynx.config.ts +0 -0
  41. /package/{template-ts → templates/_shared/ts}/package.json +0 -0
  42. /package/{template-ts → templates/_shared/ts}/src/main-thread.ts +0 -0
  43. /package/{template-ts → templates/_shared/ts}/src/rspeedy-env.d.ts +0 -0
  44. /package/{template-ts → templates/_shared/ts}/src/tsconfig.json +0 -0
  45. /package/{template-ts → templates/_shared/ts}/tsconfig.json +0 -0
  46. /package/{template-ts → templates/_shared/ts}/tsconfig.node.json +0 -0
  47. /package/{template-common → templates/hello-world/common}/src/assets/arrow.png +0 -0
  48. /package/{template-common → templates/hello-world/common}/src/assets/lynx-logo.png +0 -0
  49. /package/{template-common → templates/hello-world/common}/src/assets/mithril-logo.png +0 -0
  50. /package/{template-common → templates/hello-world/common}/src/style.css +0 -0
  51. /package/{template-ts → templates/hello-world/ts}/src/index.ts +0 -0
package/src/index.js CHANGED
@@ -10,7 +10,26 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
10
  const packageRoot = path.join(scriptDir, "..");
11
11
  const cwd = process.cwd();
12
12
 
13
- const MITHRIL_LYNX_VERSION = "0.0.1";
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";
21
+
22
+ const TEMPLATES = [
23
+ { value: "hello-world", label: "Hello World", hint: "recommended" },
24
+ { value: "blank", label: "Blank" },
25
+ { value: "basic-activity", label: "Basic Activity", hint: "multi-screen navigation" },
26
+ ];
27
+ const TEMPLATE_VALUES = TEMPLATES.map((t) => t.value);
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"]);
14
33
 
15
34
  function isValidPackageName(name) {
16
35
  return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name);
@@ -26,6 +45,27 @@ function copyDir(from, to) {
26
45
  }
27
46
  }
28
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
+
29
69
  function replaceInFile(filePath, replacements) {
30
70
  if (!fs.existsSync(filePath)) return;
31
71
  let content = fs.readFileSync(filePath, "utf8");
@@ -33,20 +73,452 @@ function replaceInFile(filePath, replacements) {
33
73
  fs.writeFileSync(filePath, content);
34
74
  }
35
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
+
36
86
  function targetDirHasConflict(value) {
37
87
  const targetDir = path.join(cwd, value);
38
88
  return fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0;
39
89
  }
40
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
+
41
497
  async function main() {
42
498
  // Non-interactive escape hatch for scripting/CI:
43
- // create-mithril-lynx my-app --ts
44
- // create-mithril-lynx my-app --js --no-install
499
+ // create-mithril-lynx my-app --ts --hello-world
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
45
503
  const args = process.argv.slice(2);
46
- 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);
47
511
  const variantFlag = args.includes("--ts") ? "ts" : args.includes("--js") ? "js" : undefined;
512
+ const templateFlag = TEMPLATE_VALUES.find((t) => args.includes(`--${t}`));
48
513
  const noInstall = args.includes("--no-install");
49
- const nonInteractive = positional != null && variantFlag != null;
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
+ }
50
522
 
51
523
  intro("create-mithril-lynx");
52
524
 
@@ -73,6 +545,15 @@ async function main() {
73
545
  ? rawName
74
546
  : rawName.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-~]/g, "-");
75
547
 
548
+ let template = templateFlag;
549
+ if (template == null) {
550
+ template = await select({
551
+ message: "Select a template",
552
+ options: TEMPLATES,
553
+ });
554
+ if (isCancel(template)) return bail();
555
+ }
556
+
76
557
  let variant = variantFlag;
77
558
  if (variant == null) {
78
559
  variant = await select({
@@ -85,11 +566,31 @@ async function main() {
85
566
  if (isCancel(variant)) return bail();
86
567
  }
87
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
+
88
584
  const targetDir = path.join(cwd, rawName);
89
585
  fs.mkdirSync(targetDir, { recursive: true });
90
586
 
91
- copyDir(path.join(packageRoot, "template-common"), targetDir);
92
- copyDir(path.join(packageRoot, `template-${variant}`), targetDir);
587
+ // Layered copy: shared build plumbing (gitignore/README, then
588
+ // lynx.config/tsconfig/main-thread for the chosen variant), then the
589
+ // chosen template's own app content (style.css/assets, then src/index).
590
+ copyDir(path.join(packageRoot, "templates/_shared/common"), targetDir);
591
+ copyDir(path.join(packageRoot, "templates/_shared", variant), targetDir);
592
+ copyDir(path.join(packageRoot, "templates", template, "common"), targetDir);
593
+ copyDir(path.join(packageRoot, "templates", template, variant), targetDir);
93
594
 
94
595
  const gitignorePath = path.join(targetDir, "gitignore");
95
596
  if (fs.existsSync(gitignorePath)) {
@@ -102,6 +603,12 @@ async function main() {
102
603
  ]);
103
604
  replaceInFile(path.join(targetDir, "README.md"), [["{{PROJECT_NAME}}", projectName]]);
104
605
 
606
+ let androidResult = null;
607
+ if (withAndroid) {
608
+ androidResult = scaffoldAndroid({ targetDir, rawName, options: androidOptions });
609
+ patchJsProject({ targetDir, android: androidResult });
610
+ }
611
+
105
612
  let shouldInstall = !noInstall;
106
613
  if (!nonInteractive && !noInstall) {
107
614
  shouldInstall = await confirm({
@@ -127,6 +634,28 @@ async function main() {
127
634
  ...(shouldInstall ? [] : ["npm install"]),
128
635
  "npm run dev",
129
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
+
130
659
  outro(`Done! Next steps:\n\n ${steps.join("\n ")}\n\nThen scan the printed QR code with LynxExplorer.`);
131
660
  }
132
661