create-octane-xplat 0.9.0 → 0.11.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 +18 -3
- package/index.mjs +106 -16
- package/package.json +8 -1
- package/scaffold.mjs +532 -0
- package/template/.agents/skills/xplat/SKILL.md +107 -38
- package/template/.agents/skills/xplat/references/components.md +172 -96
- package/template/.agents/skills/xplat/references/navigation.md +81 -31
- package/template/.agents/skills/xplat/references/platform.md +74 -39
- package/template/.agents/skills/xplat/references/styling.md +80 -27
- package/template/package.json +6 -5
- package/template/pnpm-workspace.yaml +36 -7
- package/template/src/App.tsrx +5 -5
- package/template/src/style.css +24 -6
- package/template/vite.config.native.mts +3 -2
package/scaffold.mjs
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
// Scaffold manifest + compose/apply shared by create-octane-xplat and the
|
|
2
|
+
// CLI's `xplat add`. template/package.json is the canonical store for dep
|
|
3
|
+
// versions and script bodies — targets name which keys they own; unclaimed
|
|
4
|
+
// files, deps, and scripts are part of the base scaffold. Keeping versions in
|
|
5
|
+
// template/package.json means scripts/bump-versions.mjs keeps working
|
|
6
|
+
// unchanged at release time.
|
|
7
|
+
import {
|
|
8
|
+
cpSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
16
|
+
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'template')
|
|
21
|
+
|
|
22
|
+
/** Scaffoldable targets. `files` entries are template-relative paths —
|
|
23
|
+
* directories copy recursively. `deps`/`devDeps`/`scripts` name keys whose
|
|
24
|
+
* values come from template/package.json. `typecheck` contributes one clause
|
|
25
|
+
* to the generated typecheck script. */
|
|
26
|
+
export const targets = {
|
|
27
|
+
web: {
|
|
28
|
+
label: 'Web (Vite)',
|
|
29
|
+
files: ['index.html', 'vite.config.ts', 'src/main.web.tsrx'],
|
|
30
|
+
scripts: ['dev', 'build'],
|
|
31
|
+
devDeps: ['@octanejs/vite-plugin'],
|
|
32
|
+
},
|
|
33
|
+
ios: {
|
|
34
|
+
label: 'iOS (NativeScript)',
|
|
35
|
+
requires: ['native-shared'],
|
|
36
|
+
files: ['App_Resources/iOS'],
|
|
37
|
+
scripts: ['dev:ios', 'build:ios'],
|
|
38
|
+
devDeps: ['@nativescript/ios'],
|
|
39
|
+
note: 'needs macOS + Xcode — `pnpm xplat doctor` checks the toolchain',
|
|
40
|
+
},
|
|
41
|
+
android: {
|
|
42
|
+
label: 'Android (NativeScript)',
|
|
43
|
+
requires: ['native-shared'],
|
|
44
|
+
files: ['App_Resources/Android'],
|
|
45
|
+
scripts: ['dev:android', 'build:android'],
|
|
46
|
+
devDeps: ['@nativescript/android'],
|
|
47
|
+
note: 'needs Android SDK + a compatible JDK — `pnpm xplat doctor` checks the toolchain',
|
|
48
|
+
},
|
|
49
|
+
// Shared NativeScript machinery pulled in by ios/android. Not user-
|
|
50
|
+
// selectable on its own. `patches` names the packages whose
|
|
51
|
+
// patchedDependencies entries in pnpm-workspace.yaml belong to this
|
|
52
|
+
// fragment — pnpm hard-fails installs on unused patch entries, so a
|
|
53
|
+
// scaffold must not carry patches for deps it doesn't install.
|
|
54
|
+
'native-shared': {
|
|
55
|
+
hidden: true,
|
|
56
|
+
patches: [
|
|
57
|
+
'@nativescript/core',
|
|
58
|
+
'@nativescript/types',
|
|
59
|
+
'css',
|
|
60
|
+
'@nativescript/vite',
|
|
61
|
+
'@nativescript-community/octane',
|
|
62
|
+
'@nativescript-community/vite-octane',
|
|
63
|
+
],
|
|
64
|
+
files: [
|
|
65
|
+
'nativescript.config.ts',
|
|
66
|
+
'vite.config.native.mts',
|
|
67
|
+
'tsconfig.native.json',
|
|
68
|
+
'references.d.ts',
|
|
69
|
+
'src/main.ts',
|
|
70
|
+
],
|
|
71
|
+
deps: [
|
|
72
|
+
'@nativescript-community/gesturehandler',
|
|
73
|
+
'@nativescript-community/octane',
|
|
74
|
+
'@nativescript-community/ui-drawer',
|
|
75
|
+
'@nativescript/core',
|
|
76
|
+
'@octane-xplat/effects',
|
|
77
|
+
'@octane-xplat/image',
|
|
78
|
+
'@valor/nativescript-websockets',
|
|
79
|
+
],
|
|
80
|
+
devDeps: [
|
|
81
|
+
'@nativescript-community/vite-octane',
|
|
82
|
+
'@nativescript/types',
|
|
83
|
+
'@nativescript/vite',
|
|
84
|
+
'nativescript',
|
|
85
|
+
],
|
|
86
|
+
typecheck: 'tsrx-tsc --noEmit -p tsconfig.native.json',
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const selectableTargets = Object.keys(targets).filter((id) => !targets[id].hidden)
|
|
91
|
+
|
|
92
|
+
/** `pnpm create` without --targets and `xplat add`'s prompt default. */
|
|
93
|
+
export const defaultTargets = ['web', 'ios', 'android']
|
|
94
|
+
|
|
95
|
+
const BASE_TYPECHECK = 'tsrx-tsc --noEmit'
|
|
96
|
+
|
|
97
|
+
// package.json keys every scaffolded app keeps regardless of target set —
|
|
98
|
+
// the targets' `scripts` lists own the rest.
|
|
99
|
+
const BASE_SCRIPTS = ['xplat', 'lint', 'lint:fix']
|
|
100
|
+
|
|
101
|
+
/** Expand `requires` edges, dependencies first. Throws on unknown ids. */
|
|
102
|
+
export function resolveTargets(ids) {
|
|
103
|
+
const resolved = []
|
|
104
|
+
const visit = (id) => {
|
|
105
|
+
const t = targets[id]
|
|
106
|
+
if (!t) {
|
|
107
|
+
throw new Error(`unknown target "${id}" — expected one of: ${selectableTargets.join(', ')}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const req of t.requires ?? []) {
|
|
111
|
+
visit(req)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!resolved.includes(id)) {
|
|
115
|
+
resolved.push(id)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const id of ids) {
|
|
120
|
+
visit(id)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return resolved
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const templateManifest = () => JSON.parse(readFileSync(join(TEMPLATE_DIR, 'package.json'), 'utf8'))
|
|
127
|
+
|
|
128
|
+
/** Which target claims a template-relative path (entries may be dirs). */
|
|
129
|
+
function ownerOf(path) {
|
|
130
|
+
for (const [id, t] of Object.entries(targets)) {
|
|
131
|
+
for (const entry of t.files) {
|
|
132
|
+
if (path === entry || path.startsWith(entry + '/')) {
|
|
133
|
+
return id
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function walk(dir, prefix = '') {
|
|
142
|
+
const out = []
|
|
143
|
+
for (const name of readdirSync(dir)) {
|
|
144
|
+
if (name === 'node_modules') {
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rel = prefix ? `${prefix}/${name}` : name
|
|
149
|
+
if (statSync(join(dir, name)).isDirectory()) {
|
|
150
|
+
out.push(...walk(join(dir, name), rel))
|
|
151
|
+
} else {
|
|
152
|
+
out.push(rel)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function pickKeys(source, names, label) {
|
|
160
|
+
const out = {}
|
|
161
|
+
for (const name of names) {
|
|
162
|
+
if (source[name] === undefined) {
|
|
163
|
+
throw new Error(`template/package.json has no ${label} entry "${name}"`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
out[name] = source[name]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The package.json a given target set produces. With every target selected
|
|
174
|
+
* this is identical to template/package.json.
|
|
175
|
+
*/
|
|
176
|
+
export function composeManifest(ids) {
|
|
177
|
+
const resolved = resolveTargets(ids)
|
|
178
|
+
const tpl = templateManifest()
|
|
179
|
+
const manifest = { ...tpl }
|
|
180
|
+
|
|
181
|
+
for (const section of ['dependencies', 'devDependencies']) {
|
|
182
|
+
const key = section === 'dependencies' ? 'deps' : 'devDeps'
|
|
183
|
+
const wanted = new Set()
|
|
184
|
+
for (const id of resolved) {
|
|
185
|
+
for (const name of targets[id][key] ?? []) {
|
|
186
|
+
wanted.add(name)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const next = {}
|
|
191
|
+
for (const [name, spec] of Object.entries(tpl[section] ?? {})) {
|
|
192
|
+
// Unclaimed entries are base; claimed entries follow their target.
|
|
193
|
+
const claimed = Object.keys(targets).some((id) => (targets[id][key] ?? []).includes(name))
|
|
194
|
+
|
|
195
|
+
if (!claimed || wanted.has(name)) {
|
|
196
|
+
next[name] = spec
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
manifest[section] = next
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const wantedScripts = new Set(BASE_SCRIPTS)
|
|
204
|
+
for (const id of resolved) {
|
|
205
|
+
for (const name of targets[id].scripts ?? []) {
|
|
206
|
+
wantedScripts.add(name)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const scripts = pickKeys(tpl.scripts ?? {}, [...wantedScripts].sort(), 'script')
|
|
211
|
+
const typecheck = [BASE_TYPECHECK]
|
|
212
|
+
for (const id of resolved) {
|
|
213
|
+
if (targets[id].typecheck) {
|
|
214
|
+
typecheck.push(targets[id].typecheck)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
scripts.typecheck = typecheck.join(' && ')
|
|
219
|
+
// Keep the template's key order: scripts sit where they were.
|
|
220
|
+
manifest.scripts = {}
|
|
221
|
+
for (const name of Object.keys(tpl.scripts ?? {})) {
|
|
222
|
+
if (name === 'typecheck' || wantedScripts.has(name)) {
|
|
223
|
+
manifest.scripts[name] = scripts[name]
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!resolved.includes('native-shared')) {
|
|
228
|
+
// "main" points at the native entry — meaningless without it.
|
|
229
|
+
delete manifest.main
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return manifest
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- pnpm-workspace.yaml patchedDependencies --------------------------------
|
|
236
|
+
// Patch entries look like ` octane@0.6.3: <path>` or
|
|
237
|
+
// ` "@nativescript/core@9.1.2": <path>`, each preceded by a comment block.
|
|
238
|
+
// Ownership comes from each target's `patches` list; unclaimed entries are
|
|
239
|
+
// base and ship in every scaffold.
|
|
240
|
+
const PATCH_ENTRY = /^\s+"?(@?[\w./-]+@[\w.-]+)"?:\s+\S+\s*$/
|
|
241
|
+
|
|
242
|
+
const patchPkg = (key) => key.replace(/@[^@]+$/, '')
|
|
243
|
+
|
|
244
|
+
// Split a pnpm-workspace.yaml into per-package patch blocks (leading comment
|
|
245
|
+
// lines included) so compose can drop entries and apply can transplant them.
|
|
246
|
+
function parsePatchEntries(lines) {
|
|
247
|
+
const entries = []
|
|
248
|
+
let inPatches = false
|
|
249
|
+
let pending = []
|
|
250
|
+
for (const line of lines) {
|
|
251
|
+
if (/^\S/.test(line)) {
|
|
252
|
+
inPatches = line.startsWith('patchedDependencies')
|
|
253
|
+
pending = []
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!inPatches) {
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const m = line.match(PATCH_ENTRY)
|
|
262
|
+
if (m) {
|
|
263
|
+
entries.push({ name: patchPkg(m[1]), comments: pending, line })
|
|
264
|
+
pending = []
|
|
265
|
+
} else {
|
|
266
|
+
pending.push(line)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return entries
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The pnpm-workspace.yaml a given target set produces. */
|
|
274
|
+
export function composeWorkspaceYaml(ids) {
|
|
275
|
+
const resolved = new Set(resolveTargets(ids))
|
|
276
|
+
const drop = new Set()
|
|
277
|
+
for (const [id, t] of Object.entries(targets)) {
|
|
278
|
+
if (!resolved.has(id)) {
|
|
279
|
+
for (const name of t.patches ?? []) {
|
|
280
|
+
drop.add(name)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const lines = readFileSync(join(TEMPLATE_DIR, 'pnpm-workspace.yaml'), 'utf8').split('\n')
|
|
286
|
+
const out = []
|
|
287
|
+
let inPatches = false
|
|
288
|
+
let pending = []
|
|
289
|
+
for (const line of lines) {
|
|
290
|
+
if (/^\S/.test(line)) {
|
|
291
|
+
out.push(...pending, line)
|
|
292
|
+
pending = []
|
|
293
|
+
inPatches = line.startsWith('patchedDependencies')
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!inPatches) {
|
|
298
|
+
out.push(line)
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const m = line.match(PATCH_ENTRY)
|
|
303
|
+
if (!m) {
|
|
304
|
+
pending.push(line) // comment/blank inside the section
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (drop.has(patchPkg(m[1]))) {
|
|
309
|
+
pending = []
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
out.push(...pending, line)
|
|
314
|
+
pending = []
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
out.push(...pending)
|
|
318
|
+
return out.join('\n')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Add the patch entries that `enabledIds` owns to the app's
|
|
323
|
+
* pnpm-workspace.yaml. Entries already present (by package name, any
|
|
324
|
+
* version) are kept. Returns true if the file changed.
|
|
325
|
+
*/
|
|
326
|
+
function ensurePatchEntries(appRoot, enabledIds) {
|
|
327
|
+
const needed = new Set()
|
|
328
|
+
for (const id of enabledIds) {
|
|
329
|
+
for (const name of targets[id].patches ?? []) {
|
|
330
|
+
needed.add(name)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!needed.size) {
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const yamlPath = join(appRoot, 'pnpm-workspace.yaml')
|
|
339
|
+
const appText = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf8') : ''
|
|
340
|
+
const present = new Set(parsePatchEntries(appText.split('\n')).map((e) => e.name))
|
|
341
|
+
const missing = [...needed].filter((name) => !present.has(name))
|
|
342
|
+
if (!missing.length) {
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const tplText = readFileSync(join(TEMPLATE_DIR, 'pnpm-workspace.yaml'), 'utf8')
|
|
347
|
+
const blocks = parsePatchEntries(tplText.split('\n'))
|
|
348
|
+
.filter((e) => missing.includes(e.name))
|
|
349
|
+
.map((e) => [...e.comments, e.line].join('\n'))
|
|
350
|
+
|
|
351
|
+
if (!appText.trim()) {
|
|
352
|
+
writeFileSync(yamlPath, composeWorkspaceYaml(enabledIds))
|
|
353
|
+
return true
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let next = appText
|
|
357
|
+
const lines = next.split('\n')
|
|
358
|
+
const sectionStart = lines.findIndex((l) => l.startsWith('patchedDependencies'))
|
|
359
|
+
if (sectionStart === -1) {
|
|
360
|
+
next = `${appText.trimEnd()}\npatchedDependencies:\n${blocks.join('\n')}\n`
|
|
361
|
+
} else {
|
|
362
|
+
// Insert before the next top-level key (or at EOF).
|
|
363
|
+
let insertAt = lines.length
|
|
364
|
+
for (let i = sectionStart + 1; i < lines.length; i++) {
|
|
365
|
+
if (/^\S/.test(lines[i])) {
|
|
366
|
+
insertAt = i
|
|
367
|
+
break
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
lines.splice(insertAt, 0, ...blocks.join('\n').split('\n'))
|
|
372
|
+
next = lines.join('\n')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
writeFileSync(yamlPath, next.endsWith('\n') ? next : next + '\n')
|
|
376
|
+
return true
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Write a scaffold for `ids` into `dir` (must not exist or be empty). */
|
|
380
|
+
export function composeTargets(ids, dir) {
|
|
381
|
+
const resolved = new Set(resolveTargets(ids))
|
|
382
|
+
mkdirSync(dir, { recursive: true })
|
|
383
|
+
|
|
384
|
+
for (const rel of walk(TEMPLATE_DIR)) {
|
|
385
|
+
if (rel === 'package.json' || rel === 'pnpm-workspace.yaml') {
|
|
386
|
+
continue // generated below
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const owner = ownerOf(rel)
|
|
390
|
+
if (owner && !resolved.has(owner)) {
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const dest = join(dir, rel === 'gitignore' ? '.gitignore' : rel)
|
|
395
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
396
|
+
cpSync(join(TEMPLATE_DIR, rel), dest, { recursive: true })
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify(composeManifest(ids), null, '\t') + '\n')
|
|
400
|
+
writeFileSync(join(dir, 'pnpm-workspace.yaml'), composeWorkspaceYaml(ids))
|
|
401
|
+
return [...resolved]
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const readJson = (file) => {
|
|
405
|
+
try {
|
|
406
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
407
|
+
} catch {
|
|
408
|
+
return null
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Sentinel-based enablement check, mirroring the CLI's targets.mjs. */
|
|
413
|
+
export function targetEnabled(appRoot, id) {
|
|
414
|
+
if (id === 'web') {
|
|
415
|
+
return (
|
|
416
|
+
existsSync(join(appRoot, 'vite.config.ts')) || existsSync(join(appRoot, 'vite.config.mts'))
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (id === 'native-shared') {
|
|
421
|
+
return existsSync(join(appRoot, 'nativescript.config.ts'))
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ios/android: declared runtime devDep is the canonical signal — App_Resources
|
|
425
|
+
// alone doesn't reach the bundler config.
|
|
426
|
+
const manifest = readJson(join(appRoot, 'package.json')) ?? {}
|
|
427
|
+
const declared = { ...manifest.dependencies, ...manifest.devDependencies }
|
|
428
|
+
const runtime = { ios: '@nativescript/ios', android: '@nativescript/android' }[id]
|
|
429
|
+
return runtime ? typeof declared[runtime] === 'string' : false
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Copy a template file/dir into the app unless the destination exists. */
|
|
433
|
+
function copyMissing(rel, appRoot, report) {
|
|
434
|
+
const src = join(TEMPLATE_DIR, rel)
|
|
435
|
+
const dest = join(appRoot, rel === 'gitignore' ? '.gitignore' : rel)
|
|
436
|
+
if (existsSync(dest)) {
|
|
437
|
+
report.skipped.push(rel)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
442
|
+
cpSync(src, dest, { recursive: true })
|
|
443
|
+
report.copied.push(rel)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Enable `id` (plus unmet `requires`) inside an existing app at `appRoot`.
|
|
448
|
+
* Additive only: existing files, scripts, and dep entries are skipped, never
|
|
449
|
+
* overwritten. Returns a report for the caller to render.
|
|
450
|
+
*/
|
|
451
|
+
export function applyTarget(appRoot, id) {
|
|
452
|
+
const manifestPath = join(appRoot, 'package.json')
|
|
453
|
+
const raw = readFileSync(manifestPath, 'utf8')
|
|
454
|
+
const manifest = JSON.parse(raw)
|
|
455
|
+
const tpl = templateManifest()
|
|
456
|
+
const report = {
|
|
457
|
+
enabled: [],
|
|
458
|
+
already: [],
|
|
459
|
+
copied: [],
|
|
460
|
+
skipped: [],
|
|
461
|
+
scriptsAdded: [],
|
|
462
|
+
scriptsSkipped: [],
|
|
463
|
+
depsAdded: [],
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let touched = false
|
|
467
|
+
for (const tid of resolveTargets([id])) {
|
|
468
|
+
if (targetEnabled(appRoot, tid)) {
|
|
469
|
+
report.already.push(tid)
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
report.enabled.push(tid)
|
|
474
|
+
const t = targets[tid]
|
|
475
|
+
for (const rel of t.files) {
|
|
476
|
+
copyMissing(rel, appRoot, report)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
for (const [section, key] of [
|
|
480
|
+
['dependencies', 'deps'],
|
|
481
|
+
['devDependencies', 'devDeps'],
|
|
482
|
+
]) {
|
|
483
|
+
for (const name of t[key] ?? []) {
|
|
484
|
+
manifest[section] ??= {}
|
|
485
|
+
if (manifest[section][name] === undefined) {
|
|
486
|
+
manifest[section][name] = tpl[section][name]
|
|
487
|
+
report.depsAdded.push(name)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
manifest.scripts ??= {}
|
|
493
|
+
for (const name of t.scripts ?? []) {
|
|
494
|
+
if (manifest.scripts[name] === undefined) {
|
|
495
|
+
manifest.scripts[name] = tpl.scripts[name]
|
|
496
|
+
report.scriptsAdded.push(name)
|
|
497
|
+
} else if (manifest.scripts[name] !== tpl.scripts[name]) {
|
|
498
|
+
report.scriptsSkipped.push(name)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (t.typecheck) {
|
|
503
|
+
const current = manifest.scripts.typecheck ?? ''
|
|
504
|
+
if (!current.includes(t.typecheck)) {
|
|
505
|
+
manifest.scripts.typecheck = current ? `${current} && ${t.typecheck}` : t.typecheck
|
|
506
|
+
report.scriptsAdded.push('typecheck')
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (tid === 'native-shared' && manifest.main === undefined) {
|
|
511
|
+
manifest.main = tpl.main
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
touched = true
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Newly enabled targets may carry patch entries the scaffolded
|
|
518
|
+
// pnpm-workspace.yaml never had — without them the next install misses the
|
|
519
|
+
// framework patches. enabled+already covers the yaml-missing case, where
|
|
520
|
+
// the file is regenerated for the whole enabled set.
|
|
521
|
+
if (ensurePatchEntries(appRoot, [...report.enabled, ...report.already])) {
|
|
522
|
+
report.patchesAdded = true
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (touched) {
|
|
526
|
+
// Match the file's existing indent so diffs stay minimal.
|
|
527
|
+
const indent = raw.match(/\n(\s+)"/)?.[1] ?? '\t'
|
|
528
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, indent) + '\n')
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return report
|
|
532
|
+
}
|
|
@@ -12,53 +12,122 @@ below prevent that; `pnpm lint` enforces most of them.
|
|
|
12
12
|
|
|
13
13
|
## Non-negotiables
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
15
|
+
**One element vocabulary per file.** Shared code renders only
|
|
16
|
+
`@octane-xplat/ui` components. Platform divergence lives in whole files
|
|
17
|
+
— `.web` for browser code, `.mobile` for shared iOS/Android variants, OS-specific suffixes, and the unsuffixed native default —
|
|
18
|
+
chosen by the bundler when something imports `./Foo`. Never branch on
|
|
19
|
+
platform inside JSX (`Platform.OS`, conditional imports, checks for browser globals). Platform-authentic widgets live behind
|
|
20
|
+
`@octane-xplat/ui/ios`, `/android`, and `/web` — importing a subpath
|
|
21
|
+
outside a matching suffix file fails the other platform's build (the
|
|
22
|
+
`xplat/platform-subpath-import` lint enforces it).
|
|
23
|
+
**No DOM globals in shared code.** `document`, `window`, `localStorage`,
|
|
24
|
+
DOM events — all web-only. Device capabilities (clipboard, storage,
|
|
25
|
+
permissions, connectivity) come from
|
|
26
|
+
`@octane-xplat/platform`; plugin-backed features use their leaf packages.
|
|
27
|
+
**Styles:** `className` for anything static; `style` objects only for
|
|
28
|
+
values that change while the app runs. Shared CSS must use the portable
|
|
29
|
+
subset — the native build warns on declarations it drops
|
|
30
|
+
(`position: fixed`, `margin: auto`, `box-shadow`, …).
|
|
31
|
+
**Hooks and JSX live in `.tsrx` (or `.tsx`).** Plain `.ts` is for
|
|
32
|
+
non-component code and may not import `.tsrx`. Import `.tsrx` files with
|
|
33
|
+
the explicit extension: `import { App } from './App.tsrx'`.
|
|
34
|
+
**Keep signal names suffixed with `$`.** Use `useSignal$` for state owned
|
|
35
|
+
by a component. Runtime signal imports let the compiler track reads.
|
|
36
|
+
|
|
37
|
+
```tsrx
|
|
38
|
+
import { useSignal$ } from 'octane/signals/client'
|
|
39
|
+
import { Pressable, Text } from '@octane-xplat/ui'
|
|
40
|
+
|
|
41
|
+
export function Counter() {
|
|
42
|
+
const count$ = useSignal$(0)
|
|
43
|
+
return <Pressable onPress={() => count$.set((value) => value + 1)}>
|
|
44
|
+
<Text>{count$.get()}</Text>
|
|
45
|
+
</Pressable>
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Use `signal$` for shared module state and `derived$` for a computed value.
|
|
50
|
+
Write from event handlers, not while rendering.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { signal$, derived$ } from 'octane/signals'
|
|
54
|
+
|
|
55
|
+
export const travelers$ = signal$(1)
|
|
56
|
+
export const seatsRemaining$ = derived$(() => 4 - travelers$.get())
|
|
57
|
+
export function addTraveler() {
|
|
58
|
+
travelers$.set((count) => count + 1)
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`query$` loads asynchronous data. A screen-owned query keeps its selection
|
|
63
|
+
local to the screen. `.latest()` reads without suspending; use the data
|
|
64
|
+
guide's boundary pattern when reading with `.get()`.
|
|
65
|
+
|
|
66
|
+
```tsrx
|
|
67
|
+
import { query$ } from 'octane/signals'
|
|
68
|
+
import { Text } from '@octane-xplat/ui'
|
|
69
|
+
|
|
70
|
+
export function TripTitle(props: { id: string; loadTitle: (id: string) => Promise<string> }) {
|
|
71
|
+
const title$ = query$(() => props.id, (id) => props.loadTitle(id))
|
|
72
|
+
return <Text>{title$.latest() ?? 'Loading trip…'}</Text>
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**One `octane` per app.** Don't add a second renderer or duplicate the
|
|
77
|
+
package — two copies break the reconciler without a helpful error.
|
|
78
|
+
|
|
79
|
+
For component, routing, service, and styling usage, use the references below.
|
|
80
|
+
They show the supported names and matching code examples.
|
|
47
81
|
|
|
48
82
|
## Compiler rules that surprise
|
|
49
83
|
|
|
50
84
|
- `.tsrx` files can't declare `async` top-level functions — the compiler
|
|
51
85
|
treats them as async components and the native build fails.
|
|
52
|
-
- Literal `@{` in JSX text parses as a code block.
|
|
53
|
-
|
|
86
|
+
- Literal `@{` in JSX text parses as a code block. Render the sign separately:
|
|
87
|
+
|
|
88
|
+
```tsrx
|
|
89
|
+
import { Text } from '@octane-xplat/ui'
|
|
90
|
+
|
|
91
|
+
export function Handle() { return <Text>{'@'}{'traveler'}</Text> }
|
|
92
|
+
```
|
|
93
|
+
|
|
54
94
|
- Effect dependencies are inferred from closure reads. An effect that only
|
|
55
95
|
writes (refs, DOM) and never reads its driving prop won't re-run —
|
|
56
|
-
declare
|
|
57
|
-
|
|
58
|
-
|
|
96
|
+
declare dependencies explicitly when needed:
|
|
97
|
+
|
|
98
|
+
```tsrx
|
|
99
|
+
import { useLayoutEffect } from 'octane'
|
|
100
|
+
import { Text } from '@octane-xplat/ui'
|
|
101
|
+
|
|
102
|
+
export function Progress(props: { percent: number }) {
|
|
103
|
+
useLayoutEffect(() => { console.log('Progress changed') }, [props.percent])
|
|
104
|
+
return <Text>{props.percent}%</Text>
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- Input handlers must be idempotent: store the supplied value rather than
|
|
109
|
+
incrementing a counter for each input event. Web may dispatch more than once.
|
|
110
|
+
|
|
111
|
+
```tsrx
|
|
112
|
+
import { useState } from 'octane'
|
|
113
|
+
import { TextInput } from '@octane-xplat/ui'
|
|
114
|
+
|
|
115
|
+
export function NameField() {
|
|
116
|
+
const [name, setName] = useState('')
|
|
117
|
+
return <TextInput value={name} onChange={setName} accessibilityLabel="Name" />
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
59
121
|
- `.mobile.tsrx`/`.ios.tsrx`/`.android.tsrx` files and unsuffixed native-default files containing JSX must
|
|
60
122
|
start with `/** @jsxImportSource @nativescript-community/octane */` on
|
|
61
|
-
line 1 — nothing may precede it
|
|
123
|
+
line 1 — nothing may precede it:
|
|
124
|
+
|
|
125
|
+
```tsrx
|
|
126
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
127
|
+
import { Text } from '@octane-xplat/ui'
|
|
128
|
+
|
|
129
|
+
export function NativeGreeting() { return <Text>Hello</Text> }
|
|
130
|
+
```
|
|
62
131
|
|
|
63
132
|
## Verify
|
|
64
133
|
|