rork-xcode 0.1.0 → 0.3.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/rorkai/rork-xcode/actions/workflows/ci.yml/badge.svg)](https://github.com/rorkai/rork-xcode/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/rork-xcode)](https://www.npmjs.com/package/rork-xcode)
5
5
 
6
- The [fastest](#performance) zero-dependency Xcode project (`project.pbxproj`) parser and builder for any JavaScript runtime: browsers, Node.js, Bun, Electron, Cloudflare Workers, and React Native.
6
+ The [fastest](#performance) zero-dependency Xcode project (`project.pbxproj`) parser, builder, and object model for any JavaScript runtime: browsers, Node.js, Bun, Electron, Cloudflare Workers, and React Native. [Scheme files](#schemes) (`.xcscheme`) are covered with the same round-trip guarantees.
7
7
 
8
8
  ```ts
9
9
  import { parsePbxproj, buildPbxproj } from "rork-xcode";
@@ -85,6 +85,183 @@ try {
85
85
 
86
86
  Booleans are rejected on purpose. The format has no boolean notation (Xcode models flags as the strings `"YES"` and `"NO"`), so writing one would produce a value Xcode misreads.
87
87
 
88
+ ## Object model
89
+
90
+ `XcodeProject` gives typed, mutable access to a parsed project. It is a set of lightweight views over the plain parsed document: all state lives in the document itself, a view holds only an object id, and `build()` serializes whatever the document currently says in Xcode's canonical layout. Model calls and direct dictionary access compose freely, and the model adds no measurable overhead over the raw functions (`XcodeProject.parse` and `project.build()` benchmark identically to `parsePbxproj` and `buildPbxproj`).
91
+
92
+ This document-first design is deliberate, and the library's guarantees fall out of it. There is no inflate step on parse and no deflate step on build, so an untouched project rebuilds byte-identically and an edit changes only the entries it touches. Combined with deterministic identifiers, the same edit sequence produces the same bytes on every run, on every runtime.
93
+
94
+ ```ts
95
+ import { XcodeProject } from "rork-xcode";
96
+
97
+ const project = XcodeProject.parse(pbxprojText);
98
+ const text = project.build();
99
+ ```
100
+
101
+ ### Targets and build settings
102
+
103
+ `getBuildSetting` resolves hierarchically the way Xcode does: the target's default configuration first, then the project-level configuration. Writes go to every configuration of the target, so Debug and Release stay consistent.
104
+
105
+ ```ts
106
+ const app = project.findMainAppTarget("ios"); // "macos" | "tvos" | "watchos" | "visionos"
107
+ app?.getBuildSetting("PRODUCT_BUNDLE_IDENTIFIER"); // "com.example.app"
108
+ app?.getBuildSetting("SDKROOT"); // inherited from the project configuration
109
+ app?.setBuildSetting("MARKETING_VERSION", "1.2.0");
110
+ app?.removeBuildSetting("CODE_SIGN_IDENTITY");
111
+
112
+ for (const target of project.nativeTargets()) {
113
+ console.log(target.name, target.productType);
114
+ }
115
+ ```
116
+
117
+ `project.targets()` returns every target kind. Aggregate targets (`PBXAggregateTarget`) and legacy external-build-tool targets (`PBXLegacyTarget`) share the full target surface, from configurations and build settings to phases and dependency wiring.
118
+
119
+ ```ts
120
+ for (const target of project.targets()) {
121
+ if (target instanceof LegacyTarget) {
122
+ console.log(target.name, target.buildToolPath);
123
+ }
124
+ }
125
+ ```
126
+
127
+ ### Scaffolding a target
128
+
129
+ `addNativeTarget` creates the configurations, the product reference in the Products group, and the standard Sources, Frameworks, and Resources phases. Dependencies wire the container proxy pair Xcode uses; `embed` picks the right copy-files phase and destination for the product type (foundation extensions, ExtensionKit extensions, App Clips, watch content).
130
+
131
+ ```ts
132
+ import { ProductType } from "rork-xcode";
133
+
134
+ const widget = project.addNativeTarget({
135
+ name: "DemoWidget",
136
+ productType: ProductType.appExtension,
137
+ buildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "com.example.app.widget" },
138
+ });
139
+ widget.setBuildSetting("IPHONEOS_DEPLOYMENT_TARGET", "18.0");
140
+
141
+ app.addDependency(widget);
142
+ app.embed(widget); // "Embed Foundation Extensions", dstSubfolderSpec 13
143
+
144
+ // Xcode 16 synchronized folder, with the scaffolded Info.plist excluded
145
+ // so the build does not copy it twice.
146
+ const folder = widget.addSyncGroup("DemoWidget");
147
+ folder.addMembershipExceptions(widget, ["Info.plist"]);
148
+ ```
149
+
150
+ ### Swift packages
151
+
152
+ ```ts
153
+ const pkg = project.addSwiftPackage({
154
+ repositoryURL: "https://github.com/example/example-kit",
155
+ requirement: { kind: "upToNextMajorVersion", minimumVersion: "2.0.0" },
156
+ });
157
+
158
+ // Wires the product dependency and its Frameworks-phase build file.
159
+ app.addSwiftPackageProduct({ productName: "ExampleKit", packageReference: pkg });
160
+
161
+ // Local (path-based) packages and system frameworks work the same way.
162
+ const local = project.addLocalSwiftPackage("Packages/DesignSystem");
163
+ app.addSwiftPackageProduct({ productName: "DesignSystem", packageReference: local });
164
+ app.addSystemFramework("Messages");
165
+ ```
166
+
167
+ ### Files, groups, and phases
168
+
169
+ ```ts
170
+ import { Isa } from "rork-xcode";
171
+
172
+ // Classic (non-synchronized) file management, with nested group creation.
173
+ const mainGroup = project.rootProject.mainGroup();
174
+ const generated = mainGroup?.ensureGroup("Sources/Generated");
175
+ const file = generated?.createFile("Config.swift");
176
+ if (file) app.ensureSourcesPhase().ensureBuildFile(file);
177
+
178
+ project.findFileReference("Sources/Generated/Config.swift"); // resolves through the group tree
179
+
180
+ // Phases expose their build files for reorganization, and script phases
181
+ // create with the usual defaults.
182
+ const embedPhase = app.findBuildPhase(Isa.copyFilesBuildPhase, "Embed Foundation Extensions");
183
+ embedPhase?.buildFileIds;
184
+ app.ensureShellScriptPhase("Lint", { shellScript: "lint\n" });
185
+ ```
186
+
187
+ ### Validation
188
+
189
+ Projects rot over time. References outlive the objects they pointed at, orphans pile up, an entry loses its `isa`. `validate()` finds these problems and returns them as data. `pruneOrphans()` deletes everything the root object cannot reach, and it errs on the safe side. If anything still references an object, it stays.
190
+
191
+ ```ts
192
+ for (const issue of project.validate()) {
193
+ console.warn(issue.kind, issue.message);
194
+ }
195
+ project.pruneOrphans(); // returns the removed ids
196
+ ```
197
+
198
+ ### Removal
199
+
200
+ `removeObject` deletes one object and strips every reference to it from the rest of the document. `removeTarget` composes it into a full teardown: the target's phases and build files, configurations, product reference and its embeddings, dependency objects other targets hold on it, membership exceptions, and synchronized folders no remaining target links. On-disk sources are untouched.
201
+
202
+ ```ts
203
+ const widget = project.findTarget("DemoWidget");
204
+ if (widget) project.removeTarget(widget);
205
+
206
+ project.referrersOf(app.id); // every object referencing an id, for custom teardowns
207
+ ```
208
+
209
+ ### Escape hatch
210
+
211
+ Every view exposes its raw dictionary, so anything the typed surface does not cover stays one property away, and `project.objects()` iterates every object with its typed view:
212
+
213
+ ```ts
214
+ app.properties["productName"] = "RenamedApp";
215
+
216
+ for (const [id, object] of project.objects()) {
217
+ if (object.isa === "PBXShellScriptBuildPhase") {
218
+ console.log(id, object.getString("name"));
219
+ }
220
+ }
221
+ ```
222
+
223
+ ### Semantics
224
+
225
+ - **Typed vocabulary, generic fallback.** The typed views cover targets of every kind, groups and variant groups, Xcode 16 synchronized folders and their exception sets, build phases and build rules, Core Data version groups, and cross-project reference proxies. Every other kind is a generic `XcodeObject` with the same read and write access, so nothing in a document is out of reach.
226
+ - **Typed, open property shapes.** Known keys autocomplete (`target.properties.productType`) and the shape stays open, so keys like `INFOPLIST_KEY_*` settings remain first-class. The shapes describe well-formed documents. When reading untrusted input, use the narrowing accessors, which never trust them.
227
+ - **Two verb families.** `add*` wires something to its owner (a dependency, a package, a framework, a synchronized folder) and is idempotent: re-adding returns the existing wiring. `ensure*` returns a structural container, creating it when missing (a build phase, a group chain, the Products group). Both families can therefore run unconditionally in scaffold and repair flows.
228
+ - **Deterministic identifiers.** New objects get ids derived from what they are (`XX` + 20 digest characters + `XX`, from an embedded hash), so programmatic edits are reproducible run to run and diffs stay minimal. Collisions within a document resolve deterministically, and identical edit sequences produce byte-identical documents.
229
+ - **Soft reads, loud writes.** Real-world projects can be malformed, so lookups return `undefined` where a document could omit something. Operations that cannot proceed without structure (no root project object, an unknown product type, a view whose object was deleted) throw `XcodeModelError`.
230
+ - **Identity-mapped views.** Two lookups of the same id return the same instance, so views compare with `===`.
231
+
232
+ ## Schemes
233
+
234
+ `.xcscheme` files describe how Xcode builds, runs, tests, and archives a target. They are not property lists but a small XML dialect of their own, and `parseXcscheme` and `buildXcscheme` cover it with the same contract as the pbxproj functions: an Xcode-written scheme rebuilds byte for byte, any other input reaches Xcode's canonical layout in one build, and malformed input fails with a typed error carrying line and column.
235
+
236
+ ```ts
237
+ import { buildXcscheme, parseXcscheme, xcschemeElements } from "rork-xcode";
238
+
239
+ const scheme = parseXcscheme(xcschemeText);
240
+
241
+ // The tree is plain data: elements with ordered attributes and children.
242
+ for (const reference of xcschemeElements(scheme.root, "BuildableReference")) {
243
+ reference.attributes["BlueprintName"] = "RenamedApp";
244
+ reference.attributes["BuildableName"] = "RenamedApp.app";
245
+ reference.attributes["ReferencedContainer"] = "container:RenamedApp.xcodeproj";
246
+ }
247
+
248
+ const text = buildXcscheme(scheme);
249
+ ```
250
+
251
+ `createXcscheme` produces the scheme Xcode's own "New Scheme" action writes for an application target, wired to the target's object id from the project document:
252
+
253
+ ```ts
254
+ import { createXcscheme } from "rork-xcode";
255
+
256
+ const scheme = createXcscheme({
257
+ appName: "DemoApp",
258
+ blueprintIdentifier: app.id,
259
+ });
260
+ const text = buildXcscheme(scheme);
261
+ ```
262
+
263
+ Attribute order is preserved and meaningful: the writer emits attributes in insertion order, which is how byte-identical round-trips fall out. Comments are kept, attribute values resolve the character references Xcode writes (`"`, `&`, `
` and friends), and the writer re-escapes them identically.
264
+
88
265
  ## Performance
89
266
 
90
267
  `rork-xcode` is measured against the pbxproj parsers on npm, [`@bacons/xcode`](https://www.npmjs.com/package/@bacons/xcode) (its `/json` parse/build entry point) and [`xcode`](https://www.npmjs.com/package/xcode) (the long-standing package used by native build tooling), on three documents: two real Xcode-written projects from the test suite and a deterministically generated five-target app with 800 source files. It is the fastest at both operations on every document, with zero dependencies.
@@ -116,9 +293,18 @@ Measured on an Apple M5 Max, Node.js 24, single thread, with `@bacons/xcode` 1.0
116
293
  - The committed fixture corpus spans project generations from Xcode 3 to Xcode 16, captured from real projects with identifiers neutralized: synchronized folders with both exception-set kinds, classic groups, variant groups, aggregate and legacy targets, reference proxies, build rules, Swift packages, and a ~100 KiB multiplatform framework project.
117
294
  - Documents already in current Xcode's layout must round-trip byte for byte; documents from other tool generations must normalize to a byte-stable fixed point with unchanged values.
118
295
  - On macOS, the suite cross-validates every fixture and its rebuilt form with `plutil`, Apple's own property list parser and the empirical ground truth for what Apple tooling accepts.
119
- - A corpus sweep (`pnpm corpus`) walks every Xcode project on the machine, verifies each one parses and reaches a byte-stable fixed point, and cross-validates a sample of parsed values against plutil's own reading.
296
+ - A corpus sweep (`pnpm corpus`) walks every Xcode project and scheme on the machine, verifies each one parses and reaches a byte-stable fixed point, exercises the object model against every project, and cross-validates a sample against plutil's own reading.
120
297
  - CI runs the full gate on Linux and macOS, and executes the built artifact on the oldest supported Node to enforce the `engines` floor.
121
298
 
299
+ ## Releasing
300
+
301
+ Releases publish to npm from CI with [provenance](https://docs.npmjs.com/generating-provenance-statements) via [trusted publishing](https://docs.npmjs.com/trusted-publishers); no long-lived tokens are stored in the repository.
302
+
303
+ 1. Bump `version` in `package.json` and merge to `main`.
304
+ 2. Create a GitHub release with an `X.Y.Z` tag matching the new version.
305
+ 3. The release workflow verifies the tag, runs the full gate (including
306
+ plutil cross-validation on the macOS runner), and publishes.
307
+
122
308
  ## License
123
309
 
124
310
  Apache-2.0