create-mithril-lynx 2.4.0 → 2.5.2

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/src/index.js CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { execSync } from "node:child_process";
6
+ import { execFileSync, execSync } from "node:child_process";
7
7
 
8
8
  import { cancel, confirm, intro, isCancel, multiselect, outro, select, spinner, text } from "@clack/prompts";
9
9
 
@@ -17,6 +17,7 @@ import {
17
17
  findAndroidDir,
18
18
  findProjectRoot,
19
19
  mergeFontLists,
20
+ normalizeSource,
20
21
  stripFontBlock,
21
22
  } from "./fonts-wire.js";
22
23
 
@@ -24,9 +25,10 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
24
25
  const packageRoot = path.join(scriptDir, "..");
25
26
  const cwd = process.cwd();
26
27
 
27
- // The mithril-lynx version pinned in the generated package.json — tracks
28
- // the current release, same policy as this tool's own v1.
28
+ // Resolve the newest compatible framework packages when a project is scaffolded.
29
29
  const MITHRIL_LYNX_VERSION = "latest";
30
+ const MITHRIL_LYNX_UI_VERSION = "latest";
31
+ const MITHRIL_LYNX_UI_STYLES_IMPORT = '@import "mithril-lynx-ui/styles.css";';
30
32
 
31
33
  const TEMPLATES = [
32
34
  { value: "hello-world", label: "Hello World", hint: "recommended" },
@@ -36,6 +38,101 @@ const TEMPLATES = [
36
38
  const TEMPLATE_VALUES = TEMPLATES.map((t) => t.value);
37
39
 
38
40
  const ANDROID_TEMPLATE_ROOT = path.join(packageRoot, "templates", "android");
41
+ const LYNX_ANDROID_PLUGINS_VERSION = "0.4.0";
42
+ const LYNX_ANDROID_LEGACY_VERSIONS = [
43
+ { plugins: "0.2.0", sqlite: "0.3.0", aggregate: "0.3.0" },
44
+ { plugins: "0.2.0", sqlite: "0.3.0", aggregate: "0.2.0" },
45
+ ];
46
+ const LYNX_ANDROID_JS_VERSION = "latest";
47
+ const LYNX_ANDROID_JS_LEGACY_VERSIONS = new Set(["^0.2.0"]);
48
+
49
+ const ANDROID_PLUGINS = [
50
+ {
51
+ id: "battery",
52
+ label: "Battery",
53
+ hint: "level and charging state · no permission",
54
+ artifact: "lynx-android-battery",
55
+ kotlinImport: "dev.lynx.android.plugins.battery.LynxBatteryPlugin",
56
+ registry: "LynxBatteryPlugin",
57
+ },
58
+ {
59
+ id: "camera",
60
+ label: "Camera",
61
+ hint: "external photo capture · FileProvider",
62
+ artifact: "lynx-android-camera",
63
+ kotlinImport: "dev.lynx.android.plugins.camera.LynxCameraPlugin",
64
+ registry: "LynxCameraPlugin",
65
+ activityResult: true,
66
+ },
67
+ {
68
+ id: "device",
69
+ label: "Device",
70
+ hint: "non-identifying device information · no permission",
71
+ artifact: "lynx-android-device",
72
+ kotlinImport: "dev.lynx.android.plugins.device.LynxDevicePlugin",
73
+ registry: "LynxDevicePlugin",
74
+ },
75
+ {
76
+ id: "geolocation",
77
+ label: "Geolocation",
78
+ hint: "one-shot foreground location · runtime permission",
79
+ artifact: "lynx-android-geolocation",
80
+ kotlinImport: "dev.lynx.android.plugins.geolocation.LynxGeolocationPlugin",
81
+ registry: "LynxGeolocationPlugin",
82
+ permissionResult: true,
83
+ },
84
+ {
85
+ id: "network",
86
+ label: "Network",
87
+ hint: "network snapshot · no permission",
88
+ artifact: "lynx-android-network",
89
+ kotlinImport: "dev.lynx.android.plugins.network.LynxNetworkPlugin",
90
+ registry: "LynxNetworkPlugin",
91
+ },
92
+ {
93
+ id: "vibration",
94
+ label: "Vibration",
95
+ hint: "bounded vibration · VIBRATE permission",
96
+ artifact: "lynx-android-vibration",
97
+ kotlinImport: "dev.lynx.android.plugins.vibration.LynxVibrationPlugin",
98
+ registry: "LynxVibrationPlugin",
99
+ },
100
+ {
101
+ id: "maps",
102
+ label: "Maps",
103
+ hint: "native maps · offline packages · optional foreground location",
104
+ artifact: "lynx-android-maps",
105
+ kotlinImport: "dev.lynx.android.plugins.maps.LynxMapsPlugin",
106
+ registry: "LynxMapsPlugin",
107
+ permissionResult: true,
108
+ },
109
+ {
110
+ id: "sqlite",
111
+ label: "SQLite",
112
+ hint: "app-private SQL database · no permission",
113
+ artifact: "lynx-android-sqlite",
114
+ kotlinImport: "dev.lynx.android.plugins.sqlite.LynxSqlitePlugin",
115
+ registry: "LynxSqlitePlugin",
116
+ },
117
+ ];
118
+ const ANDROID_PLUGIN_BY_ID = new Map(ANDROID_PLUGINS.map((plugin) => [plugin.id, plugin]));
119
+ const CONNECTOR_CONFIG_RELATIVE_PATH = path.join("android", "connectors.json");
120
+ const CONNECTOR_DEPENDENCY_START = " // <create-mithril-lynx:android-connectors>";
121
+ const CONNECTOR_DEPENDENCY_END = " // </create-mithril-lynx:android-connectors>";
122
+ const MAIN_ACTIVITY_CONNECTOR_MARKERS = {
123
+ imports: {
124
+ start: "// <create-mithril-lynx:connector-imports>",
125
+ end: "// </create-mithril-lynx:connector-imports>",
126
+ },
127
+ registration: {
128
+ start: " // <create-mithril-lynx:connector-registration>",
129
+ end: " // </create-mithril-lynx:connector-registration>",
130
+ },
131
+ callbacks: {
132
+ start: " // <create-mithril-lynx:connector-callbacks>",
133
+ end: " // </create-mithril-lynx:connector-callbacks>",
134
+ },
135
+ };
39
136
 
40
137
  // Files that text substitution must never touch (binaries).
41
138
  const BINARY_EXTENSIONS = new Set([".jar", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".ttf", ".otf", ".ttc", ".keystore", ".jks", ".so"]);
@@ -137,6 +234,26 @@ function appClassNameFor(rawName) {
137
234
  return name === "MainActivity" ? `${name}App` : name;
138
235
  }
139
236
 
237
+ /** Android files users edit from the JS project, before syncing them into the
238
+ * sibling Gradle host with `bun run android:prepare`. */
239
+ const ANDROID_DEVELOPMENT_FILES = [
240
+ ["AndroidManifest.xml", ["AndroidManifest.xml"]],
241
+ ["strings.xml", ["res", "values", "strings.xml"]],
242
+ ["styles.xml", ["res", "values", "styles.xml"]],
243
+ ["config.xml", ["res", "xml", "config.xml"]],
244
+ ["network_security_config.xml", ["res", "xml", "network_security_config.xml"]],
245
+ ["file_paths.xml", ["res", "xml", "file_paths.xml"]],
246
+ ];
247
+
248
+ function seedAndroidDevelopmentFiles(targetDir, androidDir) {
249
+ const sourceRoot = path.join(androidDir, "app", "src", "main");
250
+ const destinationRoot = path.join(targetDir, "android");
251
+ fs.mkdirSync(destinationRoot, { recursive: true });
252
+ for (const [name, sourceParts] of ANDROID_DEVELOPMENT_FILES) {
253
+ fs.copyFileSync(path.join(sourceRoot, ...sourceParts), path.join(destinationRoot, name));
254
+ }
255
+ }
256
+
140
257
  function fontFamilyFor(filePath) {
141
258
  return path
142
259
  .basename(filePath, path.extname(filePath))
@@ -155,6 +272,495 @@ function splitList(value) {
155
272
  .filter(Boolean);
156
273
  }
157
274
 
275
+ function normalizeAndroidPlugins(ids) {
276
+ const selected = [...new Set(ids)];
277
+ const unknown = selected.filter((id) => id !== "all" && !ANDROID_PLUGIN_BY_ID.has(id));
278
+ if (unknown.length > 0) {
279
+ cancel(`Unknown Android plugin${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. ` +
280
+ `Choose from ${ANDROID_PLUGINS.map((plugin) => plugin.id).join(", ")}, or all.`);
281
+ process.exit(1);
282
+ }
283
+ if (selected.includes("all") && selected.length > 1) {
284
+ cancel('"all" cannot be combined with individual Android plugins.');
285
+ process.exit(1);
286
+ }
287
+ return selected;
288
+ }
289
+
290
+ function connectorNpmPackages(pluginIds) {
291
+ return pluginIds.length === 0 ? [] : ["lynx-android-plugins"];
292
+ }
293
+
294
+ function mapsConfigurationUpdate(projectRoot, pluginIds) {
295
+ if (!pluginIds.includes("maps") && !pluginIds.includes("all")) return [];
296
+ const configPath = path.join(projectRoot, "android", "maps.json");
297
+ if (fs.existsSync(configPath)) return [];
298
+ return [{ path: configPath, previous: null, next: `${JSON.stringify({ url: "", sha256: "", version: "" }, null, 2)}\n` }];
299
+ }
300
+
301
+ function updatedConnectorPackageJson(projectRoot, currentIds, nextIds) {
302
+ const packagePath = path.join(projectRoot, "package.json");
303
+ const originalSource = fs.readFileSync(packagePath, "utf8");
304
+ const pkg = JSON.parse(originalSource);
305
+ if (pkg.dependencies == null || typeof pkg.dependencies !== "object" || Array.isArray(pkg.dependencies)) {
306
+ throw new Error("package.json dependencies cannot be managed. No files were changed.");
307
+ }
308
+ const current = new Set(connectorNpmPackages(currentIds));
309
+ const next = new Set(connectorNpmPackages(nextIds));
310
+ let changed = false;
311
+ for (const name of new Set([...current, ...next])) {
312
+ if (name in (pkg.devDependencies ?? {})) {
313
+ throw new Error(`${name} is in devDependencies. Move it to dependencies before managing connectors.`);
314
+ }
315
+ const version = pkg.dependencies[name];
316
+ const isManagedVersion = version === LYNX_ANDROID_JS_VERSION || LYNX_ANDROID_JS_LEGACY_VERSIONS.has(version);
317
+ if (version != null && !isManagedVersion) {
318
+ throw new Error(`${name} has a custom version. No files were changed.`);
319
+ }
320
+ if (next.has(name) && version !== LYNX_ANDROID_JS_VERSION) {
321
+ pkg.dependencies[name] = LYNX_ANDROID_JS_VERSION;
322
+ changed = true;
323
+ } else if (!next.has(name) && isManagedVersion) {
324
+ delete pkg.dependencies[name];
325
+ changed = true;
326
+ }
327
+ }
328
+ return { packagePath, source: changed ? `${JSON.stringify(pkg, null, 2)}\n` : originalSource };
329
+ }
330
+
331
+ function projectPackageManager(projectRoot) {
332
+ for (const [lock, manager] of [
333
+ ["bun.lock", "bun"], ["bun.lockb", "bun"], ["pnpm-lock.yaml", "pnpm"],
334
+ ["yarn.lock", "yarn"], ["package-lock.json", "npm"],
335
+ ]) {
336
+ if (fs.existsSync(path.join(projectRoot, lock))) return manager;
337
+ }
338
+ return detectPackageManager();
339
+ }
340
+
341
+ function androidPluginHostConfiguration(pluginIds, versions = {
342
+ plugins: LYNX_ANDROID_PLUGINS_VERSION,
343
+ sqlite: LYNX_ANDROID_PLUGINS_VERSION,
344
+ aggregate: LYNX_ANDROID_PLUGINS_VERSION,
345
+ }) {
346
+ const useAggregate = pluginIds.includes("all");
347
+ const plugins = useAggregate ? ANDROID_PLUGINS : pluginIds.map((id) => ANDROID_PLUGIN_BY_ID.get(id));
348
+ const gradleDependencies = useAggregate
349
+ ? ` implementation("io.github.carlos-sweb:lynx-android-plugins:${versions.aggregate}")`
350
+ : plugins.map((plugin) =>
351
+ ` implementation("io.github.carlos-sweb:${plugin.artifact}:${plugin.id === "sqlite" ? versions.sqlite : versions.plugins}")`,
352
+ ).join("\n");
353
+ const imports = useAggregate
354
+ ? ["import dev.lynx.android.plugins.LynxAndroidPlugins"]
355
+ : plugins.map((plugin) => `import ${plugin.kotlinImport}`);
356
+ const registrations = useAggregate
357
+ ? " LynxAndroidPlugins.register(builder)"
358
+ : plugins.map((plugin) => ` ${plugin.registry}.register(builder)`).join("\n");
359
+ const handlesPermission = useAggregate || plugins.some((plugin) => plugin.permissionResult);
360
+ const handlesActivity = useAggregate || plugins.some((plugin) => plugin.activityResult);
361
+ const permissionHandler = useAggregate
362
+ ? "LynxAndroidPlugins.onRequestPermissionsResult(requestCode, permissions, grantResults)"
363
+ : plugins.filter((plugin) => plugin.permissionResult).map((plugin) =>
364
+ `${plugin.registry}.onRequestPermissionsResult(requestCode, permissions, grantResults)`,
365
+ ).join(" || ");
366
+ const activityRegistry = useAggregate ? "LynxAndroidPlugins" : "LynxCameraPlugin";
367
+
368
+ return {
369
+ pluginIds,
370
+ handlesPermission,
371
+ handlesActivity,
372
+ gradleDependencies: gradleDependencies || " // No Lynx Android connector selected.",
373
+ imports: imports.join("\n"),
374
+ registrations: registrations || " // No Lynx Android connector selected.",
375
+ permissionHandler: handlesPermission
376
+ ? permissionHandler
377
+ : "false",
378
+ activityHandler: handlesActivity
379
+ ? `${activityRegistry}.onActivityResult(requestCode, resultCode, data)`
380
+ : "false",
381
+ };
382
+ }
383
+
384
+ function buildAndroidConnectorRegistry(packageName, pluginIds) {
385
+ if (pluginIds.length === 0) return null;
386
+ const configuration = androidPluginHostConfiguration(pluginIds);
387
+ return [
388
+ "// GENERATED by create-mithril-lynx. Manage with add-android-plugin/remove-android-plugin.",
389
+ `package ${packageName}`,
390
+ "",
391
+ ...(configuration.handlesActivity ? ["import android.content.Intent"] : []),
392
+ "import com.lynx.tasm.LynxViewBuilder",
393
+ configuration.imports,
394
+ "",
395
+ "object LynxAndroidConnectorRegistry {",
396
+ " fun register(builder: LynxViewBuilder) {",
397
+ configuration.registrations,
398
+ " }",
399
+ ...(configuration.handlesPermission ? [
400
+ "",
401
+ " fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray): Boolean =",
402
+ ` ${configuration.permissionHandler}`,
403
+ ] : []),
404
+ ...(configuration.handlesActivity ? [
405
+ "",
406
+ " fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean =",
407
+ ` ${configuration.activityHandler}`,
408
+ ] : []),
409
+ "}",
410
+ "",
411
+ ].join("\n");
412
+ }
413
+
414
+ // The previous generated format is accepted only for a one-time, exact migration.
415
+ function buildLegacyAndroidConnectorRegistry(packageName, pluginIds) {
416
+ const configuration = androidPluginHostConfiguration(pluginIds);
417
+ return [
418
+ "// GENERATED by create-mithril-lynx. Manage with add-android-plugin/remove-android-plugin.",
419
+ `package ${packageName}`,
420
+ "",
421
+ "import android.content.Intent",
422
+ "import com.lynx.tasm.LynxViewBuilder",
423
+ configuration.imports,
424
+ "",
425
+ "object LynxAndroidConnectorRegistry {",
426
+ " fun register(builder: LynxViewBuilder) {",
427
+ configuration.registrations,
428
+ " }",
429
+ "",
430
+ " fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray): Boolean =",
431
+ ` ${configuration.permissionHandler}`,
432
+ "",
433
+ " fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean =",
434
+ ` ${configuration.activityHandler}`,
435
+ "}",
436
+ "",
437
+ ].filter((line, index, lines) => line !== "" || lines[index - 1] !== "").join("\n");
438
+ }
439
+
440
+ function mainActivityConnectorSections(pluginIds) {
441
+ const configuration = androidPluginHostConfiguration(pluginIds);
442
+ const callbacks = [];
443
+ if (configuration.handlesPermission) {
444
+ callbacks.push([
445
+ " override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {",
446
+ " if (LynxAndroidConnectorRegistry.onRequestPermissionsResult(requestCode, permissions, grantResults)) return",
447
+ " super.onRequestPermissionsResult(requestCode, permissions, grantResults)",
448
+ " }",
449
+ ].join("\n"));
450
+ }
451
+ if (configuration.handlesActivity) {
452
+ callbacks.push([
453
+ ' @Deprecated("Needed for the external camera intent result")',
454
+ " override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {",
455
+ " if (LynxAndroidConnectorRegistry.onActivityResult(requestCode, resultCode, data)) return",
456
+ " super.onActivityResult(requestCode, resultCode, data)",
457
+ " }",
458
+ ].join("\n"));
459
+ }
460
+ return {
461
+ imports: configuration.handlesActivity ? "import android.content.Intent\n" : "",
462
+ registration: pluginIds.length > 0 ? " LynxAndroidConnectorRegistry.register(builder)\n" : "",
463
+ callbacks: callbacks.length > 0 ? `${callbacks.join("\n\n")}\n` : "",
464
+ };
465
+ }
466
+
467
+ function managedMainActivityBlock(source, name) {
468
+ const { start, end } = MAIN_ACTIVITY_CONNECTOR_MARKERS[name];
469
+ const startIndex = source.indexOf(start);
470
+ const endIndex = source.indexOf(end);
471
+ if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex ||
472
+ source.indexOf(start, startIndex + start.length) !== -1 || source.indexOf(end, endIndex + end.length) !== -1) {
473
+ throw new Error(`MainActivity.kt has missing or duplicate connector ${name} markers. No files were changed.`);
474
+ }
475
+ const startLineBegin = source.lastIndexOf("\n", startIndex - 1) + 1;
476
+ const startLineEnd = source.indexOf("\n", startIndex);
477
+ const endLineBegin = source.lastIndexOf("\n", endIndex - 1) + 1;
478
+ const endLineEnd = source.indexOf("\n", endIndex);
479
+ if (startLineEnd === -1 || endLineEnd === -1 || endLineBegin <= startLineEnd ||
480
+ source.slice(startLineBegin, startLineEnd) !== start || source.slice(endLineBegin, endLineEnd) !== end) {
481
+ throw new Error(`MainActivity.kt has modified connector ${name} markers. No files were changed.`);
482
+ }
483
+ return { from: startLineEnd + 1, to: endLineBegin, body: source.slice(startLineEnd + 1, endLineBegin) };
484
+ }
485
+
486
+ function replaceManagedMainActivitySections(source, sections) {
487
+ let result = source;
488
+ for (const name of Object.keys(MAIN_ACTIVITY_CONNECTOR_MARKERS)) {
489
+ const block = managedMainActivityBlock(result, name);
490
+ result = `${result.slice(0, block.from)}${sections[name]}${result.slice(block.to)}`;
491
+ }
492
+ return result;
493
+ }
494
+
495
+ function replaceLegacyMainActivityFragment(source, fragment, replacement) {
496
+ if (source.split(fragment).length !== 2) {
497
+ throw new Error("MainActivity.kt has customized legacy connector code. No files were changed.");
498
+ }
499
+ return source.replace(fragment, replacement);
500
+ }
501
+
502
+ function markLegacyMainActivity(source) {
503
+ let result = source.replace(/\r\n/g, "\n");
504
+ const old = mainActivityConnectorSections(["all"]);
505
+ for (const name of Object.keys(MAIN_ACTIVITY_CONNECTOR_MARKERS)) {
506
+ const { start, end } = MAIN_ACTIVITY_CONNECTOR_MARKERS[name];
507
+ result = replaceLegacyMainActivityFragment(result, old[name], `${start}\n${old[name]}${end}\n`);
508
+ }
509
+ return result;
510
+ }
511
+
512
+ function prepareMainActivity(source, currentPluginIds) {
513
+ const hasMarkers = Object.values(MAIN_ACTIVITY_CONNECTOR_MARKERS)
514
+ .some(({ start, end }) => source.includes(start) || source.includes(end));
515
+ if (!hasMarkers) {
516
+ if (currentPluginIds == null) {
517
+ throw new Error("MainActivity.kt is missing managed connector markers. No files were changed.");
518
+ }
519
+ return { source: markLegacyMainActivity(source), legacy: true };
520
+ }
521
+ const expected = mainActivityConnectorSections(currentPluginIds ?? []);
522
+ for (const name of Object.keys(MAIN_ACTIVITY_CONNECTOR_MARKERS)) {
523
+ if (normalizeSource(managedMainActivityBlock(source, name).body) !== normalizeSource(expected[name])) {
524
+ throw new Error(`MainActivity.kt connector ${name} block was customized. No files were changed.`);
525
+ }
526
+ }
527
+ return { source, legacy: false };
528
+ }
529
+
530
+ function mainActivityOutsideConnectorBlocks(source) {
531
+ return replaceManagedMainActivitySections(source, { imports: "", registration: "", callbacks: "" });
532
+ }
533
+
534
+ function connectorConfigPath(projectRoot) {
535
+ return path.join(projectRoot, CONNECTOR_CONFIG_RELATIVE_PATH);
536
+ }
537
+
538
+ function connectorRegistryPath(androidDir, androidId) {
539
+ return path.join(androidDir, "app", "src", "main", "java", ...androidId.split("."), "LynxAndroidConnectorRegistry.kt");
540
+ }
541
+
542
+ function connectorDependencyBlock(pluginIds, versions) {
543
+ return [
544
+ CONNECTOR_DEPENDENCY_START,
545
+ androidPluginHostConfiguration(pluginIds, versions).gradleDependencies,
546
+ CONNECTOR_DEPENDENCY_END,
547
+ ].join("\n");
548
+ }
549
+
550
+ function atomicWrite(filePath, contents) {
551
+ const temporaryPath = `${filePath}.tmp-${process.pid}`;
552
+ fs.writeFileSync(temporaryPath, contents);
553
+ fs.renameSync(temporaryPath, filePath);
554
+ }
555
+
556
+ function connectorStateUpdates({ projectRoot, androidDir, androidId, pluginIds }) {
557
+ const configPath = connectorConfigPath(projectRoot);
558
+ const registryPath = connectorRegistryPath(androidDir, androidId);
559
+ const mainActivityPath = path.join(path.dirname(registryPath), "MainActivity.kt");
560
+ const gradlePath = path.join(androidDir, "app", "build.gradle.kts");
561
+ const gradleSource = fs.readFileSync(gradlePath, "utf8");
562
+ const start = gradleSource.indexOf(CONNECTOR_DEPENDENCY_START);
563
+ const end = gradleSource.indexOf(CONNECTOR_DEPENDENCY_END);
564
+ if (start === -1 || end === -1 || end < start || gradleSource.indexOf(CONNECTOR_DEPENDENCY_START, start + 1) !== -1) {
565
+ throw new Error("The Android connector dependency block is missing or was modified. No files were changed.");
566
+ }
567
+ const currentRegistry = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, "utf8") : null;
568
+ const currentConfig = fs.existsSync(configPath) ? readConnectorConfig(configPath) : null;
569
+ const currentConfigSource = currentConfig == null ? null : fs.readFileSync(configPath, "utf8");
570
+ const mainActivitySource = fs.readFileSync(mainActivityPath, "utf8");
571
+ const mainActivityEol = mainActivitySource.includes("\r\n") ? "\r\n" : "\n";
572
+ const preparedMainActivity = prepareMainActivity(mainActivitySource.replace(/\r\n/g, "\n"), currentConfig?.plugins ?? null);
573
+ const currentDependencyBlock = gradleSource.slice(start, end + CONNECTOR_DEPENDENCY_END.length);
574
+ const managedDependencyBlocks = currentConfig == null ? [] : [
575
+ connectorDependencyBlock(currentConfig.plugins),
576
+ ...LYNX_ANDROID_LEGACY_VERSIONS.map((versions) => connectorDependencyBlock(currentConfig.plugins, versions)),
577
+ ];
578
+ if (currentConfig != null && !managedDependencyBlocks.some((block) =>
579
+ normalizeSource(currentDependencyBlock) === normalizeSource(block))) {
580
+ throw new Error("The Android connector dependency block was modified outside the generator. No files were changed.");
581
+ }
582
+ const expectedRegistry = currentConfig == null ? null : preparedMainActivity.legacy
583
+ ? buildLegacyAndroidConnectorRegistry(androidId, currentConfig.plugins)
584
+ : buildAndroidConnectorRegistry(androidId, currentConfig.plugins);
585
+ if ((currentRegistry == null) !== (expectedRegistry == null) ||
586
+ (currentRegistry != null && normalizeSource(currentRegistry) !== normalizeSource(expectedRegistry))) {
587
+ throw new Error("LynxAndroidConnectorRegistry.kt was modified outside the generator. No files were changed.");
588
+ }
589
+
590
+ const nextConfiguration = androidPluginHostConfiguration(pluginIds);
591
+ const outsideMainActivity = mainActivityOutsideConnectorBlocks(preparedMainActivity.source);
592
+ const externalIntentImport = /^import android\.content\.Intent\s*$/m.test(outsideMainActivity);
593
+ const outsideWithoutIntentImport = outsideMainActivity.replace(/^import android\.content\.Intent\s*$/gm, "");
594
+ if (nextConfiguration.handlesActivity && externalIntentImport) {
595
+ throw new Error("MainActivity.kt already imports Intent outside the managed connector block. No files were changed.");
596
+ }
597
+ if (!nextConfiguration.handlesActivity &&
598
+ managedMainActivityBlock(preparedMainActivity.source, "imports").body.includes("import android.content.Intent") &&
599
+ /\bIntent\b/.test(outsideWithoutIntentImport) && !externalIntentImport) {
600
+ throw new Error("MainActivity.kt uses the managed Intent import outside connector callbacks. No files were changed.");
601
+ }
602
+ const nextRegistry = buildAndroidConnectorRegistry(androidId, pluginIds);
603
+ if (nextRegistry == null) {
604
+ if (/\bLynxAndroidConnectorRegistry\b/.test(outsideMainActivity)) {
605
+ throw new Error("MainActivity.kt references the connector registry outside the managed block. No files were changed.");
606
+ }
607
+ const javaRoot = path.join(androidDir, "app", "src", "main", "java");
608
+ for (const file of walkFiles(javaRoot)) {
609
+ if (file === registryPath || file === mainActivityPath || !/\.(kt|java)$/.test(file)) continue;
610
+ if (fs.readFileSync(file, "utf8").includes("LynxAndroidConnectorRegistry")) {
611
+ throw new Error(`${path.basename(file)} references the connector registry. No files were changed.`);
612
+ }
613
+ }
614
+ }
615
+ const nextMainActivity = replaceManagedMainActivitySections(
616
+ preparedMainActivity.source,
617
+ mainActivityConnectorSections(pluginIds),
618
+ ).replace(/\n/g, mainActivityEol);
619
+ const updatedGradle = `${gradleSource.slice(0, start)}${connectorDependencyBlock(pluginIds)}${gradleSource.slice(end + CONNECTOR_DEPENDENCY_END.length)}`;
620
+ return [
621
+ { path: gradlePath, previous: gradleSource, next: updatedGradle },
622
+ { path: mainActivityPath, previous: mainActivitySource, next: nextMainActivity },
623
+ { path: registryPath, previous: currentRegistry, next: nextRegistry },
624
+ { path: configPath, previous: currentConfigSource, next: `${JSON.stringify({ version: 1, plugins: pluginIds }, null, 2)}\n` },
625
+ ];
626
+ }
627
+
628
+ function applyManagedUpdates(updates) {
629
+ const changed = updates.filter(({ previous, next }) => previous !== next);
630
+ for (const update of changed) {
631
+ const present = fs.existsSync(update.path);
632
+ if ((update.previous == null) !== !present ||
633
+ (present && fs.readFileSync(update.path, "utf8") !== update.previous)) {
634
+ throw new Error(`${path.basename(update.path)} changed during connector preparation. No files were changed.`);
635
+ }
636
+ }
637
+ const applied = [];
638
+ try {
639
+ for (const update of changed) {
640
+ if (update.next == null) {
641
+ fs.unlinkSync(update.path);
642
+ } else {
643
+ fs.mkdirSync(path.dirname(update.path), { recursive: true });
644
+ atomicWrite(update.path, update.next);
645
+ }
646
+ applied.push(update);
647
+ }
648
+ } catch (error) {
649
+ const rollbackErrors = [];
650
+ for (const update of applied.reverse()) {
651
+ try {
652
+ if (update.previous == null) fs.unlinkSync(update.path);
653
+ else atomicWrite(update.path, update.previous);
654
+ } catch (rollbackError) {
655
+ rollbackErrors.push(`${path.basename(update.path)}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
656
+ }
657
+ }
658
+ if (rollbackErrors.length > 0) {
659
+ throw new Error(`${error instanceof Error ? error.message : String(error)}. Rollback failed for ${rollbackErrors.join(", ")}.`);
660
+ }
661
+ throw error;
662
+ }
663
+ }
664
+
665
+ function readConnectorConfig(configPath) {
666
+ let config;
667
+ try {
668
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
669
+ } catch {
670
+ throw new Error("android/connectors.json is not valid JSON. No files were changed.");
671
+ }
672
+ if (config?.version !== 1 || !Array.isArray(config.plugins) || config.plugins.some((id) => typeof id !== "string")) {
673
+ throw new Error("android/connectors.json has an unsupported shape. No files were changed.");
674
+ }
675
+ return { plugins: normalizeAndroidPlugins(config.plugins) };
676
+ }
677
+
678
+ function requireManagedConnectorProject() {
679
+ const projectRoot = findProjectRoot(cwd);
680
+ if (projectRoot == null) throw new Error("No mithril-lynx project found here.");
681
+ const androidDir = findAndroidDir(projectRoot);
682
+ if (androidDir == null) throw new Error("No sibling Android host found for this project.");
683
+ const configPath = connectorConfigPath(projectRoot);
684
+ if (!fs.existsSync(configPath)) {
685
+ throw new Error(
686
+ "This Android host predates managed connectors or was customized. " +
687
+ "It is intentionally not patched automatically.",
688
+ );
689
+ }
690
+ const namespaceMatch = fs.readFileSync(path.join(androidDir, "app", "build.gradle.kts"), "utf8").match(/namespace\s*=\s*"([^"]+)"/);
691
+ if (namespaceMatch == null) throw new Error("Could not determine the Android namespace. No files were changed.");
692
+ return { projectRoot, androidDir, androidId: namespaceMatch[1], config: readConnectorConfig(configPath) };
693
+ }
694
+
695
+ async function runAndroidPluginCommand(command, args) {
696
+ intro(`create-mithril-lynx ${command}`);
697
+ let managed;
698
+ try {
699
+ managed = requireManagedConnectorProject();
700
+ } catch (error) {
701
+ cancel(error instanceof Error ? error.message : String(error));
702
+ process.exit(1);
703
+ }
704
+
705
+ if (command === "list-android-plugins") {
706
+ outro(
707
+ managed.config.plugins.length === 0
708
+ ? "No Android connectors are enabled."
709
+ : `Enabled Android connectors: ${managed.config.plugins.join(", ")}`,
710
+ );
711
+ return;
712
+ }
713
+
714
+ const rawPlugins = args[0];
715
+ if (rawPlugins == null) {
716
+ cancel(`Pass a comma-separated connector list, e.g. ${command} battery,geolocation.`);
717
+ process.exit(1);
718
+ }
719
+ const requested = normalizeAndroidPlugins(splitList(rawPlugins));
720
+ const skipInstall = args.includes("--no-install");
721
+ let next;
722
+ if (command === "add-android-plugin") {
723
+ next = managed.config.plugins.includes("all") || requested.includes("all")
724
+ ? ["all"]
725
+ : normalizeAndroidPlugins([...managed.config.plugins, ...requested]);
726
+ } else {
727
+ if (managed.config.plugins.includes("all") && !requested.includes("all")) {
728
+ cancel('The aggregate "all" connector must be removed as a whole.');
729
+ process.exit(1);
730
+ }
731
+ next = managed.config.plugins.filter((plugin) => !requested.includes(plugin));
732
+ }
733
+
734
+ let dependenciesChanged = false;
735
+ try {
736
+ const npm = updatedConnectorPackageJson(managed.projectRoot, managed.config.plugins, next);
737
+ const previousPackageSource = fs.readFileSync(npm.packagePath, "utf8");
738
+ dependenciesChanged = npm.source !== previousPackageSource;
739
+ applyManagedUpdates([
740
+ ...connectorStateUpdates({ ...managed, pluginIds: next }),
741
+ ...mapsConfigurationUpdate(managed.projectRoot, next),
742
+ { path: npm.packagePath, previous: previousPackageSource, next: npm.source },
743
+ ]);
744
+ if (dependenciesChanged) {
745
+ if (!skipInstall) {
746
+ const manager = projectPackageManager(managed.projectRoot);
747
+ try {
748
+ execFileSync(manager, ["install"], { cwd: managed.projectRoot, stdio: "inherit" });
749
+ } catch {
750
+ throw new Error(`Connector configuration was updated, but ${manager} install failed. Run it in the project to finish installation.`);
751
+ }
752
+ }
753
+ }
754
+ } catch (error) {
755
+ const message = error instanceof Error ? error.message : String(error);
756
+ cancel(message);
757
+ process.exitCode = 1;
758
+ return;
759
+ }
760
+ outro((next.length === 0 ? "No Android connectors are enabled." : `Enabled Android connectors: ${next.join(", ")}.`) +
761
+ (skipInstall && dependenciesChanged ? `\nRun ${projectPackageManager(managed.projectRoot)} install to update dependencies.` : ""));
762
+ }
763
+
158
764
  /** Renames `file` on any font past the first with the same basename, so
159
765
  * e.g. two different "Inter" downloads (different weights) don't collide
160
766
  * once copied into src/assets/fonts/. */
@@ -215,6 +821,7 @@ function parseAndroidArgs(args) {
215
821
  fontPath: readOption(args, "with-font"),
216
822
  fontFamily: readOption(args, "font-family"),
217
823
  findFontTerm: readOption(args, "find-font"),
824
+ plugins: readOption(args, "android-plugins"),
218
825
  };
219
826
  }
220
827
 
@@ -227,6 +834,7 @@ const OPTIONS_WITH_VALUE = new Set([
227
834
  "--with-font",
228
835
  "--font-family",
229
836
  "--find-font",
837
+ "--android-plugins",
230
838
  ]);
231
839
 
232
840
  function findPositional(args) {
@@ -369,6 +977,46 @@ async function findFontInteractively(term) {
369
977
  return results;
370
978
  }
371
979
 
980
+ /** Prompt for any number of Fontsource families during interactive scaffolding. */
981
+ async function addFontsInteractively() {
982
+ const fonts = [];
983
+ let addAnother = await confirm({
984
+ message: "Would you like to add fonts?",
985
+ initialValue: false,
986
+ });
987
+ if (isCancel(addAnother)) return null;
988
+
989
+ while (addAnother) {
990
+ const term = await text({
991
+ message: "Search for a font",
992
+ placeholder: "e.g. Inter",
993
+ validate(value) {
994
+ if (!value?.trim()) return "Enter a font name to search.";
995
+ },
996
+ });
997
+ if (isCancel(term)) return null;
998
+
999
+ const selected = await findFontInteractively(term.trim());
1000
+ if (selected == null) return null;
1001
+ fonts.push(
1002
+ ...selected.map((font) => ({
1003
+ sourcePath: font.sourcePath,
1004
+ file: path.basename(font.sourcePath),
1005
+ family: font.family,
1006
+ })),
1007
+ );
1008
+ dedupeFontFiles(fonts);
1009
+
1010
+ addAnother = await confirm({
1011
+ message: "Would you like to add another font?",
1012
+ initialValue: false,
1013
+ });
1014
+ if (isCancel(addAnother)) return null;
1015
+ }
1016
+
1017
+ return fonts;
1018
+ }
1019
+
372
1020
  // ---------------------------------------------------------------------------
373
1021
  // Android host scaffolding
374
1022
  // ---------------------------------------------------------------------------
@@ -412,10 +1060,30 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
412
1060
  }
413
1061
  appName = appName ?? path.basename(rawName);
414
1062
 
1063
+ let plugins = android.plugins == null ? [] : normalizeAndroidPlugins(splitList(android.plugins));
1064
+ if (android.plugins == null && canPrompt) {
1065
+ plugins = await multiselect({
1066
+ message: "Which Lynx Android connectors should the host include?",
1067
+ initialValues: [],
1068
+ options: [
1069
+ ...ANDROID_PLUGINS.map((plugin) => ({ value: plugin.id, label: plugin.label, hint: plugin.hint })),
1070
+ { value: "all", label: "All connectors", hint: "convenience aggregate; includes every manifest contribution" },
1071
+ ],
1072
+ });
1073
+ if (isCancel(plugins)) return null;
1074
+ plugins = normalizeAndroidPlugins(plugins);
1075
+ }
1076
+
415
1077
  const fonts = await resolveFontsFromFlags(android, { baseDir: cwd, requireFonts: false });
416
1078
  if (fonts == null) return null;
1079
+ if (canPrompt && android.fontPath == null && android.findFontTerm == null) {
1080
+ const interactiveFonts = await addFontsInteractively();
1081
+ if (interactiveFonts == null) return null;
1082
+ fonts.push(...interactiveFonts);
1083
+ dedupeFontFiles(fonts);
1084
+ }
417
1085
 
418
- return { androidId, appName, fonts };
1086
+ return { androidId, appName, fonts, plugins };
419
1087
  }
420
1088
 
421
1089
  /**
@@ -584,7 +1252,7 @@ async function runAddFont(args) {
584
1252
  }
585
1253
 
586
1254
  function scaffoldAndroid({ targetDir, rawName, options }) {
587
- const { androidId, appName, fonts } = options;
1255
+ const { androidId, appName, fonts, plugins } = options;
588
1256
 
589
1257
  const androidDirName = `${path.basename(rawName.replace(/^@[^/]+\//, ""))}-android`;
590
1258
  const androidDir = path.join(path.dirname(targetDir), androidDirName);
@@ -616,6 +1284,8 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
616
1284
  // and templates/android/host/.../NoopGenericResourceFetcher.kt.
617
1285
  const sdkDir = findAndroidSdk();
618
1286
 
1287
+ const pluginHost = androidPluginHostConfiguration(plugins);
1288
+
619
1289
  // 3. Text substitutions across the whole Android host (skipping the
620
1290
  // gradle-wrapper.jar, the .ttf and any other binary).
621
1291
  replaceInTree(androidDir, [
@@ -623,6 +1293,7 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
623
1293
  ["{{APP_CLASS}}", appClass],
624
1294
  ["{{APP_NAME}}", xmlEscape(appName)],
625
1295
  ["{{ANDROID_DIR}}", androidDirName],
1296
+ ["{{ANDROID_PLUGIN_DEPENDENCIES}}", pluginHost.gradleDependencies],
626
1297
  [
627
1298
  "{{SDK_DIR}}",
628
1299
  sdkDir != null
@@ -641,10 +1312,23 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
641
1312
  }
642
1313
  }
643
1314
 
644
- // 5. The script that joins the two halves, inside the JS project.
1315
+ // 5. Give app developers editable Android XML in their project, instead of
1316
+ // making them traverse into the sibling Gradle host. android:prepare copies
1317
+ // these exact files back into their Android resource locations.
1318
+ seedAndroidDevelopmentFiles(targetDir, androidDir);
1319
+
1320
+ // 6. The managed MainActivity sections, registry, and Gradle dependency block
1321
+ // are rendered together so post-install connector changes stay consistent.
1322
+ applyManagedUpdates([
1323
+ ...connectorStateUpdates({ projectRoot: targetDir, androidDir, androidId, pluginIds: plugins }),
1324
+ ...mapsConfigurationUpdate(targetDir, plugins),
1325
+ ]);
1326
+
1327
+ // 7. The scripts that join the two halves, inside the JS project.
645
1328
  const scriptsDir = path.join(targetDir, "scripts");
646
1329
  fs.mkdirSync(scriptsDir, { recursive: true });
647
1330
  fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android.mjs"), path.join(scriptsDir, "android.mjs"));
1331
+ fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android-prepare.mjs"), path.join(scriptsDir, "android-prepare.mjs"));
648
1332
 
649
1333
  const androidRelDir = path.relative(targetDir, androidDir).split(path.sep).join("/");
650
1334
  replaceInFile(path.join(scriptsDir, "android.mjs"), [
@@ -654,20 +1338,28 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
654
1338
  // A name with quotes would break keytool's -dname=CN=...
655
1339
  ["{{APP_NAME}}", appName.replace(/["\\]/g, "")],
656
1340
  ]);
1341
+ replaceInFile(path.join(scriptsDir, "android-prepare.mjs"), [["{{ANDROID_REL_DIR}}", androidRelDir]]);
657
1342
 
658
- return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, fonts, sdkDir };
1343
+ return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, fonts, plugins: pluginHost.pluginIds, sdkDir };
659
1344
  }
660
1345
 
661
1346
  function patchJsProject({ targetDir, android }) {
662
1347
  // package.json: the Android scripts.
663
1348
  const pkgPath = path.join(targetDir, "package.json");
664
1349
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
1350
+ for (const name of connectorNpmPackages(android.plugins)) pkg.dependencies[name] = LYNX_ANDROID_JS_VERSION;
1351
+ const buildCommand = pkg.scripts.build;
665
1352
  pkg.scripts = {
666
1353
  ...pkg.scripts,
1354
+ // Keep project-owned Android XML authoritative for every build path:
1355
+ // `bun run build`, `npm run build`, and android.mjs's package-manager
1356
+ // invocation all execute this command before rspeedy starts.
1357
+ build: "node scripts/android-prepare.mjs && " + buildCommand,
667
1358
  android: "node scripts/android.mjs",
668
1359
  "android:apk": "node scripts/android.mjs --apk",
669
1360
  "android:release": "node scripts/android.mjs --release",
670
1361
  "android:sync": "node scripts/android.mjs --sync-only",
1362
+ "android:prepare": "node scripts/android-prepare.mjs",
671
1363
  "android:keystore": "node scripts/android.mjs --keystore",
672
1364
  };
673
1365
  fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
@@ -701,6 +1393,7 @@ function patchJsProject({ targetDir, android }) {
701
1393
  "npm run android # build the bundle, copy it to assets, install and launch on the device",
702
1394
  "npm run android:apk # build the debug APK only",
703
1395
  "npm run android:sync # bundle -> assets only, no Gradle",
1396
+ "bun run android:prepare # android/*.xml -> sibling Android host",
704
1397
  "",
705
1398
  "# Signed release APK (generate the keystore once):",
706
1399
  "KEYSTORE_PASSWORD='...' npm run android:keystore",
@@ -709,6 +1402,11 @@ function patchJsProject({ targetDir, android }) {
709
1402
  "",
710
1403
  `- Application ID: \`${android.androidId}\``,
711
1404
  `- Application class: \`${android.appClass}\` · Activity: \`MainActivity\``,
1405
+ "- Android XML: edit `android/*.xml`; every `bun run build` synchronizes those files to the sibling host before bundling. Run `bun run android:prepare` when you only need the synchronization.",
1406
+ "- Android system Back bridge: the host pre-registers `MithrilLynxNavigationModule`; connect a `mithril-lynx/route` app with `route.listenBackButton({ onCanGoBackChange: (value) => NativeModules.MithrilLynxNavigationModule?.setCanGoBack(value) })`. The Basic Activity template is already connected.",
1407
+ android.plugins.length > 0
1408
+ ? `- Android connectors: ${android.plugins.join(", ")}. The host registers the native modules and package.json includes their typed JavaScript facades. See [lynx-android-plugins](https://github.com/carlos-sweb/lynx-android-plugins#javascript-facades).`
1409
+ : "- Android connectors: none selected. Run `npx create-mithril-lynx add-android-plugin <name>` from this app directory to opt into one.",
712
1410
  ...android.fonts.map(
713
1411
  (f) =>
714
1412
  `- 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)).`,
@@ -719,6 +1417,79 @@ function patchJsProject({ targetDir, android }) {
719
1417
  }
720
1418
  }
721
1419
 
1420
+ function addUiThemeClass(source, rootClass, themeClass, sourcePath) {
1421
+ const themedClass = `class: "${rootClass} ${themeClass}"`;
1422
+ if (source.includes(themedClass)) return source;
1423
+
1424
+ const currentClass = `class: "${rootClass}"`;
1425
+ const index = source.indexOf(currentClass);
1426
+ if (index === -1) {
1427
+ throw new Error(`Cannot configure mithril-lynx-ui: expected root class ${JSON.stringify(rootClass)} in ${sourcePath}.`);
1428
+ }
1429
+ return `${source.slice(0, index)}${themedClass}${source.slice(index + currentClass.length)}`;
1430
+ }
1431
+
1432
+ function patchUiProject({ targetDir, template }) {
1433
+ const templateUi = {
1434
+ "hello-world": {
1435
+ themeClass: "luna-dark",
1436
+ roots: [{ file: "src/index.ts", className: "App" }],
1437
+ },
1438
+ blank: {
1439
+ themeClass: "luna-light",
1440
+ roots: [{ file: "src/index.ts", className: "Page" }],
1441
+ },
1442
+ "basic-activity": {
1443
+ themeClass: "luna-light",
1444
+ roots: ["home", "detail"].map((screen) => ({
1445
+ file: `src/screens/${screen}.ts`,
1446
+ className: "Page",
1447
+ })),
1448
+ },
1449
+ }[template];
1450
+ if (templateUi == null) throw new Error(`Cannot configure mithril-lynx-ui for unknown template "${template}".`);
1451
+
1452
+ const packagePath = path.join(targetDir, "package.json");
1453
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
1454
+ packageJson.dependencies ??= {};
1455
+ packageJson.dependencies["mithril-lynx-ui"] = MITHRIL_LYNX_UI_VERSION;
1456
+
1457
+ const stylesheetPath = path.join(targetDir, "src", "style.css");
1458
+ const stylesheet = fs.readFileSync(stylesheetPath, "utf8");
1459
+ const updatedStylesheet = stylesheet.includes(MITHRIL_LYNX_UI_STYLES_IMPORT)
1460
+ ? stylesheet
1461
+ : `${MITHRIL_LYNX_UI_STYLES_IMPORT}\n\n${stylesheet}`;
1462
+
1463
+ const themedSources = templateUi.roots.map(({ file, className }) => {
1464
+ const sourcePath = path.join(targetDir, file);
1465
+ const source = fs.readFileSync(sourcePath, "utf8");
1466
+ return [sourcePath, addUiThemeClass(source, className, templateUi.themeClass, file)];
1467
+ });
1468
+
1469
+ fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
1470
+ if (updatedStylesheet !== stylesheet) fs.writeFileSync(stylesheetPath, updatedStylesheet);
1471
+ for (const [sourcePath, source] of themedSources) fs.writeFileSync(sourcePath, source);
1472
+
1473
+ const readmePath = path.join(targetDir, "README.md");
1474
+ if (fs.existsSync(readmePath)) {
1475
+ const readme = fs.readFileSync(readmePath, "utf8");
1476
+ if (!readme.includes("## UI components")) {
1477
+ const section = [
1478
+ "",
1479
+ "## UI components",
1480
+ "",
1481
+ `This project was scaffolded with \`mithril-lynx-ui\`. Its stylesheet is imported from \`src/style.css\`, and the app root uses the \`${templateUi.themeClass}\` Luna theme.`,
1482
+ "",
1483
+ "Import components from their package entry points, for example `import { Button } from \"mithril-lynx-ui/button\";`. Components are headless by default; add their documented `ui-*` classes to opt into the supplied styles.",
1484
+ "",
1485
+ "See the [mithril-lynx-ui documentation](https://github.com/carlos-sweb/mithril-lynx-ui#readme) for component APIs and styling details.",
1486
+ "",
1487
+ ].join("\n");
1488
+ fs.appendFileSync(readmePath, section);
1489
+ }
1490
+ }
1491
+ }
1492
+
722
1493
  // ---------------------------------------------------------------------------
723
1494
 
724
1495
  const USAGE = `
@@ -727,17 +1498,27 @@ create-mithril-lynx — scaffold a mithril-lynx app (and, optionally, its Androi
727
1498
  Usage:
728
1499
  npm create mithril-lynx@latest [name] [options]
729
1500
  npx create-mithril-lynx <name> --blank --android
1501
+ npx create-mithril-lynx <name> --blank --with-ui
730
1502
  npx create-mithril-lynx add-font --with-font ./Foo.ttf
731
1503
  npx create-mithril-lynx add-font --find-font Inter
1504
+ npx create-mithril-lynx add-android-plugin battery,geolocation
732
1505
 
733
1506
  Template (prompted for if omitted):
734
1507
  --hello-world | --blank | --basic-activity
735
1508
 
1509
+ UI components:
1510
+ --with-ui add mithril-lynx-ui and import its stylesheet;
1511
+ offered during interactive setup
1512
+
736
1513
  Android host:
737
- --android, --target android, target=android
1514
+ --android, --target android, --target=android, target=android
738
1515
  scaffold the sibling Gradle project <name>-android/
739
1516
  --android-id <id> applicationId / namespace (default com.example.<name>)
740
1517
  --app-name <name> launcher label (default: the project name)
1518
+ --android-plugins <list> native connectors to include: battery, camera,
1519
+ device, geolocation, network, vibration, maps,
1520
+ sqlite, or all.
1521
+ Comma-separate individual connectors.
741
1522
  --with-font <file.ttf> bundle the font into the JS project and register it
742
1523
  with lynx.addFont() (DEV: data: URI for Lynx Go;
743
1524
  PROD: asset:/// + AssetFontFaceLoader — see README).
@@ -760,9 +1541,14 @@ Post-init (run inside an existing app directory):
760
1541
  print the lines to paste by hand.
761
1542
  Same --with-font / --find-font / --font-family
762
1543
  flags as above.
1544
+ add-android-plugin <list> add managed Android connector(s)
1545
+ remove-android-plugin <list>
1546
+ remove managed Android connector(s)
1547
+ list-android-plugins show managed Android connectors
763
1548
 
764
1549
  Other:
765
- --no-install don't install dependencies
1550
+ --no-install don't install dependencies during scaffold or
1551
+ post-init connector changes
766
1552
  -h, --help print this
767
1553
  `.trim();
768
1554
 
@@ -784,16 +1570,20 @@ async function main() {
784
1570
  await runAddFont(args.slice(1));
785
1571
  return;
786
1572
  }
1573
+ if (["add-android-plugin", "remove-android-plugin", "list-android-plugins"].includes(args[0])) {
1574
+ await runAndroidPluginCommand(args[0], args.slice(1));
1575
+ return;
1576
+ }
787
1577
 
788
1578
  const positional = findPositional(args);
789
1579
  const templateFlag = TEMPLATE_VALUES.find((t) => args.includes(`--${t}`));
790
1580
  const noInstall = args.includes("--no-install");
1581
+ let withUi = args.includes("--with-ui");
791
1582
  const nonInteractive = positional != null && templateFlag != null;
792
1583
  const android = parseAndroidArgs(args);
793
1584
 
794
- // --with-font/--find-font/--font-family only make sense with an Android
795
- // host: imply it rather than ignoring them silently.
796
- if (android.fontPath != null || android.findFontTerm != null || android.fontFamily != null) {
1585
+ // Android-only options imply the host rather than being silently ignored.
1586
+ if (android.fontPath != null || android.findFontTerm != null || android.fontFamily != null || android.plugins != null) {
797
1587
  android.requested = true;
798
1588
  }
799
1589
 
@@ -831,6 +1621,14 @@ async function main() {
831
1621
  if (isCancel(template)) return bail();
832
1622
  }
833
1623
 
1624
+ if (!withUi && !nonInteractive) {
1625
+ withUi = await confirm({
1626
+ message: "Include mithril-lynx-ui components?",
1627
+ initialValue: false,
1628
+ });
1629
+ if (isCancel(withUi)) return bail();
1630
+ }
1631
+
834
1632
  let withAndroid = android.requested;
835
1633
  if (!withAndroid && !nonInteractive) {
836
1634
  withAndroid = await confirm({
@@ -871,6 +1669,8 @@ async function main() {
871
1669
  ]);
872
1670
  replaceInFile(path.join(targetDir, "README.md"), [["{{PROJECT_NAME}}", projectName]]);
873
1671
 
1672
+ if (withUi) patchUiProject({ targetDir, template });
1673
+
874
1674
  let androidResult = null;
875
1675
  if (withAndroid) {
876
1676
  androidResult = scaffoldAndroid({ targetDir, rawName, options: androidOptions });