@cmpose/core 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cmpose contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
- # Temporary Holding Version
1
+ # @cmpose/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The cmpose generator library: it turns the pinned Compose Multiplatform template into a new project (validation, package and name rewrites, optional features and modules). It is the same code cmpose.dev runs. See the README at the root of the cmpose repository for usage and development.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@cmpose/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "cmpose project generator core (Compose Multiplatform)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Keremturker/cmpose.git",
9
+ "directory": "packages/core"
10
+ },
11
+ "homepage": "https://github.com/Keremturker/cmpose/tree/main/packages/core#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Keremturker/cmpose/issues"
14
+ },
15
+ "keywords": [
16
+ "compose-multiplatform",
17
+ "kotlin-multiplatform",
18
+ "kmp",
19
+ "generator",
20
+ "scaffold",
21
+ "android",
22
+ "ios"
23
+ ],
24
+ "main": "src/index.js",
25
+ "files": [
26
+ "src"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test test/*.test.js"
33
+ },
34
+ "dependencies": {
35
+ "fs-extra": "^11.3.2"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * ProjectValidator - Shared validation logic for both frontend and backend
3
+ * UMD (Universal Module Definition) format for browser and Node.js compatibility
4
+ */
5
+ (function (root, factory) {
6
+ if (typeof module !== 'undefined' && module.exports) {
7
+ // Node.js / CommonJS
8
+ module.exports = factory();
9
+ } else {
10
+ // Browser global
11
+ root.ProjectValidator = factory();
12
+ }
13
+ }(typeof self !== 'undefined' ? self : this, function () {
14
+ 'use strict';
15
+
16
+ // Default Constants (can be overridden via configure())
17
+ let MAX_MODULES = 3;
18
+ let MIN_SDK_MIN = 24;
19
+ let MIN_SDK_MAX = 36;
20
+ let IOS_VERSION_MIN = '15.0';
21
+ let IOS_VERSION_MAX = '26.2';
22
+ // Package reserved by the template itself (e.g. its own package); none by default
23
+ let RESERVED_PACKAGE_PREFIX = null;
24
+
25
+ // Max lengths for input fields
26
+ const MAX_LENGTH_PROJECT_NAME = 30;
27
+ const MAX_LENGTH_APP_NAME = 30;
28
+ const MAX_LENGTH_PACKAGE_NAME = 50;
29
+ const MAX_LENGTH_MODULE_NAME = 20;
30
+ const MAX_LENGTH_BASE_URL = 100;
31
+
32
+ // Format regexes: the one place they live (validators below use them; contract.js publishes their sources)
33
+ const PATTERNS = {
34
+ projectName: /^[a-zA-Z]+$/,
35
+ packageName: /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*){2,}$/,
36
+ moduleName: /^[a-z][a-z0-9]*$/,
37
+ iosVersionFormat: /^\d+(\.\d+){1,2}$/
38
+ };
39
+
40
+ // Kotlin hard keywords - cannot be used as package segments or module names
41
+ const KOTLIN_HARD_KEYWORDS = ['as', 'break', 'class', 'continue', 'do', 'else', 'false', 'for', 'fun', 'if', 'in', 'interface', 'is', 'null', 'object', 'package', 'return', 'super', 'this', 'throw', 'true', 'try', 'typealias', 'typeof', 'val', 'var', 'when', 'while'];
42
+
43
+ function isKotlinHardKeyword(word) {
44
+ return KOTLIN_HARD_KEYWORDS.indexOf(word) !== -1;
45
+ }
46
+
47
+ // Module names the generator cannot use: file processing skips every directory named build
48
+ // (Gradle output), so feature/build would keep the template package; monetization is the
49
+ // template's own feature/monetization module (purchases/ads flags); notes is blank's Room demo
50
+ // (feature/notes)
51
+ const RESERVED_MODULE_NAMES = ['build', 'monetization', 'notes'];
52
+
53
+ /**
54
+ * Compare two version strings (e.g., '15.0' vs '15.0.1')
55
+ * @returns {number} - -1 if a < b, 0 if a == b, 1 if a > b
56
+ */
57
+ function compareVersions(a, b) {
58
+ const partsA = a.split('.').map(Number);
59
+ const partsB = b.split('.').map(Number);
60
+ const len = Math.max(partsA.length, partsB.length);
61
+ for (let i = 0; i < len; i++) {
62
+ const numA = partsA[i] || 0;
63
+ const numB = partsB[i] || 0;
64
+ if (numA < numB) return -1;
65
+ if (numA > numB) return 1;
66
+ }
67
+ return 0;
68
+ }
69
+
70
+ const ProjectValidator = {
71
+ /**
72
+ * Maximum number of feature modules allowed
73
+ */
74
+ MAX_MODULES: MAX_MODULES,
75
+
76
+ /**
77
+ * Configure validation limits (call this on server startup or page load)
78
+ * @param {Object} config - Configuration object
79
+ */
80
+ configure: function (config) {
81
+ if (config.maxModules !== undefined) MAX_MODULES = config.maxModules;
82
+ if (config.minSdkMin !== undefined) MIN_SDK_MIN = config.minSdkMin;
83
+ if (config.minSdkMax !== undefined) MIN_SDK_MAX = config.minSdkMax;
84
+ if (config.iosVersionMin !== undefined) IOS_VERSION_MIN = config.iosVersionMin;
85
+ if (config.iosVersionMax !== undefined) IOS_VERSION_MAX = config.iosVersionMax;
86
+ if (config.reservedPackagePrefix !== undefined) RESERVED_PACKAGE_PREFIX = config.reservedPackagePrefix;
87
+ // Update exposed constants
88
+ this.MAX_MODULES = MAX_MODULES;
89
+ this.MIN_SDK_MIN = MIN_SDK_MIN;
90
+ this.MIN_SDK_MAX = MIN_SDK_MAX;
91
+ this.IOS_VERSION_MIN = IOS_VERSION_MIN;
92
+ this.IOS_VERSION_MAX = IOS_VERSION_MAX;
93
+ // Update dynamic messages
94
+ this.messages.maxModulesReached = 'Maximum ' + MAX_MODULES + ' feature modules allowed';
95
+ this.messages.minSdkInvalid = 'Min SDK must be a number between ' + MIN_SDK_MIN + ' and ' + MIN_SDK_MAX;
96
+ this.messages.iosVersionInvalid = 'iOS Version must be between ' + IOS_VERSION_MIN + ' and ' + IOS_VERSION_MAX;
97
+ },
98
+
99
+ /**
100
+ * Get current configuration values
101
+ * @returns {Object} - Current config values
102
+ */
103
+ getConfig: function () {
104
+ return {
105
+ maxModules: MAX_MODULES,
106
+ minSdkMin: MIN_SDK_MIN,
107
+ minSdkMax: MIN_SDK_MAX,
108
+ iosVersionMin: IOS_VERSION_MIN,
109
+ iosVersionMax: IOS_VERSION_MAX,
110
+ reservedPackagePrefix: RESERVED_PACKAGE_PREFIX
111
+ };
112
+ },
113
+
114
+ /**
115
+ * Exposed config values
116
+ */
117
+ MIN_SDK_MIN: MIN_SDK_MIN,
118
+ MIN_SDK_MAX: MIN_SDK_MAX,
119
+ IOS_VERSION_MIN: IOS_VERSION_MIN,
120
+ IOS_VERSION_MAX: IOS_VERSION_MAX,
121
+
122
+ /**
123
+ * Max lengths for input fields
124
+ */
125
+ MAX_LENGTH_PROJECT_NAME: MAX_LENGTH_PROJECT_NAME, // Project name
126
+ MAX_LENGTH_APP_NAME: MAX_LENGTH_APP_NAME, // App name (user visible)
127
+ MAX_LENGTH_PACKAGE_NAME: MAX_LENGTH_PACKAGE_NAME, // Package name (e.g., com.company.app.module)
128
+ MAX_LENGTH_MODULE_NAME: MAX_LENGTH_MODULE_NAME, // Feature module name
129
+ MAX_LENGTH_MIN_SDK: 2, // Max 2 digits (e.g., 36)
130
+ MAX_LENGTH_IOS_VERSION: 8, // Max format: xx.xx.xx (e.g., 26.2.1)
131
+ MAX_LENGTH_BASE_URL: MAX_LENGTH_BASE_URL, // Network base URL
132
+
133
+ /**
134
+ * Format regexes and name lists (read-only; contract.js publishes them)
135
+ */
136
+ patterns: PATTERNS,
137
+ KOTLIN_HARD_KEYWORDS: KOTLIN_HARD_KEYWORDS,
138
+ RESERVED_MODULE_NAMES: RESERVED_MODULE_NAMES,
139
+
140
+ /**
141
+ * Validates that the value is a string no longer than max characters
142
+ * @param {string} value - The value to validate
143
+ * @param {number} max - Maximum allowed length (inclusive)
144
+ * @returns {boolean} - True if valid
145
+ */
146
+ validateMaxLength: function (value, max) {
147
+ return typeof value === 'string' && value.length <= max;
148
+ },
149
+
150
+ /**
151
+ * Validates that the value contains no spaces
152
+ * @param {string} value - The value to validate
153
+ * @returns {boolean} - True if valid (no spaces)
154
+ */
155
+ validateNoSpaces: function (value) {
156
+ return !value.includes(' ');
157
+ },
158
+
159
+ /**
160
+ * Validates App Name (call with the trimmed value) - letters (any script, including
161
+ * combining marks), digits, spaces and . _ ' - only, no leading/trailing whitespace,
162
+ * at most MAX_LENGTH_APP_NAME
163
+ * @param {string} value - The app name to validate
164
+ * @returns {boolean} - True if valid
165
+ */
166
+ validateAppName: function (value) {
167
+ if (typeof value !== 'string') return false;
168
+ if (value.length === 0 || value.length > MAX_LENGTH_APP_NAME) return false;
169
+ if (value.trim() !== value) return false;
170
+ return /^[\p{L}\p{M}\p{N} ._'-]+$/u.test(value);
171
+ },
172
+
173
+ /**
174
+ * Validates Package Name format
175
+ * Must start with a letter, contain at least 3 segments separated by dots,
176
+ * and use only lowercase letters and numbers. No segment may be a Kotlin hard
177
+ * keyword, and the reserved package (see configure) and packages under it are rejected.
178
+ * Example: com.example.app
179
+ * @param {string} value - The package name to validate
180
+ * @returns {boolean} - True if valid
181
+ */
182
+ validatePackageName: function (value) {
183
+ if (!PATTERNS.packageName.test(value)) return false;
184
+ if (value.split('.').some(isKotlinHardKeyword)) return false;
185
+ if (RESERVED_PACKAGE_PREFIX &&
186
+ (value === RESERVED_PACKAGE_PREFIX || value.indexOf(RESERVED_PACKAGE_PREFIX + '.') === 0)) {
187
+ return false;
188
+ }
189
+ return true;
190
+ },
191
+
192
+ /**
193
+ * Validates URL - http(s) only, at most MAX_LENGTH_BASE_URL characters, and free of
194
+ * characters that would break the generated Kotlin string (" \ $, whitespace, controls)
195
+ * @param {string} value - The URL to validate
196
+ * @returns {boolean} - True if valid URL
197
+ */
198
+ validateUrl: function (value) {
199
+ if (typeof value !== 'string') return false;
200
+ if (value.length > MAX_LENGTH_BASE_URL) return false;
201
+ if (/["\\$\s\u0000-\u001F\u007F]/.test(value)) return false;
202
+ try {
203
+ const url = new URL(value);
204
+ return url.protocol === 'http:' || url.protocol === 'https:';
205
+ } catch (_) {
206
+ return false;
207
+ }
208
+ },
209
+
210
+ /**
211
+ * Validates module count against maximum limit
212
+ * @param {number} count - Number of modules
213
+ * @returns {boolean} - True if count is valid (at or below limit)
214
+ */
215
+ validateModuleCount: function (count) {
216
+ return count <= MAX_MODULES;
217
+ },
218
+
219
+ /**
220
+ * Checks if more modules can be added
221
+ * @param {number} currentCount - Current number of modules
222
+ * @returns {boolean} - True if can add more modules
223
+ */
224
+ canAddModule: function (currentCount) {
225
+ return currentCount < MAX_MODULES;
226
+ },
227
+
228
+ /**
229
+ * Validates Project Name - only letters allowed (a-zA-Z)
230
+ * @param {string} value - The project name to validate
231
+ * @returns {boolean} - True if valid
232
+ */
233
+ validateProjectName: function (value) {
234
+ return PATTERNS.projectName.test(value);
235
+ },
236
+
237
+ /**
238
+ * Sanitizes Project Name - removes all non-letter characters
239
+ * @param {string} value - The input value
240
+ * @returns {string} - Sanitized value
241
+ */
242
+ sanitizeProjectName: function (value) {
243
+ return value.replace(/[^a-zA-Z]/g, '');
244
+ },
245
+
246
+ /**
247
+ * Sanitizes Package Name - lowercase, only a-z, 0-9 and . allowed, no consecutive dots
248
+ * (same character set as validatePackageName, so typing cannot produce a rejected char)
249
+ * @param {string} value - The input value
250
+ * @returns {string} - Sanitized value
251
+ */
252
+ sanitizePackageName: function (value) {
253
+ return value.toLowerCase().replace(/[^a-z0-9.]/g, '').replace(/\.{2,}/g, '.');
254
+ },
255
+
256
+ /**
257
+ * Sanitizes App Name - no leading spaces, no consecutive spaces
258
+ * @param {string} value - The input value
259
+ * @returns {string} - Sanitized value
260
+ */
261
+ sanitizeAppName: function (value) {
262
+ return value.replace(/^\s+/, '').replace(/\s{2,}/g, ' ');
263
+ },
264
+
265
+ /**
266
+ * Sanitizes Module Name - lowercase, a-z and 0-9 allowed, removes leading digits
267
+ * @param {string} value - The input value
268
+ * @returns {string} - Sanitized value
269
+ */
270
+ sanitizeModuleName: function (value) {
271
+ let sanitized = value.toLowerCase().replace(/[^a-z0-9]/g, '');
272
+ // Remove leading digits (module name must start with a letter)
273
+ while (sanitized.length > 0 && /^[0-9]/.test(sanitized)) {
274
+ sanitized = sanitized.substring(1);
275
+ }
276
+ return sanitized;
277
+ },
278
+
279
+ /**
280
+ * Validates Module Name - lowercase letters and numbers allowed, must start with letter,
281
+ * must not be a Kotlin hard keyword (it becomes a package segment) or a reserved name
282
+ * (RESERVED_MODULE_NAMES)
283
+ * @param {string} value - The module name to validate
284
+ * @returns {boolean} - True if valid
285
+ */
286
+ validateModuleName: function (value) {
287
+ return PATTERNS.moduleName.test(value) && value.length > 0 && !isKotlinHardKeyword(value) &&
288
+ RESERVED_MODULE_NAMES.indexOf(value) === -1;
289
+ },
290
+
291
+ /**
292
+ * Sanitizes Android Min SDK - only digits allowed
293
+ * @param {string} value - The input value
294
+ * @returns {string} - Sanitized value (digits only)
295
+ */
296
+ sanitizeMinSdk: function (value) {
297
+ return value.replace(/[^0-9]/g, '');
298
+ },
299
+
300
+ /**
301
+ * Validates Android Min SDK - must be a valid number within range
302
+ * @param {number|string} value - The min SDK value
303
+ * @returns {boolean} - True if valid (number between MIN_SDK_MIN and MIN_SDK_MAX)
304
+ */
305
+ validateMinSdk: function (value) {
306
+ if (!/^\d{1,2}$/.test(value)) return false;
307
+ const num = parseInt(value, 10);
308
+ return num >= MIN_SDK_MIN && num <= MIN_SDK_MAX;
309
+ },
310
+
311
+ /**
312
+ * Sanitizes iOS Version - only digits and dots, max 2 dots, no consecutive dots
313
+ * @param {string} value - The input value
314
+ * @returns {string} - Sanitized value
315
+ */
316
+ sanitizeIosVersion: function (value) {
317
+ // Remove non-digit and non-dot characters
318
+ let sanitized = value.replace(/[^0-9.]/g, '');
319
+ // Remove consecutive dots
320
+ sanitized = sanitized.replace(/\.{2,}/g, '.');
321
+ // Remove leading dot
322
+ if (sanitized.startsWith('.')) {
323
+ sanitized = sanitized.substring(1);
324
+ }
325
+ // Limit to max 2 dots (major.minor.patch)
326
+ const parts = sanitized.split('.');
327
+ if (parts.length > 3) {
328
+ sanitized = parts.slice(0, 3).join('.');
329
+ }
330
+ return sanitized;
331
+ },
332
+
333
+ /**
334
+ * Validates iOS Version format and range
335
+ * @param {string} value - The iOS version string
336
+ * @returns {boolean} - True if valid format and within range
337
+ */
338
+ validateIosVersion: function (value) {
339
+ // Check format first
340
+ if (!PATTERNS.iosVersionFormat.test(value)) return false;
341
+ // Check range
342
+ return compareVersions(value, IOS_VERSION_MIN) >= 0 &&
343
+ compareVersions(value, IOS_VERSION_MAX) <= 0;
344
+ },
345
+
346
+ /**
347
+ * Error messages for validation failures
348
+ */
349
+ messages: {
350
+ projectNameSpaces: 'Project Name cannot contain spaces',
351
+ projectNameInvalid: 'Project Name must contain only letters (a-z, A-Z)',
352
+ projectNameTooLong: 'Project Name must be at most ' + MAX_LENGTH_PROJECT_NAME + ' characters',
353
+ packageNameSpaces: 'Package Name cannot contain spaces',
354
+ packageNameInvalid: 'Invalid Package Name. Must start with a letter, contain at least two dots (3 segments), and use only lowercase letters and numbers (e.g., com.example.app). Segments cannot be Kotlin keywords (e.g., in, is, object), and the template\'s reserved package (or any package under it) cannot be used.',
355
+ packageNameTooLong: 'Package Name must be at most ' + MAX_LENGTH_PACKAGE_NAME + ' characters',
356
+ appNameInvalid: 'App Name must be 1-' + MAX_LENGTH_APP_NAME + ' characters using only letters, numbers, spaces and . _ \' - (no leading or trailing spaces)',
357
+ urlInvalid: 'Invalid Base URL. Please enter a valid http(s) URL of at most ' + MAX_LENGTH_BASE_URL + ' characters without spaces, quotes, backslashes or $ (e.g., https://api.example.com)',
358
+ maxModulesReached: 'Maximum ' + MAX_MODULES + ' feature modules allowed',
359
+ moduleNameInvalid: 'Module name must start with a letter, contain only lowercase letters and numbers (a-z, 0-9), and not be a Kotlin keyword (e.g., in, is, object) or one of the reserved names (' + RESERVED_MODULE_NAMES.join(', ') + ')',
360
+ moduleNameTooLong: 'Module name must be at most ' + MAX_LENGTH_MODULE_NAME + ' characters',
361
+ moduleNameDuplicate: 'Each module name can be used only once',
362
+ minSdkInvalid: 'Min SDK must be a number between ' + MIN_SDK_MIN + ' and ' + MIN_SDK_MAX + '',
363
+ iosVersionInvalid: 'iOS Version must be between ' + IOS_VERSION_MIN + ' and ' + IOS_VERSION_MAX
364
+ }
365
+ };
366
+
367
+ return ProjectValidator;
368
+ }));
@@ -0,0 +1,31 @@
1
+ const { log } = require('./log');
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ const { removeMarkedBlock } = require('./readmeService');
5
+
6
+ // ci-optin: GitHub Actions CI is an opt-in for both templates. The template ships
7
+ // .github/workflows/ci.yml and marks its README line <!-- cmpose:ci -->; without the option both go.
8
+ const CI_WORKFLOW = path.join('.github', 'workflows', 'ci.yml');
9
+
10
+ async function removeIfEmpty(dir) {
11
+ if (await fs.pathExists(dir) && (await fs.readdir(dir)).length === 0) await fs.remove(dir);
12
+ }
13
+
14
+ async function applyCiOption(projectPath, ci) {
15
+ if (ci === true) return;
16
+ const workflow = path.join(projectPath, CI_WORKFLOW);
17
+ if (await fs.pathExists(workflow)) {
18
+ await fs.remove(workflow);
19
+ await removeIfEmpty(path.dirname(workflow));
20
+ await removeIfEmpty(path.join(projectPath, '.github'));
21
+ log.info('✅ Removed the CI workflow (ci option off)');
22
+ }
23
+ const readmePath = path.join(projectPath, 'README.md');
24
+ if (await fs.pathExists(readmePath)) {
25
+ const readme = await fs.readFile(readmePath, 'utf8');
26
+ const updated = removeMarkedBlock(readme, 'cmpose:ci');
27
+ if (updated !== readme) await fs.writeFile(readmePath, updated);
28
+ }
29
+ }
30
+
31
+ module.exports = { CI_WORKFLOW, applyCiOption };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ const crypto = require('crypto');
3
+ const PV = require('./ProjectValidator');
4
+ const LIMITS = require('./limits');
5
+ const { OLD_PACKAGE_NAME } = require('./templateIdentity');
6
+
7
+ // The one place the skill's contract comes from. Schema values moved here from the skill's
8
+ // sync-contract.sh; featuresConfig.js and the generator read the same objects.
9
+ const SCHEMA = {
10
+ templateTypes: ['blank', 'showcase'],
11
+ featuresConfigKeys: ['network', 'networkInspector', 'networkBaseUrl', 'theming', 'multiLang', 'dataStore', 'detekt', 'detektYamlContent', 'purchases', 'ads', 'room'],
12
+ dependencyRules: {
13
+ alwaysOn: ['koin', 'navigation'],
14
+ implies: { networkInspector: ['network'] }, // inspector => network
15
+ auto: { dataStore: ['theming', 'multiLang'] } // theming || multiLang => dataStore
16
+ },
17
+ // Top-level request options that apply to every template (featuresConfig is blank-only)
18
+ projectOptions: {
19
+ ci: { default: false, templates: ['blank', 'showcase'] }
20
+ },
21
+ // featuresConfig flag -> core modules it adds (for the anatomy preview tree).
22
+ // `requires` = all must be true; `requiresAny` = at least one true.
23
+ anatomy: {
24
+ always: ['androidApp', 'iosApp', 'shared', 'core/about', 'core/domain', 'core/image', 'core/logging', 'core/presentation', 'core/navigation', 'core/review', 'core/update', 'build-logic'],
25
+ alwaysNote: 'shared wires Koin (DI) + Navigation, which are always included; core/logging sets up Kermit logging, core/review asks for in-app reviews and core/about shows the version, legal links and licences in every project, core/update checks for app updates, core/image loads images with Coil.',
26
+ coreModules: {
27
+ 'core/network': { requires: ['network'], note: 'Ktor HTTP client; Coil network images' },
28
+ 'core/designsystem': { requiresAny: ['theming', 'multiLang'], note: 'theme (palette + KtTheme) and/or LocalStringResources' },
29
+ 'core/multilang': { requires: ['multiLang'], note: 'i18n / localization' },
30
+ 'core/database': { requires: ['dataStore'], note: 'DataStore persistence; with theming also the saved Light/Dark/System choice (DarkModeManager)' },
31
+ 'core/purchases': { requires: ['purchases'], note: 'offer / purchase / restore API, RevenueCat KMP (Test Store key: replace before you ship)' },
32
+ 'core/ads': { requires: ['ads'], note: 'AdMob banner/interstitial/rewarded + UMP/ATT consent (test IDs)' },
33
+ 'core/room': { requires: ['room'], note: 'Room KMP database (bundled SQLite): AppDatabase, DAOs, schemas/' }
34
+ },
35
+ // featuresConfig flag -> feature modules kept in a blank project (same requires/requiresAny rules).
36
+ featureModules: {
37
+ 'feature/monetization': { requiresAny: ['purchases', 'ads'], note: 'custom paywall (Compose) and/or ads demo' },
38
+ 'feature/notes': { requires: ['room'], note: 'Notes demo screen on Room (blank only; showcase uses Room for favorites)' }
39
+ },
40
+ detektDir: { requires: ['detekt'], path: 'detekt/' },
41
+ showcaseFeatures: ['feature/home', 'feature/monetization', 'feature/onboarding'],
42
+ customFeaturePath: 'feature/<name>',
43
+ customFeatureLayers: ['contract', 'data', 'domain', 'presentation']
44
+ },
45
+ // Deep links (<scheme>://<path>): the generator writes the scheme; '' opens the start screen and
46
+ // '<module>' each generated feature module
47
+ deepLinks: {
48
+ scheme: 'projectName, lower case',
49
+ showcase: ['user/{username}', 'settings'],
50
+ blank: ['', '<module>']
51
+ }
52
+ };
53
+
54
+ function configureValidator(limits = LIMITS) {
55
+ // The template's own package (and below) cannot be generated: the rename would hit itself
56
+ PV.configure({
57
+ minSdkMin: limits.minSdkMin, minSdkMax: limits.minSdkMax,
58
+ iosVersionMin: limits.iosVersionMin, iosVersionMax: limits.iosVersionMax,
59
+ maxModules: limits.maxModules,
60
+ reservedPackagePrefix: OLD_PACKAGE_NAME
61
+ });
62
+ }
63
+
64
+ configureValidator();
65
+
66
+ // serverLimits: the hosted generator's own facts (rate limit, body size); local generation has none
67
+ function contractFacts({ limits = LIMITS, serverLimits = null } = {}) {
68
+ configureValidator(limits);
69
+ return {
70
+ limits: {
71
+ minSdkMin: limits.minSdkMin,
72
+ minSdkMax: limits.minSdkMax,
73
+ iosVersionMin: limits.iosVersionMin,
74
+ iosVersionMax: limits.iosVersionMax,
75
+ maxModules: limits.maxModules,
76
+ ...(serverLimits || {})
77
+ },
78
+ maxLengths: {
79
+ projectName: PV.MAX_LENGTH_PROJECT_NAME,
80
+ appName: PV.MAX_LENGTH_APP_NAME,
81
+ packageName: PV.MAX_LENGTH_PACKAGE_NAME,
82
+ moduleName: PV.MAX_LENGTH_MODULE_NAME,
83
+ minSdk: PV.MAX_LENGTH_MIN_SDK,
84
+ iosVersion: PV.MAX_LENGTH_IOS_VERSION,
85
+ networkBaseUrl: PV.MAX_LENGTH_BASE_URL
86
+ },
87
+ regex: Object.fromEntries(Object.entries(PV.patterns).map(([k, re]) => [k, re.source])),
88
+ kotlinHardKeywords: [...PV.KOTLIN_HARD_KEYWORDS],
89
+ reservedModuleNames: [...PV.RESERVED_MODULE_NAMES],
90
+ reservedPackagePrefix: OLD_PACKAGE_NAME,
91
+ messages: { ...PV.messages },
92
+ ...SCHEMA
93
+ };
94
+ }
95
+
96
+ function stable(value) {
97
+ if (Array.isArray(value)) return value.map(stable);
98
+ if (value && typeof value === 'object') {
99
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, stable(value[k])]));
100
+ }
101
+ return value;
102
+ }
103
+
104
+ // Any product's contract: sha256 of the key-sorted JSON, first 12 hex characters
105
+ function hashContract(facts) {
106
+ return crypto.createHash('sha256').update(JSON.stringify(stable(facts))).digest('hex').slice(0, 12);
107
+ }
108
+
109
+ module.exports = { SCHEMA, contractFacts, hashContract, configureValidator };