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.
- package/README.md +63 -0
- package/package.json +2 -2
- package/src/index.js +509 -4
- package/templates/android/app-scripts/android.mjs +232 -0
- package/templates/android/font/AssetFontFaceLoader.kt +39 -0
- package/templates/android/host/app/build.gradle.kts +103 -0
- package/templates/android/host/app/proguard-rules.pro +51 -0
- package/templates/android/host/app/src/main/AndroidManifest.xml +28 -0
- package/templates/android/host/app/src/main/java/package-path/App.kt +23 -0
- package/templates/android/host/app/src/main/java/package-path/AssetTemplateProvider.kt +27 -0
- package/templates/android/host/app/src/main/java/package-path/MainActivity.kt +37 -0
- package/templates/android/host/app/src/main/res/drawable/ic_launcher_background.xml +6 -0
- package/templates/android/host/app/src/main/res/drawable/ic_launcher_foreground.xml +11 -0
- package/templates/android/host/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/templates/android/host/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
- package/templates/android/host/app/src/main/res/values/strings.xml +3 -0
- package/templates/android/host/app/src/main/res/values/themes.xml +17 -0
- package/templates/android/host/build.gradle.kts +4 -0
- package/templates/android/host/gitignore +16 -0
- package/templates/android/host/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/templates/android/host/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/templates/android/host/gradle.properties +3 -0
- package/templates/android/host/gradlew +251 -0
- package/templates/android/host/gradlew.bat +94 -0
- package/templates/android/host/keystore.properties.example +15 -0
- package/templates/android/host/local.properties +4 -0
- 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
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
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
|
|