craft-native 0.0.113 → 0.0.118
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/dist/android/src/index.js +6 -0
- package/dist/android/templates/AndroidManifest.xml.template +8 -0
- package/dist/android/templates/CraftBridge.kt.template +285 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +181 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +335 -0
- package/dist/android/templates/MainActivityNative.kt.template +493 -42
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +29 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +9 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -1
- package/dist/ios/src/index.js +2 -0
- package/dist/ios/templates/CraftApp.swift +221 -0
- package/dist/ios/templates/CraftNativeFlatList.swift +323 -0
- package/dist/ios/templates/CraftNativeMutation.swift +365 -0
- package/dist/ios/templates/CraftNativeScreen.swift +460 -31
- package/dist/mobile.js +58 -0
- package/dist/types.d.ts +81 -1
- package/package.json +1 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
|
|
6
|
+
const val CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION = 1
|
|
7
|
+
|
|
8
|
+
data class CraftNativeMutationFailure(
|
|
9
|
+
val code: String,
|
|
10
|
+
override val message: String,
|
|
11
|
+
val operationIndex: Int? = null
|
|
12
|
+
) : Exception(message)
|
|
13
|
+
|
|
14
|
+
data class CraftNativeMutationResult(
|
|
15
|
+
val batchId: String,
|
|
16
|
+
val revision: Int,
|
|
17
|
+
val document: JSONObject?,
|
|
18
|
+
val updatedNodeIds: List<String>,
|
|
19
|
+
val affectedNodeIds: List<String>,
|
|
20
|
+
val requiresFullRender: Boolean
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/** Validates a complete batch against a copy before committing any state. */
|
|
24
|
+
class CraftNativeMutationDocument {
|
|
25
|
+
private data class Node(
|
|
26
|
+
val id: String,
|
|
27
|
+
var type: String,
|
|
28
|
+
var props: JSONObject,
|
|
29
|
+
var style: JSONObject,
|
|
30
|
+
var events: JSONObject,
|
|
31
|
+
var text: MutableList<String>,
|
|
32
|
+
var children: MutableList<String>,
|
|
33
|
+
var parent: String?
|
|
34
|
+
) {
|
|
35
|
+
fun duplicate(): Node = copy(
|
|
36
|
+
props = JSONObject(props.toString()),
|
|
37
|
+
style = JSONObject(style.toString()),
|
|
38
|
+
events = JSONObject(events.toString()),
|
|
39
|
+
text = text.toMutableList(),
|
|
40
|
+
children = children.toMutableList()
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private var nodes = linkedMapOf<String, Node>()
|
|
45
|
+
private var rootId: String? = null
|
|
46
|
+
var revision: Int = 0
|
|
47
|
+
private set
|
|
48
|
+
|
|
49
|
+
fun replace(document: JSONObject) {
|
|
50
|
+
val replacement = linkedMapOf<String, Node>()
|
|
51
|
+
val root = importNode(document, "root", null, replacement)
|
|
52
|
+
nodes = replacement
|
|
53
|
+
rootId = root
|
|
54
|
+
revision = 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fun apply(payload: JSONObject): CraftNativeMutationResult {
|
|
58
|
+
val batchId = payload.optString("batchId")
|
|
59
|
+
if (batchId.isBlank()) fail("INVALID_BATCH", "batchId must be a non-empty string")
|
|
60
|
+
if (strictInteger(payload.opt("version")) != CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION) {
|
|
61
|
+
fail(
|
|
62
|
+
"UNSUPPORTED_VERSION",
|
|
63
|
+
"mutation protocol version must be $CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION"
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
if (strictInteger(payload.opt("baseRevision")) != revision) {
|
|
67
|
+
fail("REVISION_MISMATCH", "baseRevision must equal the current revision $revision")
|
|
68
|
+
}
|
|
69
|
+
val nextRevision = strictInteger(payload.opt("revision"))
|
|
70
|
+
if (nextRevision != revision + 1) {
|
|
71
|
+
fail("INVALID_REVISION", "revision must advance exactly once from $revision")
|
|
72
|
+
}
|
|
73
|
+
val operations = payload.opt("operations") as? JSONArray
|
|
74
|
+
if (operations == null || operations.length() == 0) {
|
|
75
|
+
fail("INVALID_BATCH", "operations must be a non-empty array")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
val candidateNodes = linkedMapOf<String, Node>().apply {
|
|
79
|
+
nodes.forEach { (id, node) -> put(id, node.duplicate()) }
|
|
80
|
+
}
|
|
81
|
+
var candidateRoot = rootId
|
|
82
|
+
val updatedNodeIds = mutableListOf<String>()
|
|
83
|
+
val affectedNodeIds = mutableListOf<String>()
|
|
84
|
+
var requiresFullRender = false
|
|
85
|
+
for (index in 0 until operations.length()) {
|
|
86
|
+
val operation = operations.opt(index) as? JSONObject
|
|
87
|
+
?: failAt("INVALID_OPERATION", "operation must be an object with an op string", index)
|
|
88
|
+
val name = operation.opt("op") as? String
|
|
89
|
+
if (name.isNullOrBlank()) {
|
|
90
|
+
failAt("INVALID_OPERATION", "operation must be an object with an op string", index)
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
when (name) {
|
|
94
|
+
"createNode" -> {
|
|
95
|
+
if (operation.opt("root") == true) requiresFullRender = true
|
|
96
|
+
candidateRoot = createNode(operation, candidateNodes, candidateRoot)
|
|
97
|
+
}
|
|
98
|
+
"updateNode" -> {
|
|
99
|
+
updateNode(operation, candidateNodes)
|
|
100
|
+
operation.optString("id").let {
|
|
101
|
+
if (it !in updatedNodeIds) updatedNodeIds.add(it)
|
|
102
|
+
if (it !in affectedNodeIds) affectedNodeIds.add(it)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
"insertChild" -> {
|
|
106
|
+
insertChild(operation, candidateNodes)
|
|
107
|
+
operation.optString("parentId").takeIf { it !in affectedNodeIds }?.let { affectedNodeIds.add(it) }
|
|
108
|
+
}
|
|
109
|
+
"moveChild" -> {
|
|
110
|
+
moveChild(operation, candidateNodes)
|
|
111
|
+
operation.optString("parentId").takeIf { it !in affectedNodeIds }?.let { affectedNodeIds.add(it) }
|
|
112
|
+
}
|
|
113
|
+
"removeNode" -> {
|
|
114
|
+
val parent = candidateNodes[operation.optString("id")]?.parent
|
|
115
|
+
if (parent == null) requiresFullRender = true
|
|
116
|
+
else if (parent !in affectedNodeIds) affectedNodeIds.add(parent)
|
|
117
|
+
candidateRoot = removeNode(operation, candidateNodes, candidateRoot)
|
|
118
|
+
}
|
|
119
|
+
else -> fail("UNKNOWN_OPERATION", "unsupported operation $name")
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (failure: CraftNativeMutationFailure) {
|
|
123
|
+
throw CraftNativeMutationFailure(failure.code, failure.message, index)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (candidateRoot == null && candidateNodes.isNotEmpty()) {
|
|
127
|
+
fail("MISSING_ROOT", "a non-empty document must have one root node")
|
|
128
|
+
}
|
|
129
|
+
if (candidateRoot != null) {
|
|
130
|
+
val root = candidateNodes[candidateRoot]
|
|
131
|
+
if (root == null || root.parent != null) {
|
|
132
|
+
fail("INVALID_TREE", "root node must not have a parent")
|
|
133
|
+
}
|
|
134
|
+
if (descendants(candidateRoot, candidateNodes).size + 1 != candidateNodes.size) {
|
|
135
|
+
fail("INVALID_TREE", "all nodes must be reachable from the root")
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
nodes = candidateNodes
|
|
140
|
+
rootId = candidateRoot
|
|
141
|
+
revision = nextRevision!!
|
|
142
|
+
return CraftNativeMutationResult(
|
|
143
|
+
batchId,
|
|
144
|
+
revision,
|
|
145
|
+
candidateRoot?.let { materialize(it, candidateNodes) },
|
|
146
|
+
updatedNodeIds,
|
|
147
|
+
affectedNodeIds,
|
|
148
|
+
requiresFullRender
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fun node(id: String): JSONObject? = materialize(id, nodes)
|
|
153
|
+
|
|
154
|
+
private fun createNode(
|
|
155
|
+
operation: JSONObject,
|
|
156
|
+
nodes: MutableMap<String, Node>,
|
|
157
|
+
root: String?
|
|
158
|
+
): String? {
|
|
159
|
+
val id = requiredId(operation, "id", "createNode")
|
|
160
|
+
if (nodes.containsKey(id)) fail("DUPLICATE_NODE", "node $id already exists")
|
|
161
|
+
val value = operation.opt("node") as? JSONObject
|
|
162
|
+
?: fail("INVALID_NODE", "createNode.node.type must be a non-empty string")
|
|
163
|
+
val type = value.opt("type") as? String
|
|
164
|
+
if (type.isNullOrBlank()) fail("INVALID_NODE", "createNode.node.type must be a non-empty string")
|
|
165
|
+
val makeRoot = operation.opt("root") == true
|
|
166
|
+
if (makeRoot && root != null) fail("ROOT_EXISTS", "the document already has a root node")
|
|
167
|
+
nodes[id] = Node(
|
|
168
|
+
id,
|
|
169
|
+
type,
|
|
170
|
+
dictionary(value.opt("props")),
|
|
171
|
+
dictionary(value.opt("style")),
|
|
172
|
+
dictionary(value.opt("events")),
|
|
173
|
+
textChildren(if (value.has("children")) value.opt("children") else value.opt("text")),
|
|
174
|
+
mutableListOf(),
|
|
175
|
+
null
|
|
176
|
+
)
|
|
177
|
+
return if (makeRoot) id else root
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private fun updateNode(operation: JSONObject, nodes: MutableMap<String, Node>) {
|
|
181
|
+
val id = requiredId(operation, "id", "updateNode")
|
|
182
|
+
val node = nodes[id] ?: fail("UNKNOWN_NODE", "node $id does not exist")
|
|
183
|
+
val patch = operation.opt("patch") as? JSONObject
|
|
184
|
+
?: fail("INVALID_PATCH", "updateNode.patch must be an object")
|
|
185
|
+
val allowed = setOf("props", "style", "events", "children", "text")
|
|
186
|
+
val unknown = patch.keys().asSequence().filter { it !in allowed }.sorted().firstOrNull()
|
|
187
|
+
if (unknown != null) fail("INVALID_PATCH", "unsupported patch field $unknown")
|
|
188
|
+
if (patch.has("props")) node.props = dictionary(patch.opt("props"))
|
|
189
|
+
if (patch.has("style")) node.style = dictionary(patch.opt("style"))
|
|
190
|
+
if (patch.has("events")) node.events = dictionary(patch.opt("events"))
|
|
191
|
+
if (patch.has("children") || patch.has("text")) {
|
|
192
|
+
node.text = textChildren(if (patch.has("children")) patch.opt("children") else patch.opt("text"))
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private fun insertChild(operation: JSONObject, nodes: MutableMap<String, Node>) {
|
|
197
|
+
val parentId = requiredId(operation, "parentId", "insertChild")
|
|
198
|
+
val childId = requiredId(operation, "childId", "insertChild")
|
|
199
|
+
val parent = nodes[parentId] ?: fail("UNKNOWN_PARENT", "parent node $parentId does not exist")
|
|
200
|
+
val child = nodes[childId] ?: fail("UNKNOWN_NODE", "node $childId does not exist")
|
|
201
|
+
if (!acceptsChildren(parent.type)) fail("INVALID_PARENT", "node $parentId cannot contain child nodes")
|
|
202
|
+
if (child.parent != null) fail("NODE_ATTACHED", "node $childId already has a parent")
|
|
203
|
+
if (parentId == childId || parentId in descendants(childId, nodes)) {
|
|
204
|
+
fail("CYCLE", "inserting node $childId would create a cycle")
|
|
205
|
+
}
|
|
206
|
+
val index = insertionIndex(operation, parent.children.size)
|
|
207
|
+
parent.children.add(index, childId)
|
|
208
|
+
child.parent = parentId
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun moveChild(operation: JSONObject, nodes: MutableMap<String, Node>) {
|
|
212
|
+
val parentId = requiredId(operation, "parentId", "moveChild")
|
|
213
|
+
val childId = requiredId(operation, "childId", "moveChild")
|
|
214
|
+
val parent = nodes[parentId] ?: fail("UNKNOWN_PARENT", "parent node $parentId does not exist")
|
|
215
|
+
val oldIndex = parent.children.indexOf(childId)
|
|
216
|
+
if (nodes[childId]?.parent != parentId || oldIndex < 0) {
|
|
217
|
+
fail("NOT_A_CHILD", "node $childId is not a child of $parentId")
|
|
218
|
+
}
|
|
219
|
+
parent.children.removeAt(oldIndex)
|
|
220
|
+
parent.children.add(insertionIndex(operation, parent.children.size), childId)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private fun removeNode(
|
|
224
|
+
operation: JSONObject,
|
|
225
|
+
nodes: MutableMap<String, Node>,
|
|
226
|
+
root: String?
|
|
227
|
+
): String? {
|
|
228
|
+
val id = requiredId(operation, "id", "removeNode")
|
|
229
|
+
val node = nodes[id] ?: fail("UNKNOWN_NODE", "node $id does not exist")
|
|
230
|
+
node.parent?.let { nodes[it]?.children?.remove(id) }
|
|
231
|
+
(descendants(id, nodes) + id).forEach { nodes.remove(it) }
|
|
232
|
+
return if (root == id) null else root
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private fun requiredId(operation: JSONObject, field: String, name: String): String {
|
|
236
|
+
val id = operation.opt(field) as? String
|
|
237
|
+
if (id.isNullOrBlank()) fail("INVALID_NODE", "$name.$field must be a non-empty string")
|
|
238
|
+
return id
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private fun insertionIndex(operation: JSONObject, count: Int): Int {
|
|
242
|
+
val index = strictInteger(operation.opt("index"))
|
|
243
|
+
if (index == null || index < 0 || index > count) {
|
|
244
|
+
fail("INVALID_INDEX", "index must be between 0 and $count")
|
|
245
|
+
}
|
|
246
|
+
return index
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private fun textChildren(value: Any?): MutableList<String> {
|
|
250
|
+
if (value == null || value == JSONObject.NULL) return mutableListOf()
|
|
251
|
+
if (value is String) return mutableListOf(value)
|
|
252
|
+
val values = value as? JSONArray
|
|
253
|
+
?: fail("INVALID_NODE", "node children may contain text only; use insertChild for node relationships")
|
|
254
|
+
val result = mutableListOf<String>()
|
|
255
|
+
for (index in 0 until values.length()) {
|
|
256
|
+
val text = values.opt(index) as? String
|
|
257
|
+
?: fail("INVALID_NODE", "node children may contain text only; use insertChild for node relationships")
|
|
258
|
+
result.add(text)
|
|
259
|
+
}
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private fun acceptsChildren(type: String): Boolean =
|
|
264
|
+
type == "View" || type == "SafeAreaView" || type == "ScrollView"
|
|
265
|
+
|
|
266
|
+
private fun descendants(id: String, nodes: Map<String, Node>): Set<String> {
|
|
267
|
+
val result = linkedSetOf<String>()
|
|
268
|
+
nodes[id]?.children?.forEach { child ->
|
|
269
|
+
result.add(child)
|
|
270
|
+
result.addAll(descendants(child, nodes))
|
|
271
|
+
}
|
|
272
|
+
return result
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private fun materialize(id: String, nodes: Map<String, Node>): JSONObject? {
|
|
276
|
+
val node = nodes[id] ?: return null
|
|
277
|
+
val children = JSONArray()
|
|
278
|
+
node.text.forEach { children.put(it) }
|
|
279
|
+
node.children.forEach { child -> materialize(child, nodes)?.let { children.put(it) } }
|
|
280
|
+
return JSONObject()
|
|
281
|
+
.put("id", node.id)
|
|
282
|
+
.put("type", node.type)
|
|
283
|
+
.put("props", node.props)
|
|
284
|
+
.put("style", node.style)
|
|
285
|
+
.put("events", node.events)
|
|
286
|
+
.put("children", children)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private fun importNode(
|
|
290
|
+
value: JSONObject,
|
|
291
|
+
preferredId: String,
|
|
292
|
+
parent: String?,
|
|
293
|
+
nodes: MutableMap<String, Node>
|
|
294
|
+
): String {
|
|
295
|
+
val props = dictionary(value.opt("props"))
|
|
296
|
+
val explicit = (value.opt("id") as? String)?.takeIf { it.isNotBlank() }
|
|
297
|
+
val key = listOf(value.opt("key"), props.opt("key"), props.opt("testID"))
|
|
298
|
+
.filterIsInstance<String>().firstOrNull { it.isNotBlank() }
|
|
299
|
+
var id = explicit ?: key?.let { "${parent ?: "root"}/key:$it" } ?: preferredId
|
|
300
|
+
if (nodes.containsKey(id)) id = "$preferredId#${nodes.size}"
|
|
301
|
+
val children = value.optJSONArray("children") ?: JSONArray()
|
|
302
|
+
val node = Node(
|
|
303
|
+
id,
|
|
304
|
+
value.optString("type", "View"),
|
|
305
|
+
props,
|
|
306
|
+
dictionary(value.opt("style")),
|
|
307
|
+
dictionary(value.opt("events")),
|
|
308
|
+
mutableListOf(),
|
|
309
|
+
mutableListOf(),
|
|
310
|
+
parent
|
|
311
|
+
)
|
|
312
|
+
nodes[id] = node
|
|
313
|
+
for (index in 0 until children.length()) {
|
|
314
|
+
when (val child = children.opt(index)) {
|
|
315
|
+
is String -> node.text.add(child)
|
|
316
|
+
is JSONObject -> node.children.add(importNode(child, "$id/index:$index", id, nodes))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return id
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private fun dictionary(value: Any?): JSONObject =
|
|
323
|
+
if (value is JSONObject) JSONObject(value.toString()) else JSONObject()
|
|
324
|
+
|
|
325
|
+
private fun strictInteger(value: Any?): Int? {
|
|
326
|
+
if (value !is Number) return null
|
|
327
|
+
val integer = value.toInt()
|
|
328
|
+
return integer.takeIf { value.toDouble() == integer.toDouble() }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private fun fail(code: String, message: String): Nothing = throw CraftNativeMutationFailure(code, message)
|
|
332
|
+
|
|
333
|
+
private fun failAt(code: String, message: String, index: Int): Nothing =
|
|
334
|
+
throw CraftNativeMutationFailure(code, message, index)
|
|
335
|
+
}
|