@swiftbrowser/xcodeproj 0.0.0-stage → 0.2.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.
@@ -0,0 +1,578 @@
1
+ /**
2
+ * Interprets a parsed `project.pbxproj` (pbxproj.ts) as targets with resolved
3
+ * file paths and build settings, so a CLI can find an iOS app's Swift sources,
4
+ * asset catalogs and settings without Xcode.
5
+ *
6
+ * Object graph in brief: the `rootObject` is a `PBXProject` with `targets`
7
+ * (`PBXNativeTarget`), a `mainGroup` (the tree of `PBXGroup`s and
8
+ * `PBXFileReference`s) and a `buildConfigurationList`. Each target has
9
+ * `buildPhases` (`PBXSourcesBuildPhase`, `PBXResourcesBuildPhase`, ...) whose
10
+ * `files` are `PBXBuildFile`s pointing at a `fileRef`, plus its own
11
+ * `buildConfigurationList` (`XCConfigurationList` -> `XCBuildConfiguration`
12
+ * with `buildSettings`). Xcode 16 projects can instead point a target at
13
+ * `PBXFileSystemSynchronizedRootGroup`s: folders whose contents are members of
14
+ * the target implicitly, minus per-target exception sets.
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import { isDict, parsePbxproj } from './pbxproj.js';
19
+ import { parseXmlPlist } from './plist.js';
20
+ export const APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application';
21
+ /** SwiftPM's identity of a package at a URL or path: its last component, without `.git`, lower-cased. */
22
+ export function packageIdentity(location) {
23
+ const last = location.replace(/[/\\]+$/, '').split(/[/\\:]/).pop() ?? location;
24
+ return last.replace(/\.git$/i, '').toLowerCase();
25
+ }
26
+ /** Reads `<xcodeprojPath>/project.pbxproj` and interprets it. */
27
+ export function readXcodeProject(xcodeprojPath) {
28
+ const absolute = path.resolve(xcodeprojPath);
29
+ const text = fs.readFileSync(path.join(absolute, 'project.pbxproj'), 'utf8');
30
+ return xcodeProjectFromDocument(parsePbxproj(text), absolute);
31
+ }
32
+ /**
33
+ * Interprets an already parsed document as the project at `xcodeprojPath`
34
+ * (which need not exist; its parent directory is the source root).
35
+ */
36
+ export function xcodeProjectFromDocument(doc, xcodeprojPath) {
37
+ return new ProjectReader(doc, path.resolve(xcodeprojPath)).read();
38
+ }
39
+ // ----- Field access helpers -------------------------------------------------
40
+ function str(value) {
41
+ return typeof value === 'string' ? value : undefined;
42
+ }
43
+ /** The string items of an array value (anything else yields no items). */
44
+ function strings(value) {
45
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
46
+ }
47
+ /** A build setting as one string: arrays (`GCC_PREPROCESSOR_DEFINITIONS`) are space-joined. */
48
+ function settingString(value) {
49
+ if (typeof value === 'string')
50
+ return value;
51
+ if (Array.isArray(value))
52
+ return strings(value).join(' ');
53
+ return undefined;
54
+ }
55
+ const SYNCHRONIZED_GROUP_ISAS = new Set(['PBXFileSystemSynchronizedRootGroup']);
56
+ const GROUP_ISAS = new Set(['PBXGroup', 'PBXVariantGroup', 'XCVersionGroup', ...SYNCHRONIZED_GROUP_ISAS]);
57
+ // ----- Reader -----------------------------------------------------------------
58
+ class ProjectReader {
59
+ doc;
60
+ xcodeprojPath;
61
+ objects;
62
+ project;
63
+ /** Source root: the directory containing the `.xcodeproj`. */
64
+ root;
65
+ /** Directory the main group resolves to: `root` plus the project's `projectDirPath` (normally empty). */
66
+ mainGroupDir;
67
+ /** Group id of every group's and file reference's parent, for `<group>`-relative paths. */
68
+ parentOf = new Map();
69
+ /**
70
+ * Position of every group and file reference in a depth-first walk of the main
71
+ * group: the order the Xcode navigator shows. Build phases list their files
72
+ * in creation order, which is meaningless, so we sort them by this instead.
73
+ */
74
+ navigatorIndex = new Map();
75
+ resolvedPaths = new Map();
76
+ constructor(doc, xcodeprojPath) {
77
+ this.doc = doc;
78
+ this.xcodeprojPath = xcodeprojPath;
79
+ this.objects = doc.objects;
80
+ const project = this.objects[doc.rootObject];
81
+ if (!project || project.isa !== 'PBXProject') {
82
+ throw new Error(`project.pbxproj: rootObject ${doc.rootObject} is not a PBXProject`);
83
+ }
84
+ this.project = project;
85
+ this.root = path.dirname(this.xcodeprojPath);
86
+ this.mainGroupDir = path.resolve(this.root, str(project['projectDirPath']) ?? '');
87
+ for (const [id, object] of Object.entries(this.objects)) {
88
+ if (!GROUP_ISAS.has(object.isa))
89
+ continue;
90
+ for (const child of strings(object['children']))
91
+ this.parentOf.set(child, id);
92
+ }
93
+ const mainGroup = str(project['mainGroup']);
94
+ if (mainGroup)
95
+ this.indexGroup(mainGroup);
96
+ }
97
+ indexGroup(id) {
98
+ if (this.navigatorIndex.has(id))
99
+ return; // cyclic or shared child
100
+ this.navigatorIndex.set(id, this.navigatorIndex.size);
101
+ const object = this.objects[id];
102
+ if (object && GROUP_ISAS.has(object.isa)) {
103
+ for (const child of strings(object['children']))
104
+ this.indexGroup(child);
105
+ }
106
+ }
107
+ read() {
108
+ const targets = [];
109
+ for (const id of strings(this.project['targets'])) {
110
+ const target = this.objects[id];
111
+ if (target?.isa === 'PBXNativeTarget')
112
+ targets.push(this.readTarget(id, target));
113
+ }
114
+ const packages = strings(this.project['packageReferences'])
115
+ .map((id) => this.packageReference(id))
116
+ .filter((reference) => reference !== undefined);
117
+ return {
118
+ path: this.xcodeprojPath,
119
+ root: this.root,
120
+ objectVersion: this.doc.objectVersion,
121
+ packages,
122
+ targets,
123
+ appTargets: () => targets.filter((target) => target.productType === APPLICATION_PRODUCT_TYPE),
124
+ };
125
+ }
126
+ // --- Paths ---
127
+ /**
128
+ * Absolute path of a file reference or group, following `sourceTree`:
129
+ * `<group>` is relative to the parent group's directory, `SOURCE_ROOT` to the
130
+ * source root, `<absolute>` is absolute; anything else (`BUILT_PRODUCTS_DIR`,
131
+ * `SDKROOT`, `DEVELOPER_DIR`) falls back to the source root. Groups without a
132
+ * `path` resolve to their parent's directory.
133
+ */
134
+ resolvePath(id) {
135
+ if (this.resolvedPaths.has(id))
136
+ return this.resolvedPaths.get(id);
137
+ this.resolvedPaths.set(id, undefined); // guards against cyclic groups
138
+ const object = this.objects[id];
139
+ if (!object)
140
+ return undefined;
141
+ const relative = str(object['path']);
142
+ let base;
143
+ switch (str(object['sourceTree']) ?? '<group>') {
144
+ case '<group>': {
145
+ const parent = this.parentOf.get(id);
146
+ base = (parent ? this.resolvePath(parent) : undefined) ?? this.mainGroupDir;
147
+ break;
148
+ }
149
+ case '<absolute>':
150
+ base = '/';
151
+ break;
152
+ case 'SOURCE_ROOT':
153
+ default:
154
+ base = this.root;
155
+ break;
156
+ }
157
+ const resolved = relative ? path.resolve(base, relative) : base;
158
+ this.resolvedPaths.set(id, resolved);
159
+ return resolved;
160
+ }
161
+ /**
162
+ * Absolute paths of the files a build phase's entry refers to. A
163
+ * `PBXVariantGroup` (localized resources) contributes each of its variants.
164
+ */
165
+ buildFilePaths(buildFileId) {
166
+ const buildFile = this.objects[buildFileId];
167
+ const fileRef = str(buildFile?.['fileRef']);
168
+ if (!fileRef)
169
+ return [];
170
+ const ref = this.objects[fileRef];
171
+ if (!ref)
172
+ return [];
173
+ if (ref.isa === 'PBXVariantGroup') {
174
+ return strings(ref['children'])
175
+ .map((child) => this.resolvePath(child))
176
+ .filter((p) => p !== undefined);
177
+ }
178
+ const resolved = this.resolvePath(fileRef);
179
+ return resolved ? [resolved] : [];
180
+ }
181
+ /** The files of the target's build phases with the given `isa`, in navigator order. */
182
+ buildPhaseFiles(target, isa) {
183
+ const buildFileIds = [];
184
+ for (const phaseId of strings(target['buildPhases'])) {
185
+ const phase = this.objects[phaseId];
186
+ if (phase?.isa === isa)
187
+ buildFileIds.push(...strings(phase['files']));
188
+ }
189
+ const position = (buildFileId) => {
190
+ const fileRef = str(this.objects[buildFileId]?.['fileRef']);
191
+ return (fileRef ? this.navigatorIndex.get(fileRef) : undefined) ?? Number.MAX_SAFE_INTEGER;
192
+ };
193
+ return buildFileIds
194
+ .map((id, order) => ({ id, order, position: position(id) }))
195
+ .sort((a, b) => a.position - b.position || a.order - b.order)
196
+ .flatMap((entry) => this.buildFilePaths(entry.id));
197
+ }
198
+ // --- Synchronized folders (Xcode 16) ---
199
+ /**
200
+ * The `.swift` files, Core ML models and `.xcassets` folders under the
201
+ * target's synchronized folders, read from disk (sorted, recursive), minus
202
+ * the target's membership exceptions. A folder missing on disk contributes
203
+ * nothing.
204
+ */
205
+ synchronizedMembers(targetId, target) {
206
+ const sources = [];
207
+ const models = [];
208
+ const assetCatalogs = [];
209
+ const localizations = [];
210
+ for (const groupId of strings(target['fileSystemSynchronizedGroups'])) {
211
+ const group = this.objects[groupId];
212
+ if (!group || !SYNCHRONIZED_GROUP_ISAS.has(group.isa))
213
+ continue;
214
+ const dir = this.resolvePath(groupId);
215
+ if (!dir)
216
+ continue;
217
+ const excluded = new Set();
218
+ for (const exceptionId of strings(group['exceptions'])) {
219
+ const exceptions = this.objects[exceptionId];
220
+ if (exceptions?.isa !== 'PBXFileSystemSynchronizedBuildFileExceptionSet')
221
+ continue;
222
+ if (str(exceptions['target']) !== targetId)
223
+ continue;
224
+ for (const relative of strings(exceptions['membershipExceptions']))
225
+ excluded.add(path.resolve(dir, relative));
226
+ }
227
+ walkSynchronizedFolder(dir, (entry) => {
228
+ if (excluded.has(entry))
229
+ return false;
230
+ if (entry.endsWith('.swift'))
231
+ sources.push(entry);
232
+ else if (isCoreMLModel(entry))
233
+ models.push(entry);
234
+ else if (entry.endsWith('.xcassets'))
235
+ assetCatalogs.push(entry);
236
+ else if (isLocalization(entry))
237
+ localizations.push(entry);
238
+ return true;
239
+ });
240
+ }
241
+ return { sources, models, assetCatalogs, localizations };
242
+ }
243
+ // --- Build settings ---
244
+ configurations(listId) {
245
+ const list = listId ? this.objects[listId] : undefined;
246
+ if (list?.isa !== 'XCConfigurationList')
247
+ return [];
248
+ return strings(list['buildConfigurations'])
249
+ .map((id) => this.objects[id])
250
+ .filter((config) => config?.isa === 'XCBuildConfiguration');
251
+ }
252
+ defaultConfigurationName(target) {
253
+ const listId = str(target['buildConfigurationList']);
254
+ const names = this.configurations(listId).map((config) => str(config['name']));
255
+ if (names.includes('Debug'))
256
+ return 'Debug';
257
+ const list = listId ? this.objects[listId] : undefined;
258
+ return str(list?.['defaultConfigurationName']) ?? names[0];
259
+ }
260
+ /** The `buildSettings` of the configuration named `name` in a configuration list. */
261
+ buildSettings(listId, name) {
262
+ const config = this.configurations(listId).find((candidate) => str(candidate['name']) === name);
263
+ const settings = config?.['buildSettings'];
264
+ return isDict(settings) ? settings : {};
265
+ }
266
+ /** Project-level then target-level settings for a configuration, as raw strings. */
267
+ rawSettings(target, configuration) {
268
+ const merged = {};
269
+ const layers = [
270
+ this.buildSettings(str(this.project['buildConfigurationList']), configuration),
271
+ this.buildSettings(str(target['buildConfigurationList']), configuration),
272
+ ];
273
+ for (const layer of layers) {
274
+ for (const [key, value] of Object.entries(layer)) {
275
+ const text = settingString(value);
276
+ if (text !== undefined)
277
+ merged[key] = text;
278
+ }
279
+ }
280
+ return merged;
281
+ }
282
+ /**
283
+ * Expands the variables this package knows about in every value. `PRODUCT_NAME`
284
+ * is expanded first (with `$(TARGET_NAME)` only) because the other values may
285
+ * refer to it.
286
+ */
287
+ expandSettings(targetName, raw) {
288
+ const base = {
289
+ TARGET_NAME: targetName,
290
+ SRCROOT: this.root,
291
+ PROJECT_DIR: this.root,
292
+ };
293
+ const productName = expandVariables(raw['PRODUCT_NAME'] ?? '$(TARGET_NAME)', base) || targetName;
294
+ const variables = { ...base, PRODUCT_NAME: productName };
295
+ const settings = {};
296
+ for (const [key, value] of Object.entries(raw))
297
+ settings[key] = expandVariables(value, variables);
298
+ return settings;
299
+ }
300
+ // --- Targets ---
301
+ readTarget(targetId, target) {
302
+ const name = str(target['name']) ?? str(target['productName']) ?? targetId;
303
+ const defaultConfiguration = this.defaultConfigurationName(target);
304
+ const settingsCache = new Map();
305
+ const settings = (configuration = defaultConfiguration ?? '') => {
306
+ let result = settingsCache.get(configuration);
307
+ if (!result) {
308
+ result = this.expandSettings(name, this.rawSettings(target, configuration));
309
+ settingsCache.set(configuration, result);
310
+ }
311
+ return result;
312
+ };
313
+ const defaults = settings();
314
+ const phaseFiles = this.buildPhaseFiles(target, 'PBXSourcesBuildPhase');
315
+ const phaseSources = phaseFiles.filter((p) => p.endsWith('.swift'));
316
+ const resources = this.buildPhaseFiles(target, 'PBXResourcesBuildPhase');
317
+ const synchronized = this.synchronizedMembers(targetId, target);
318
+ const productName = defaults['PRODUCT_NAME'] || str(target['productName']) || name;
319
+ const result = {
320
+ name,
321
+ productType: str(target['productType']) ?? '',
322
+ productName,
323
+ sources: [...phaseSources, ...synchronized.sources],
324
+ models: [...phaseFiles.filter(isCoreMLModel), ...synchronized.models],
325
+ resources,
326
+ assetCatalogs: [...resources.filter((p) => p.endsWith('.xcassets')), ...synchronized.assetCatalogs],
327
+ localizations: [...localizationFiles(resources), ...synchronized.localizations],
328
+ dependencies: this.dependencyNames(target),
329
+ packageProducts: this.packageProductNames(target),
330
+ packageDependencies: this.packageProductDependencies(target),
331
+ settings,
332
+ };
333
+ const bundleIdentifier = defaults['PRODUCT_BUNDLE_IDENTIFIER'];
334
+ if (bundleIdentifier)
335
+ result.bundleIdentifier = bundleIdentifier;
336
+ const displayName = defaults['INFOPLIST_KEY_CFBundleDisplayName'] ?? this.infoPlistDisplayName(defaults['INFOPLIST_FILE']);
337
+ if (displayName)
338
+ result.displayName = displayName;
339
+ if (defaults['ASSETCATALOG_COMPILER_APPICON_NAME'])
340
+ result.appIconName = defaults['ASSETCATALOG_COMPILER_APPICON_NAME'];
341
+ if (defaults['SWIFT_VERSION'])
342
+ result.swiftVersion = defaults['SWIFT_VERSION'];
343
+ if (defaults['IPHONEOS_DEPLOYMENT_TARGET'])
344
+ result.deploymentTarget = defaults['IPHONEOS_DEPLOYMENT_TARGET'];
345
+ return result;
346
+ }
347
+ /** `CFBundleDisplayName` from an `INFOPLIST_FILE` (relative to the source root) when it is a readable XML plist. */
348
+ infoPlistDisplayName(infoPlistFile) {
349
+ if (!infoPlistFile)
350
+ return undefined;
351
+ let text;
352
+ try {
353
+ text = fs.readFileSync(path.resolve(this.root, infoPlistFile), 'utf8');
354
+ }
355
+ catch {
356
+ return undefined;
357
+ }
358
+ const plist = parseXmlPlist(text);
359
+ if (typeof plist !== 'object' || plist === null || Array.isArray(plist))
360
+ return undefined;
361
+ const displayName = plist['CFBundleDisplayName'];
362
+ return typeof displayName === 'string' && displayName !== '' ? displayName : undefined;
363
+ }
364
+ /** Names of the target dependencies: the referenced target's name, else the dependency's own `name`. */
365
+ dependencyNames(target) {
366
+ const names = [];
367
+ for (const dependencyId of strings(target['dependencies'])) {
368
+ const dependency = this.objects[dependencyId];
369
+ if (dependency?.isa !== 'PBXTargetDependency')
370
+ continue;
371
+ const dependedId = str(dependency['target']);
372
+ const depended = dependedId ? this.objects[dependedId] : undefined;
373
+ const name = str(depended?.['name']) ?? str(dependency['name']) ?? this.packageProductName(str(dependency['productRef']));
374
+ if (name)
375
+ names.push(name);
376
+ }
377
+ return names;
378
+ }
379
+ packageProductName(productRefId) {
380
+ const product = productRefId ? this.objects[productRefId] : undefined;
381
+ return product?.isa === 'XCSwiftPackageProductDependency' ? str(product['productName']) : undefined;
382
+ }
383
+ /**
384
+ * Swift package products the target links: its `packageProductDependencies`,
385
+ * plus any `productRef` of a build file in its Frameworks phase (older projects).
386
+ */
387
+ packageProductNames(target) {
388
+ const names = new Set();
389
+ for (const id of strings(target['packageProductDependencies'])) {
390
+ const name = this.packageProductName(id);
391
+ if (name)
392
+ names.add(name);
393
+ }
394
+ for (const phaseId of strings(target['buildPhases'])) {
395
+ const phase = this.objects[phaseId];
396
+ if (phase?.isa !== 'PBXFrameworksBuildPhase')
397
+ continue;
398
+ for (const buildFileId of strings(phase['files'])) {
399
+ const name = this.packageProductName(str(this.objects[buildFileId]?.['productRef']));
400
+ if (name)
401
+ names.add(name);
402
+ }
403
+ }
404
+ return [...names];
405
+ }
406
+ /** `packageProductNames` with the package of each product, when recorded. */
407
+ packageProductDependencies(target) {
408
+ const seen = new Set();
409
+ const out = [];
410
+ const add = (productRefId) => {
411
+ const product = productRefId ? this.objects[productRefId] : undefined;
412
+ if (product?.isa !== 'XCSwiftPackageProductDependency')
413
+ return;
414
+ const name = str(product['productName']);
415
+ if (!name || seen.has(name))
416
+ return;
417
+ seen.add(name);
418
+ const reference = this.packageReference(str(product['package'])) ?? this.folderPackage(name);
419
+ out.push(reference ? { product: name, package: reference } : { product: name });
420
+ };
421
+ for (const id of strings(target['packageProductDependencies']))
422
+ add(id);
423
+ for (const phaseId of strings(target['buildPhases'])) {
424
+ const phase = this.objects[phaseId];
425
+ if (phase?.isa !== 'PBXFrameworksBuildPhase')
426
+ continue;
427
+ for (const buildFileId of strings(phase['files']))
428
+ add(str(this.objects[buildFileId]?.['productRef']));
429
+ }
430
+ return out;
431
+ }
432
+ /**
433
+ * Local packages added to the project as folders (how Xcode added a local
434
+ * package before `XCLocalSwiftPackageReference`, and still does when one is
435
+ * dragged in): a file reference to a folder holding a `Package.swift`. The
436
+ * product dependency then names no package, so the package is the folder
437
+ * whose manifest declares a library of that name (read from the manifest's
438
+ * text: `.library(name: "…"`), or, failing that, the folder of that name.
439
+ */
440
+ folderPackagesByProduct;
441
+ folderPackage(product) {
442
+ if (!this.folderPackagesByProduct) {
443
+ const byProduct = new Map();
444
+ const byFolder = new Map();
445
+ for (const [id, object] of Object.entries(this.objects)) {
446
+ if (object.isa !== 'PBXFileReference')
447
+ continue;
448
+ const type = str(object['lastKnownFileType']) ?? str(object['explicitFileType']);
449
+ if (type !== undefined && type !== 'wrapper' && type !== 'folder')
450
+ continue;
451
+ const dir = this.resolvePath(id);
452
+ if (!dir)
453
+ continue;
454
+ let manifest;
455
+ try {
456
+ manifest = fs.readFileSync(path.join(dir, 'Package.swift'), 'utf8');
457
+ }
458
+ catch {
459
+ continue;
460
+ }
461
+ const reference = { kind: 'local', path: dir, identity: packageIdentity(dir) };
462
+ byFolder.set(path.basename(dir), reference);
463
+ for (const match of manifest.matchAll(/\.library\(\s*name:\s*"([^"]+)"/g)) {
464
+ if (!byProduct.has(match[1]))
465
+ byProduct.set(match[1], reference);
466
+ }
467
+ }
468
+ for (const [name, reference] of byFolder)
469
+ if (!byProduct.has(name))
470
+ byProduct.set(name, reference);
471
+ this.folderPackagesByProduct = byProduct;
472
+ }
473
+ return this.folderPackagesByProduct.get(product);
474
+ }
475
+ /** An `XCRemoteSwiftPackageReference` or `XCLocalSwiftPackageReference`. */
476
+ packageReference(id) {
477
+ const object = id ? this.objects[id] : undefined;
478
+ if (object?.isa === 'XCRemoteSwiftPackageReference') {
479
+ const url = str(object['repositoryURL']);
480
+ if (!url)
481
+ return undefined;
482
+ const requirement = packageRequirement(object['requirement']);
483
+ return requirement ? { kind: 'remote', url, identity: packageIdentity(url), requirement } : { kind: 'remote', url, identity: packageIdentity(url) };
484
+ }
485
+ if (object?.isa === 'XCLocalSwiftPackageReference') {
486
+ const relative = str(object['relativePath']);
487
+ if (!relative)
488
+ return undefined;
489
+ const absolute = path.resolve(this.root, relative);
490
+ return { kind: 'local', path: absolute, identity: packageIdentity(absolute) };
491
+ }
492
+ return undefined;
493
+ }
494
+ }
495
+ /** Xcode's dependency rule; undefined for a kind it does not spell out here. */
496
+ function packageRequirement(value) {
497
+ if (!isDict(value))
498
+ return undefined;
499
+ const minimumVersion = str(value['minimumVersion']);
500
+ switch (str(value['kind'])) {
501
+ case 'upToNextMajorVersion':
502
+ return minimumVersion ? { kind: 'upToNextMajorVersion', minimumVersion } : undefined;
503
+ case 'upToNextMinorVersion':
504
+ return minimumVersion ? { kind: 'upToNextMinorVersion', minimumVersion } : undefined;
505
+ case 'exactVersion': {
506
+ const version = str(value['version']);
507
+ return version ? { kind: 'exactVersion', version } : undefined;
508
+ }
509
+ case 'versionRange': {
510
+ const maximumVersion = str(value['maximumVersion']);
511
+ return minimumVersion && maximumVersion ? { kind: 'versionRange', minimumVersion, maximumVersion } : undefined;
512
+ }
513
+ case 'branch': {
514
+ const branch = str(value['branch']);
515
+ return branch ? { kind: 'branch', branch } : undefined;
516
+ }
517
+ case 'revision': {
518
+ const revision = str(value['revision']);
519
+ return revision ? { kind: 'revision', revision } : undefined;
520
+ }
521
+ default:
522
+ return undefined;
523
+ }
524
+ }
525
+ // ----- Helpers ----------------------------------------------------------------
526
+ /**
527
+ * Replaces `$(NAME)` and `${NAME}` for the names in `variables`; other
528
+ * references (`$(inherited)`, `$(SDKROOT)`, ...) are kept verbatim.
529
+ */
530
+ export function expandVariables(value, variables) {
531
+ return value.replace(/\$[({]([A-Za-z_][A-Za-z0-9_]*)[)}]/g, (whole, name) => variables[name] ?? whole);
532
+ }
533
+ /**
534
+ * Visits every entry under `dir` in sorted order, depth first. `visit` returns
535
+ * whether to descend into a directory; `.xcassets` bundles and dot-entries are
536
+ * never descended into. Unreadable directories are skipped.
537
+ */
538
+ /** A string catalog, or a `.strings` / `.stringsdict` table. */
539
+ function isLocalization(file) {
540
+ return /\.(xcstrings|strings|stringsdict)$/.test(file);
541
+ }
542
+ /** The localization files among resources: catalogs and tables, and the tables in `.lproj` folders. */
543
+ function localizationFiles(resources) {
544
+ const out = [];
545
+ for (const resource of resources) {
546
+ if (isLocalization(resource)) {
547
+ out.push(resource);
548
+ }
549
+ else if (resource.endsWith('.lproj') && fs.existsSync(resource) && fs.statSync(resource).isDirectory()) {
550
+ for (const name of fs.readdirSync(resource).sort())
551
+ if (isLocalization(name))
552
+ out.push(path.join(resource, name));
553
+ }
554
+ }
555
+ return out;
556
+ }
557
+ function walkSynchronizedFolder(dir, visit) {
558
+ let entries;
559
+ try {
560
+ entries = fs.readdirSync(dir, { withFileTypes: true });
561
+ }
562
+ catch {
563
+ return;
564
+ }
565
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
566
+ for (const entry of entries) {
567
+ if (entry.name.startsWith('.'))
568
+ continue;
569
+ const full = path.join(dir, entry.name);
570
+ const descend = visit(full);
571
+ if (descend && entry.isDirectory() && !entry.name.endsWith('.xcassets') && !entry.name.endsWith('.mlpackage'))
572
+ walkSynchronizedFolder(full, visit);
573
+ }
574
+ }
575
+ /** A Core ML model Xcode compiles into a class: an `.mlmodel` file or an `.mlpackage` bundle. */
576
+ function isCoreMLModel(file) {
577
+ return file.endsWith('.mlmodel') || file.endsWith('.mlpackage');
578
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@swiftbrowser/xcodeproj",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Reads Xcode projects (project.pbxproj): app targets, their Swift sources, asset catalogs and build settings. Pure TypeScript, no Xcode needed.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/noya-app/SwiftBrowser.git",
8
+ "directory": "packages/xcodeproj"
9
+ },
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "typecheck": "tsc --noEmit -p tsconfig.json",
25
+ "test:unit": "vitest run",
26
+ "build": "tsc -p tsconfig.build.json"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^26.6.4",
30
+ "typescript": "^5.9.2",
31
+ "vitest": "^5.0.3"
32
+ }
33
+ }