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.
- package/README.md +97 -12
- package/package.json +3 -5
- package/src/index.js +536 -7
- 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/templates/basic-activity/common/src/style.css +59 -0
- package/templates/basic-activity/js/src/index.js +43 -0
- package/templates/basic-activity/ts/src/index.ts +48 -0
- package/templates/blank/common/src/style.css +16 -0
- package/templates/blank/js/src/index.js +11 -0
- package/templates/blank/ts/src/index.ts +11 -0
- /package/{template-js → templates/hello-world/js}/src/index.js +0 -0
- /package/{template-common → templates/_shared/common}/README.md +0 -0
- /package/{template-common → templates/_shared/common}/gitignore +0 -0
- /package/{template-js → templates/_shared/js}/lynx.config.js +0 -0
- /package/{template-js → templates/_shared/js}/package.json +0 -0
- /package/{template-js → templates/_shared/js}/src/main-thread.js +0 -0
- /package/{template-ts → templates/_shared/ts}/lynx.config.ts +0 -0
- /package/{template-ts → templates/_shared/ts}/package.json +0 -0
- /package/{template-ts → templates/_shared/ts}/src/main-thread.ts +0 -0
- /package/{template-ts → templates/_shared/ts}/src/rspeedy-env.d.ts +0 -0
- /package/{template-ts → templates/_shared/ts}/src/tsconfig.json +0 -0
- /package/{template-ts → templates/_shared/ts}/tsconfig.json +0 -0
- /package/{template-ts → templates/_shared/ts}/tsconfig.node.json +0 -0
- /package/{template-common → templates/hello-world/common}/src/assets/arrow.png +0 -0
- /package/{template-common → templates/hello-world/common}/src/assets/lynx-logo.png +0 -0
- /package/{template-common → templates/hello-world/common}/src/assets/mithril-logo.png +0 -0
- /package/{template-common → templates/hello-world/common}/src/style.css +0 -0
- /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
|
-
|
|
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, "&").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
|
+
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|