@swiftbrowser/xcodeproj 0.0.0-stage → 0.1.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 +84 -2
- package/dist/find.d.ts +9 -0
- package/dist/find.js +43 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/pbxproj.d.ts +67 -0
- package/dist/pbxproj.js +287 -0
- package/dist/plist.d.ts +15 -0
- package/dist/plist.js +147 -0
- package/dist/project.d.ts +84 -0
- package/dist/project.js +417 -0
- package/package.json +31 -4
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @swiftbrowser/xcodeproj
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reads Xcode projects without Xcode: a pure-TypeScript parser for `project.pbxproj`
|
|
4
|
+
(Node built-ins only) that answers the questions a CLI has about an iOS app:
|
|
5
|
+
which targets exist, which Swift files and asset catalogs they compile, and what
|
|
6
|
+
their build settings are.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { findXcodeProjects, readXcodeProject } from '@swiftbrowser/xcodeproj';
|
|
10
|
+
|
|
11
|
+
const [xcodeproj] = findXcodeProjects(process.cwd()); // ./*.xcodeproj, else one level down
|
|
12
|
+
const project = readXcodeProject(xcodeproj);
|
|
13
|
+
const [app] = project.appTargets(); // targets whose product is an application
|
|
14
|
+
|
|
15
|
+
app.name; // 'GuessTheFlag'
|
|
16
|
+
app.productName; // PRODUCT_NAME with $(TARGET_NAME) resolved
|
|
17
|
+
app.bundleIdentifier; // PRODUCT_BUNDLE_IDENTIFIER
|
|
18
|
+
app.displayName; // INFOPLIST_KEY_CFBundleDisplayName, else CFBundleDisplayName from INFOPLIST_FILE
|
|
19
|
+
app.appIconName; // ASSETCATALOG_COMPILER_APPICON_NAME
|
|
20
|
+
app.swiftVersion; // SWIFT_VERSION
|
|
21
|
+
app.deploymentTarget; // IPHONEOS_DEPLOYMENT_TARGET
|
|
22
|
+
app.sources; // absolute paths of the .swift files, in navigator order
|
|
23
|
+
app.resources; // absolute paths of the Resources build phase entries
|
|
24
|
+
app.assetCatalogs; // the *.xcassets among them (and in synchronized folders)
|
|
25
|
+
app.dependencies; // names of target dependencies
|
|
26
|
+
app.packageProducts; // Swift package products the target links
|
|
27
|
+
app.settings('Release'); // merged build settings for a configuration
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## API
|
|
31
|
+
|
|
32
|
+
- `readXcodeProject(xcodeprojPath)` parses `<xcodeprojPath>/project.pbxproj` and
|
|
33
|
+
returns an `XcodeProject`: `path`, `root` (the directory containing the
|
|
34
|
+
bundle, what `$(SRCROOT)` means), `objectVersion`, `targets` (every
|
|
35
|
+
`PBXNativeTarget`) and `appTargets()`.
|
|
36
|
+
- `parsePbxproj(text)` returns the raw object graph: `{ objectVersion,
|
|
37
|
+
rootObject, objects }`, where each object has an `isa` plus arbitrary fields
|
|
38
|
+
(strings, arrays, nested dictionaries). `parseOldStylePlist(text)` parses any
|
|
39
|
+
old-style property list fragment.
|
|
40
|
+
- `xcodeProjectFromDocument(doc, xcodeprojPath)` interprets an already parsed
|
|
41
|
+
document as the project at that path.
|
|
42
|
+
- `findXcodeProjects(dir)` lists the `*.xcodeproj` bundles directly in `dir`,
|
|
43
|
+
else one level down (sorted), skipping `Pods/`, `Carthage/`, `DerivedData/`,
|
|
44
|
+
`.build/`, `node_modules/` and dot-directories.
|
|
45
|
+
- `parseXmlPlist(text)` is the minimal XML plist reader used for `Info.plist`.
|
|
46
|
+
|
|
47
|
+
## How paths are resolved
|
|
48
|
+
|
|
49
|
+
A file's absolute path is the project root plus the chain of group `path`s
|
|
50
|
+
leading to it, following each object's `sourceTree`: `<group>` is relative to
|
|
51
|
+
the parent group, `SOURCE_ROOT` to the root, `<absolute>` is absolute; others
|
|
52
|
+
(`BUILT_PRODUCTS_DIR`, `SDKROOT`, ...) fall back to the root. Groups without a
|
|
53
|
+
`path` contribute nothing. Build phase files are listed in the order of the
|
|
54
|
+
group tree (what the Xcode navigator shows), not the arbitrary order of the
|
|
55
|
+
build phase. Paths are resolved exactly as written; a project whose group `path`
|
|
56
|
+
differs from the folder on disk only in letter case works on macOS but not on a
|
|
57
|
+
case-sensitive filesystem.
|
|
58
|
+
|
|
59
|
+
Xcode 16 "synchronized folders" (`PBXFileSystemSynchronizedRootGroup`
|
|
60
|
+
referenced from a target's `fileSystemSynchronizedGroups`) are read from disk:
|
|
61
|
+
every `*.swift` under the folder (recursive, sorted) is a source of the target
|
|
62
|
+
and every `*.xcassets` an asset catalog, minus the paths listed in the target's
|
|
63
|
+
`PBXFileSystemSynchronizedBuildFileExceptionSet.membershipExceptions`. A folder
|
|
64
|
+
missing on disk contributes nothing.
|
|
65
|
+
|
|
66
|
+
## Build settings
|
|
67
|
+
|
|
68
|
+
`target.settings(configuration?)` merges the project-level `buildSettings` of
|
|
69
|
+
the named `XCBuildConfiguration` with the target-level ones on top. The default
|
|
70
|
+
configuration is `Debug` when the target has one, else the configuration list's
|
|
71
|
+
`defaultConfigurationName`. Array-valued settings are space-joined.
|
|
72
|
+
`$(TARGET_NAME)`, `$(PRODUCT_NAME)`, `$(SRCROOT)` and `$(PROJECT_DIR)` (also in
|
|
73
|
+
`${...}` form) are expanded; every other `$(...)` reference, including
|
|
74
|
+
`$(inherited)` and modifiers such as `$(X:rfc1034identifier)`, is left as is.
|
|
75
|
+
|
|
76
|
+
## Not handled
|
|
77
|
+
|
|
78
|
+
- `.xcconfig` files referenced by `baseConfigurationReference` are not read, so
|
|
79
|
+
settings defined only there are absent.
|
|
80
|
+
- Binary `Info.plist` files are skipped (only XML plists are read).
|
|
81
|
+
- `PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet` (Xcode 16
|
|
82
|
+
per-build-phase exceptions) and `PBXBuildRule`s are ignored.
|
|
83
|
+
- Workspaces (`.xcworkspace`), referenced projects (`PBXReferenceProxy`) and
|
|
84
|
+
`PBXAggregateTarget`/`PBXLegacyTarget` are not modelled; only
|
|
85
|
+
`PBXNativeTarget`s appear in `targets`.
|
package/dist/find.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating `.xcodeproj` bundles on disk, for a CLI run from a project folder.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* The `.xcodeproj` bundles directly in `dir`, as absolute paths (sorted). When
|
|
6
|
+
* there are none, looks one level down, skipping `Pods/`, `Carthage/`,
|
|
7
|
+
* `DerivedData/`, `.build/`, `node_modules/` and dot-directories.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findXcodeProjects(dir: string): string[];
|
package/dist/find.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating `.xcodeproj` bundles on disk, for a CLI run from a project folder.
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
/** Directories whose `.xcodeproj`s are generated or third party, never the user's app. */
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set(['Pods', 'Carthage', 'DerivedData', '.build', 'node_modules']);
|
|
8
|
+
/**
|
|
9
|
+
* The `.xcodeproj` bundles directly in `dir`, as absolute paths (sorted). When
|
|
10
|
+
* there are none, looks one level down, skipping `Pods/`, `Carthage/`,
|
|
11
|
+
* `DerivedData/`, `.build/`, `node_modules/` and dot-directories.
|
|
12
|
+
*/
|
|
13
|
+
export function findXcodeProjects(dir) {
|
|
14
|
+
const absolute = path.resolve(dir);
|
|
15
|
+
const direct = xcodeProjectsIn(absolute);
|
|
16
|
+
if (direct.length > 0)
|
|
17
|
+
return direct;
|
|
18
|
+
const nested = [];
|
|
19
|
+
for (const entry of readDirectories(absolute)) {
|
|
20
|
+
if (IGNORED_DIRECTORIES.has(entry) || entry.startsWith('.'))
|
|
21
|
+
continue;
|
|
22
|
+
nested.push(...xcodeProjectsIn(path.join(absolute, entry)));
|
|
23
|
+
}
|
|
24
|
+
return nested.sort();
|
|
25
|
+
}
|
|
26
|
+
function xcodeProjectsIn(dir) {
|
|
27
|
+
return readDirectories(dir)
|
|
28
|
+
.filter((name) => name.endsWith('.xcodeproj'))
|
|
29
|
+
.sort()
|
|
30
|
+
.map((name) => path.join(dir, name));
|
|
31
|
+
}
|
|
32
|
+
/** Names of the subdirectories of `dir` (empty when it cannot be read). */
|
|
33
|
+
function readDirectories(dir) {
|
|
34
|
+
try {
|
|
35
|
+
return fs
|
|
36
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
37
|
+
.filter((entry) => entry.isDirectory())
|
|
38
|
+
.map((entry) => entry.name);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @swiftbrowser/xcodeproj: reads Xcode projects without Xcode.
|
|
3
|
+
*
|
|
4
|
+
* - `readXcodeProject(path)` interprets `<path>/project.pbxproj` as targets
|
|
5
|
+
* with resolved sources, asset catalogs and build settings.
|
|
6
|
+
* - `parsePbxproj(text)` gives the raw object graph.
|
|
7
|
+
* - `findXcodeProjects(dir)` locates `.xcodeproj` bundles.
|
|
8
|
+
*/
|
|
9
|
+
export { parsePbxproj, parseOldStylePlist, tokenizePbxproj, PbxprojSyntaxError, type PbxValue, type PbxDict, type PbxObject, type PbxprojDocument, } from './pbxproj.js';
|
|
10
|
+
export { readXcodeProject, xcodeProjectFromDocument, expandVariables, APPLICATION_PRODUCT_TYPE, type XcodeProject, type XcodeTarget, } from './project.js';
|
|
11
|
+
export { findXcodeProjects } from './find.js';
|
|
12
|
+
export { parseXmlPlist, type PlistValue, type PlistDict } from './plist.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @swiftbrowser/xcodeproj: reads Xcode projects without Xcode.
|
|
3
|
+
*
|
|
4
|
+
* - `readXcodeProject(path)` interprets `<path>/project.pbxproj` as targets
|
|
5
|
+
* with resolved sources, asset catalogs and build settings.
|
|
6
|
+
* - `parsePbxproj(text)` gives the raw object graph.
|
|
7
|
+
* - `findXcodeProjects(dir)` locates `.xcodeproj` bundles.
|
|
8
|
+
*/
|
|
9
|
+
export { parsePbxproj, parseOldStylePlist, tokenizePbxproj, PbxprojSyntaxError, } from './pbxproj.js';
|
|
10
|
+
export { readXcodeProject, xcodeProjectFromDocument, expandVariables, APPLICATION_PRODUCT_TYPE, } from './project.js';
|
|
11
|
+
export { findXcodeProjects } from './find.js';
|
|
12
|
+
export { parseXmlPlist } from './plist.js';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for `project.pbxproj`, the file inside every `.xcodeproj` bundle.
|
|
3
|
+
*
|
|
4
|
+
* The format is the old-style ASCII (NeXTSTEP) property list:
|
|
5
|
+
*
|
|
6
|
+
* // !$*UTF8*$!
|
|
7
|
+
* {
|
|
8
|
+
* archiveVersion = 1;
|
|
9
|
+
* objectVersion = 56;
|
|
10
|
+
* objects = {
|
|
11
|
+
* 518D77712AD7303000E0BF20 (comment: ContentView.swift) = {isa = PBXFileReference; path = ContentView.swift; sourceTree = "<group>"; };
|
|
12
|
+
* ...
|
|
13
|
+
* };
|
|
14
|
+
* rootObject = 518D77662AD7303000E0BF20 (comment: Project object);
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Dictionaries are `{ key = value; }`, arrays are `( a, b, )`, and every scalar is
|
|
18
|
+
* a string: either an unquoted word (letters, digits, `_ . / $ - + :` ...) or a
|
|
19
|
+
* double-quoted string with backslash escapes. Block comments (slash-star) and
|
|
20
|
+
* line comments (double slash) may appear anywhere; Xcode puts a block comment
|
|
21
|
+
* with the object's name after every object id, as sketched above. The parser
|
|
22
|
+
* is tolerant of trailing commas and of a missing `;` before `}`.
|
|
23
|
+
*
|
|
24
|
+
* This module only builds the raw object graph; `project.ts` interprets it.
|
|
25
|
+
*/
|
|
26
|
+
/** A pbxproj value: a string, an array of values or a dictionary of values. */
|
|
27
|
+
export type PbxValue = string | PbxValue[] | PbxDict;
|
|
28
|
+
export interface PbxDict {
|
|
29
|
+
[key: string]: PbxValue;
|
|
30
|
+
}
|
|
31
|
+
/** One entry of `objects`: every object has an `isa` class name plus arbitrary fields. */
|
|
32
|
+
export interface PbxObject extends PbxDict {
|
|
33
|
+
isa: string;
|
|
34
|
+
}
|
|
35
|
+
/** The parsed file. `objects` is keyed by object id (usually 24 hex digits). */
|
|
36
|
+
export interface PbxprojDocument {
|
|
37
|
+
archiveVersion: number;
|
|
38
|
+
objectVersion: number;
|
|
39
|
+
rootObject: string;
|
|
40
|
+
objects: Record<string, PbxObject>;
|
|
41
|
+
}
|
|
42
|
+
/** Thrown for malformed input; `offset` is the character index where parsing failed. */
|
|
43
|
+
export declare class PbxprojSyntaxError extends Error {
|
|
44
|
+
readonly offset: number;
|
|
45
|
+
readonly line: number;
|
|
46
|
+
readonly column: number;
|
|
47
|
+
constructor(message: string, offset: number, line: number, column: number);
|
|
48
|
+
}
|
|
49
|
+
type TokenKind = '{' | '}' | '(' | ')' | '=' | ';' | ',' | 'string' | 'eof';
|
|
50
|
+
interface Token {
|
|
51
|
+
kind: TokenKind;
|
|
52
|
+
/** The string value (unescaped) for `string` tokens; the punctuation otherwise. */
|
|
53
|
+
value: string;
|
|
54
|
+
/** Character offset of the token's first character, for error messages. */
|
|
55
|
+
offset: number;
|
|
56
|
+
}
|
|
57
|
+
/** Breaks the text into tokens, dropping comments. */
|
|
58
|
+
export declare function tokenizePbxproj(text: string): Token[];
|
|
59
|
+
/**
|
|
60
|
+
* Parses any old-style property list text into its value tree. `parsePbxproj`
|
|
61
|
+
* is the usual entry point; this one is handy for fragments and tests.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parseOldStylePlist(text: string): PbxValue;
|
|
64
|
+
/** Parses the text of a `project.pbxproj` file into its raw object graph. */
|
|
65
|
+
export declare function parsePbxproj(text: string): PbxprojDocument;
|
|
66
|
+
export declare function isDict(value: PbxValue | undefined): value is PbxDict;
|
|
67
|
+
export {};
|
package/dist/pbxproj.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for `project.pbxproj`, the file inside every `.xcodeproj` bundle.
|
|
3
|
+
*
|
|
4
|
+
* The format is the old-style ASCII (NeXTSTEP) property list:
|
|
5
|
+
*
|
|
6
|
+
* // !$*UTF8*$!
|
|
7
|
+
* {
|
|
8
|
+
* archiveVersion = 1;
|
|
9
|
+
* objectVersion = 56;
|
|
10
|
+
* objects = {
|
|
11
|
+
* 518D77712AD7303000E0BF20 (comment: ContentView.swift) = {isa = PBXFileReference; path = ContentView.swift; sourceTree = "<group>"; };
|
|
12
|
+
* ...
|
|
13
|
+
* };
|
|
14
|
+
* rootObject = 518D77662AD7303000E0BF20 (comment: Project object);
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Dictionaries are `{ key = value; }`, arrays are `( a, b, )`, and every scalar is
|
|
18
|
+
* a string: either an unquoted word (letters, digits, `_ . / $ - + :` ...) or a
|
|
19
|
+
* double-quoted string with backslash escapes. Block comments (slash-star) and
|
|
20
|
+
* line comments (double slash) may appear anywhere; Xcode puts a block comment
|
|
21
|
+
* with the object's name after every object id, as sketched above. The parser
|
|
22
|
+
* is tolerant of trailing commas and of a missing `;` before `}`.
|
|
23
|
+
*
|
|
24
|
+
* This module only builds the raw object graph; `project.ts` interprets it.
|
|
25
|
+
*/
|
|
26
|
+
/** Thrown for malformed input; `offset` is the character index where parsing failed. */
|
|
27
|
+
export class PbxprojSyntaxError extends Error {
|
|
28
|
+
offset;
|
|
29
|
+
line;
|
|
30
|
+
column;
|
|
31
|
+
constructor(message, offset, line, column) {
|
|
32
|
+
super(`${message} (line ${line}, column ${column})`);
|
|
33
|
+
this.offset = offset;
|
|
34
|
+
this.line = line;
|
|
35
|
+
this.column = column;
|
|
36
|
+
this.name = 'PbxprojSyntaxError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const PUNCTUATION = new Set(['{', '}', '(', ')', '=', ';', ',']);
|
|
40
|
+
/** Characters that end an unquoted word (besides whitespace and comment starts). */
|
|
41
|
+
const WORD_TERMINATORS = new Set([...PUNCTUATION, '"']);
|
|
42
|
+
function isWhitespace(ch) {
|
|
43
|
+
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' || ch === '\v';
|
|
44
|
+
}
|
|
45
|
+
/** Escape sequences recognised inside quoted strings (besides `\\uXXXX`, octal and the quote/backslash themselves). */
|
|
46
|
+
const SIMPLE_ESCAPES = {
|
|
47
|
+
n: '\n',
|
|
48
|
+
t: '\t',
|
|
49
|
+
r: '\r',
|
|
50
|
+
a: '\x07',
|
|
51
|
+
b: '\b',
|
|
52
|
+
f: '\f',
|
|
53
|
+
v: '\v',
|
|
54
|
+
'"': '"',
|
|
55
|
+
"'": "'",
|
|
56
|
+
'\\': '\\',
|
|
57
|
+
};
|
|
58
|
+
/** Breaks the text into tokens, dropping comments. */
|
|
59
|
+
export function tokenizePbxproj(text) {
|
|
60
|
+
const tokens = [];
|
|
61
|
+
const length = text.length;
|
|
62
|
+
let i = 0;
|
|
63
|
+
// A function declaration (not an arrow) so TypeScript treats calls as assertions.
|
|
64
|
+
function fail(message, offset) {
|
|
65
|
+
throw syntaxError(text, message, offset);
|
|
66
|
+
}
|
|
67
|
+
while (i < length) {
|
|
68
|
+
const ch = text[i];
|
|
69
|
+
if (isWhitespace(ch)) {
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Comments: `/* ... */` and `// ...` to end of line.
|
|
74
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
75
|
+
const end = text.indexOf('*/', i + 2);
|
|
76
|
+
if (end < 0)
|
|
77
|
+
fail('Unterminated block comment', i);
|
|
78
|
+
i = end + 2;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
82
|
+
const end = text.indexOf('\n', i + 2);
|
|
83
|
+
i = end < 0 ? length : end + 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (PUNCTUATION.has(ch)) {
|
|
87
|
+
tokens.push({ kind: ch, value: ch, offset: i });
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ch === '"') {
|
|
92
|
+
const start = i;
|
|
93
|
+
i++;
|
|
94
|
+
let value = '';
|
|
95
|
+
for (;;) {
|
|
96
|
+
if (i >= length)
|
|
97
|
+
fail('Unterminated string', start);
|
|
98
|
+
const c = text[i];
|
|
99
|
+
if (c === '"') {
|
|
100
|
+
i++;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (c !== '\\') {
|
|
104
|
+
value += c;
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Backslash escape.
|
|
109
|
+
const e = text[i + 1];
|
|
110
|
+
if (e === undefined)
|
|
111
|
+
fail('Unterminated string', start);
|
|
112
|
+
if (e === 'U' || e === 'u') {
|
|
113
|
+
// `\Uxxxx`: up to four hex digits.
|
|
114
|
+
const hex = /^[0-9a-fA-F]{1,4}/.exec(text.slice(i + 2, i + 6));
|
|
115
|
+
if (!hex)
|
|
116
|
+
fail('Invalid \\U escape', i);
|
|
117
|
+
value += String.fromCharCode(parseInt(hex[0], 16));
|
|
118
|
+
i += 2 + hex[0].length;
|
|
119
|
+
}
|
|
120
|
+
else if (e >= '0' && e <= '7') {
|
|
121
|
+
// `\ooo`: up to three octal digits.
|
|
122
|
+
const oct = /^[0-7]{1,3}/.exec(text.slice(i + 1, i + 4));
|
|
123
|
+
value += String.fromCharCode(parseInt(oct[0], 8));
|
|
124
|
+
i += 1 + oct[0].length;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Unknown escapes keep the character (`\x` -> `x`), like Foundation does.
|
|
128
|
+
value += SIMPLE_ESCAPES[e] ?? e;
|
|
129
|
+
i += 2;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
tokens.push({ kind: 'string', value, offset: start });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// Unquoted word: runs until whitespace, punctuation, a quote or a comment start.
|
|
136
|
+
const start = i;
|
|
137
|
+
while (i < length) {
|
|
138
|
+
const c = text[i];
|
|
139
|
+
if (isWhitespace(c) || WORD_TERMINATORS.has(c))
|
|
140
|
+
break;
|
|
141
|
+
if (c === '/' && (text[i + 1] === '*' || text[i + 1] === '/'))
|
|
142
|
+
break;
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
if (i === start)
|
|
146
|
+
fail(`Unexpected character ${JSON.stringify(ch)}`, i);
|
|
147
|
+
tokens.push({ kind: 'string', value: text.slice(start, i), offset: start });
|
|
148
|
+
}
|
|
149
|
+
tokens.push({ kind: 'eof', value: '', offset: length });
|
|
150
|
+
return tokens;
|
|
151
|
+
}
|
|
152
|
+
function syntaxError(text, message, offset) {
|
|
153
|
+
let line = 1;
|
|
154
|
+
let lineStart = 0;
|
|
155
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
156
|
+
if (text[i] === '\n') {
|
|
157
|
+
line++;
|
|
158
|
+
lineStart = i + 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return new PbxprojSyntaxError(message, offset, line, offset - lineStart + 1);
|
|
162
|
+
}
|
|
163
|
+
// ----- Parser --------------------------------------------------------------
|
|
164
|
+
class Parser {
|
|
165
|
+
text;
|
|
166
|
+
tokens;
|
|
167
|
+
pos = 0;
|
|
168
|
+
constructor(text, tokens) {
|
|
169
|
+
this.text = text;
|
|
170
|
+
this.tokens = tokens;
|
|
171
|
+
}
|
|
172
|
+
peek() {
|
|
173
|
+
return this.tokens[this.pos];
|
|
174
|
+
}
|
|
175
|
+
next() {
|
|
176
|
+
const token = this.tokens[this.pos];
|
|
177
|
+
if (token.kind !== 'eof')
|
|
178
|
+
this.pos++;
|
|
179
|
+
return token;
|
|
180
|
+
}
|
|
181
|
+
fail(message, token = this.peek()) {
|
|
182
|
+
const found = token.kind === 'eof' ? 'end of input' : JSON.stringify(token.value);
|
|
183
|
+
throw syntaxError(this.text, `${message}, found ${found}`, token.offset);
|
|
184
|
+
}
|
|
185
|
+
expect(kind) {
|
|
186
|
+
const token = this.peek();
|
|
187
|
+
if (token.kind !== kind)
|
|
188
|
+
this.fail(`Expected ${kind}`);
|
|
189
|
+
return this.next();
|
|
190
|
+
}
|
|
191
|
+
/** Parses the whole document: one top-level value followed by end of input. */
|
|
192
|
+
parseDocument() {
|
|
193
|
+
const value = this.parseValue();
|
|
194
|
+
if (this.peek().kind !== 'eof')
|
|
195
|
+
this.fail('Expected end of input');
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
parseValue() {
|
|
199
|
+
const token = this.peek();
|
|
200
|
+
switch (token.kind) {
|
|
201
|
+
case '{':
|
|
202
|
+
return this.parseDict();
|
|
203
|
+
case '(':
|
|
204
|
+
return this.parseArray();
|
|
205
|
+
case 'string':
|
|
206
|
+
return this.next().value;
|
|
207
|
+
default:
|
|
208
|
+
return this.fail('Expected a value');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
parseDict() {
|
|
212
|
+
this.expect('{');
|
|
213
|
+
const dict = {};
|
|
214
|
+
while (this.peek().kind !== '}') {
|
|
215
|
+
if (this.peek().kind === 'eof')
|
|
216
|
+
this.fail('Unterminated dictionary');
|
|
217
|
+
const key = this.expect('string').value;
|
|
218
|
+
this.expect('=');
|
|
219
|
+
dict[key] = this.parseValue();
|
|
220
|
+
// The `;` after an entry is required by Xcode but we accept a missing one before `}`.
|
|
221
|
+
if (this.peek().kind === ';')
|
|
222
|
+
this.next();
|
|
223
|
+
else if (this.peek().kind === 'eof')
|
|
224
|
+
this.fail('Unterminated dictionary');
|
|
225
|
+
else if (this.peek().kind !== '}')
|
|
226
|
+
this.fail('Expected ; after dictionary entry');
|
|
227
|
+
}
|
|
228
|
+
this.expect('}');
|
|
229
|
+
return dict;
|
|
230
|
+
}
|
|
231
|
+
parseArray() {
|
|
232
|
+
this.expect('(');
|
|
233
|
+
const items = [];
|
|
234
|
+
while (this.peek().kind !== ')') {
|
|
235
|
+
if (this.peek().kind === 'eof')
|
|
236
|
+
this.fail('Unterminated array');
|
|
237
|
+
items.push(this.parseValue());
|
|
238
|
+
// Commas separate items; a trailing comma is the norm. A missing comma is
|
|
239
|
+
// tolerated since values are self-delimiting.
|
|
240
|
+
if (this.peek().kind === ',')
|
|
241
|
+
this.next();
|
|
242
|
+
}
|
|
243
|
+
this.expect(')');
|
|
244
|
+
return items;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Parses any old-style property list text into its value tree. `parsePbxproj`
|
|
249
|
+
* is the usual entry point; this one is handy for fragments and tests.
|
|
250
|
+
*/
|
|
251
|
+
export function parseOldStylePlist(text) {
|
|
252
|
+
return new Parser(text, tokenizePbxproj(text)).parseDocument();
|
|
253
|
+
}
|
|
254
|
+
/** Parses the text of a `project.pbxproj` file into its raw object graph. */
|
|
255
|
+
export function parsePbxproj(text) {
|
|
256
|
+
const value = parseOldStylePlist(text);
|
|
257
|
+
if (!isDict(value))
|
|
258
|
+
throw new Error('project.pbxproj: top-level value is not a dictionary');
|
|
259
|
+
const objectsValue = value['objects'];
|
|
260
|
+
if (!isDict(objectsValue))
|
|
261
|
+
throw new Error('project.pbxproj: missing `objects` dictionary');
|
|
262
|
+
const objects = {};
|
|
263
|
+
for (const [id, object] of Object.entries(objectsValue)) {
|
|
264
|
+
if (!isDict(object) || typeof object['isa'] !== 'string') {
|
|
265
|
+
throw new Error(`project.pbxproj: object ${id} has no isa`);
|
|
266
|
+
}
|
|
267
|
+
objects[id] = object;
|
|
268
|
+
}
|
|
269
|
+
const rootObject = value['rootObject'];
|
|
270
|
+
if (typeof rootObject !== 'string')
|
|
271
|
+
throw new Error('project.pbxproj: missing `rootObject`');
|
|
272
|
+
return {
|
|
273
|
+
archiveVersion: toInteger(value['archiveVersion'], 1),
|
|
274
|
+
objectVersion: toInteger(value['objectVersion'], 0),
|
|
275
|
+
rootObject,
|
|
276
|
+
objects,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export function isDict(value) {
|
|
280
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
281
|
+
}
|
|
282
|
+
function toInteger(value, fallback) {
|
|
283
|
+
if (typeof value !== 'string')
|
|
284
|
+
return fallback;
|
|
285
|
+
const n = Number.parseInt(value, 10);
|
|
286
|
+
return Number.isNaN(n) ? fallback : n;
|
|
287
|
+
}
|
package/dist/plist.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal reader for XML property lists (`Info.plist`).
|
|
3
|
+
*
|
|
4
|
+
* Only what the project reader needs: the top-level `<dict>` with string,
|
|
5
|
+
* integer, real, boolean, date, data, array and nested dict values. Binary
|
|
6
|
+
* plists (`bplist00`) and the old-style ASCII format are not handled here;
|
|
7
|
+
* `parseXmlPlist` returns `undefined` for anything it cannot read, and the
|
|
8
|
+
* caller treats that as "no Info.plist values available".
|
|
9
|
+
*/
|
|
10
|
+
export type PlistValue = string | number | boolean | PlistValue[] | PlistDict;
|
|
11
|
+
export interface PlistDict {
|
|
12
|
+
[key: string]: PlistValue;
|
|
13
|
+
}
|
|
14
|
+
/** Parses an XML plist. Returns `undefined` when the text is not an XML plist. */
|
|
15
|
+
export declare function parseXmlPlist(text: string): PlistValue | undefined;
|
package/dist/plist.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal reader for XML property lists (`Info.plist`).
|
|
3
|
+
*
|
|
4
|
+
* Only what the project reader needs: the top-level `<dict>` with string,
|
|
5
|
+
* integer, real, boolean, date, data, array and nested dict values. Binary
|
|
6
|
+
* plists (`bplist00`) and the old-style ASCII format are not handled here;
|
|
7
|
+
* `parseXmlPlist` returns `undefined` for anything it cannot read, and the
|
|
8
|
+
* caller treats that as "no Info.plist values available".
|
|
9
|
+
*/
|
|
10
|
+
/** Parses an XML plist. Returns `undefined` when the text is not an XML plist. */
|
|
11
|
+
export function parseXmlPlist(text) {
|
|
12
|
+
if (!/<plist[\s>]/.test(text))
|
|
13
|
+
return undefined;
|
|
14
|
+
const reader = new XmlPlistReader(text);
|
|
15
|
+
try {
|
|
16
|
+
return reader.read();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
class XmlPlistReader {
|
|
23
|
+
text;
|
|
24
|
+
pos = 0;
|
|
25
|
+
constructor(text) {
|
|
26
|
+
this.text = text;
|
|
27
|
+
}
|
|
28
|
+
read() {
|
|
29
|
+
const plist = this.nextTag();
|
|
30
|
+
if (!plist || plist.name !== 'plist')
|
|
31
|
+
throw new Error('not a plist');
|
|
32
|
+
if (plist.selfClosing)
|
|
33
|
+
return undefined;
|
|
34
|
+
const value = this.readValue();
|
|
35
|
+
this.expectClosing('plist');
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
/** Reads the next opening tag (skipping the prolog, DOCTYPE, comments and whitespace). */
|
|
39
|
+
nextTag() {
|
|
40
|
+
for (;;) {
|
|
41
|
+
const lt = this.text.indexOf('<', this.pos);
|
|
42
|
+
if (lt < 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (this.text.startsWith('<?', lt)) {
|
|
45
|
+
this.pos = this.text.indexOf('?>', lt) + 2;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (this.text.startsWith('<!--', lt)) {
|
|
49
|
+
this.pos = this.text.indexOf('-->', lt) + 3;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (this.text.startsWith('<!', lt)) {
|
|
53
|
+
this.pos = this.text.indexOf('>', lt) + 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const gt = this.text.indexOf('>', lt);
|
|
57
|
+
if (gt < 0)
|
|
58
|
+
throw new Error('unterminated tag');
|
|
59
|
+
let inside = this.text.slice(lt + 1, gt).trim();
|
|
60
|
+
const selfClosing = inside.endsWith('/');
|
|
61
|
+
if (selfClosing)
|
|
62
|
+
inside = inside.slice(0, -1).trim();
|
|
63
|
+
const name = inside.split(/\s/, 1)[0] ?? '';
|
|
64
|
+
this.pos = gt + 1;
|
|
65
|
+
return { name, selfClosing, end: gt + 1 };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Peeks: is the next tag the closing `</name>`? Consumes it when so. */
|
|
69
|
+
atClosing(name) {
|
|
70
|
+
const rest = this.text.slice(this.pos);
|
|
71
|
+
const match = /^\s*<\/\s*([^\s>]+)\s*>/.exec(rest);
|
|
72
|
+
if (!match || match[1] !== name)
|
|
73
|
+
return false;
|
|
74
|
+
this.pos += match[0].length;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
expectClosing(name) {
|
|
78
|
+
if (!this.atClosing(name))
|
|
79
|
+
throw new Error(`expected </${name}>`);
|
|
80
|
+
}
|
|
81
|
+
/** Reads the text content up to `</name>` (entities decoded). */
|
|
82
|
+
readText(name) {
|
|
83
|
+
const close = this.text.indexOf(`</${name}>`, this.pos);
|
|
84
|
+
if (close < 0)
|
|
85
|
+
throw new Error(`expected </${name}>`);
|
|
86
|
+
const raw = this.text.slice(this.pos, close);
|
|
87
|
+
this.pos = close + name.length + 3;
|
|
88
|
+
return decodeEntities(raw);
|
|
89
|
+
}
|
|
90
|
+
readValue() {
|
|
91
|
+
const tag = this.nextTag();
|
|
92
|
+
if (!tag)
|
|
93
|
+
throw new Error('expected a value');
|
|
94
|
+
switch (tag.name) {
|
|
95
|
+
case 'string':
|
|
96
|
+
case 'date':
|
|
97
|
+
case 'data':
|
|
98
|
+
return tag.selfClosing ? '' : this.readText(tag.name);
|
|
99
|
+
case 'integer':
|
|
100
|
+
case 'real':
|
|
101
|
+
return tag.selfClosing ? 0 : Number(this.readText(tag.name).trim());
|
|
102
|
+
case 'true':
|
|
103
|
+
return true;
|
|
104
|
+
case 'false':
|
|
105
|
+
return false;
|
|
106
|
+
case 'array': {
|
|
107
|
+
const items = [];
|
|
108
|
+
if (tag.selfClosing)
|
|
109
|
+
return items;
|
|
110
|
+
while (!this.atClosing('array'))
|
|
111
|
+
items.push(this.readValue());
|
|
112
|
+
return items;
|
|
113
|
+
}
|
|
114
|
+
case 'dict': {
|
|
115
|
+
const dict = {};
|
|
116
|
+
if (tag.selfClosing)
|
|
117
|
+
return dict;
|
|
118
|
+
while (!this.atClosing('dict')) {
|
|
119
|
+
const keyTag = this.nextTag();
|
|
120
|
+
if (!keyTag || keyTag.name !== 'key')
|
|
121
|
+
throw new Error('expected <key>');
|
|
122
|
+
const key = keyTag.selfClosing ? '' : this.readText('key');
|
|
123
|
+
dict[key] = this.readValue();
|
|
124
|
+
}
|
|
125
|
+
return dict;
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`unexpected <${tag.name}>`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const NAMED_ENTITIES = {
|
|
133
|
+
amp: '&',
|
|
134
|
+
lt: '<',
|
|
135
|
+
gt: '>',
|
|
136
|
+
quot: '"',
|
|
137
|
+
apos: "'",
|
|
138
|
+
};
|
|
139
|
+
function decodeEntities(text) {
|
|
140
|
+
return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (whole, body) => {
|
|
141
|
+
if (body.startsWith('#x'))
|
|
142
|
+
return String.fromCodePoint(parseInt(body.slice(2), 16));
|
|
143
|
+
if (body.startsWith('#'))
|
|
144
|
+
return String.fromCodePoint(parseInt(body.slice(1), 10));
|
|
145
|
+
return NAMED_ENTITIES[body] ?? whole;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { type PbxprojDocument } from './pbxproj.js';
|
|
17
|
+
export declare const APPLICATION_PRODUCT_TYPE = "com.apple.product-type.application";
|
|
18
|
+
export interface XcodeProject {
|
|
19
|
+
/** Absolute path of the `.xcodeproj` bundle. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** Directory containing the `.xcodeproj`: the source root, what `$(SRCROOT)` means. */
|
|
22
|
+
root: string;
|
|
23
|
+
objectVersion: number;
|
|
24
|
+
/** Every `PBXNativeTarget`, in project order. */
|
|
25
|
+
targets: XcodeTarget[];
|
|
26
|
+
/** The targets whose product is an application (`com.apple.product-type.application`). */
|
|
27
|
+
appTargets(): XcodeTarget[];
|
|
28
|
+
}
|
|
29
|
+
export interface XcodeTarget {
|
|
30
|
+
name: string;
|
|
31
|
+
/** e.g. `com.apple.product-type.application`. */
|
|
32
|
+
productType: string;
|
|
33
|
+
/** `PRODUCT_NAME` with `$(TARGET_NAME)` resolved; falls back to the target name. */
|
|
34
|
+
productName: string;
|
|
35
|
+
/** `PRODUCT_BUNDLE_IDENTIFIER`. */
|
|
36
|
+
bundleIdentifier?: string;
|
|
37
|
+
/**
|
|
38
|
+
* `INFOPLIST_KEY_CFBundleDisplayName`, else `CFBundleDisplayName` from the
|
|
39
|
+
* `INFOPLIST_FILE` when that file exists and is an XML plist.
|
|
40
|
+
*/
|
|
41
|
+
displayName?: string;
|
|
42
|
+
/** `ASSETCATALOG_COMPILER_APPICON_NAME`. */
|
|
43
|
+
appIconName?: string;
|
|
44
|
+
/** `SWIFT_VERSION`. */
|
|
45
|
+
swiftVersion?: string;
|
|
46
|
+
/** `IPHONEOS_DEPLOYMENT_TARGET`. */
|
|
47
|
+
deploymentTarget?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Absolute paths of the `.swift` files compiled by the target: the Sources
|
|
50
|
+
* build phase in project (navigator) order, then the files of synchronized
|
|
51
|
+
* folders (sorted).
|
|
52
|
+
*/
|
|
53
|
+
sources: string[];
|
|
54
|
+
/** Absolute paths of the files and folders in the Resources build phase, in project (navigator) order. */
|
|
55
|
+
resources: string[];
|
|
56
|
+
/** Absolute paths of the `*.xcassets` among the resources and in synchronized folders. */
|
|
57
|
+
assetCatalogs: string[];
|
|
58
|
+
/** Names of the targets this one depends on (`PBXTargetDependency`). */
|
|
59
|
+
dependencies: string[];
|
|
60
|
+
/** Swift package products the target links (`XCSwiftPackageProductDependency.productName`). */
|
|
61
|
+
packageProducts: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Merged build settings for a configuration: project-level settings first,
|
|
64
|
+
* target-level settings on top. `configuration` defaults to the target's
|
|
65
|
+
* default configuration: `Debug` when it has one, else the configuration
|
|
66
|
+
* list's `defaultConfigurationName`, else the first configuration.
|
|
67
|
+
* `$(TARGET_NAME)`, `$(PRODUCT_NAME)`, `$(SRCROOT)` and `$(PROJECT_DIR)` are
|
|
68
|
+
* expanded; other `$(...)` references are left as they are. An `.xcconfig`
|
|
69
|
+
* referenced by `baseConfigurationReference` is not read.
|
|
70
|
+
*/
|
|
71
|
+
settings(configuration?: string): Record<string, string>;
|
|
72
|
+
}
|
|
73
|
+
/** Reads `<xcodeprojPath>/project.pbxproj` and interprets it. */
|
|
74
|
+
export declare function readXcodeProject(xcodeprojPath: string): XcodeProject;
|
|
75
|
+
/**
|
|
76
|
+
* Interprets an already parsed document as the project at `xcodeprojPath`
|
|
77
|
+
* (which need not exist; its parent directory is the source root).
|
|
78
|
+
*/
|
|
79
|
+
export declare function xcodeProjectFromDocument(doc: PbxprojDocument, xcodeprojPath: string): XcodeProject;
|
|
80
|
+
/**
|
|
81
|
+
* Replaces `$(NAME)` and `${NAME}` for the names in `variables`; other
|
|
82
|
+
* references (`$(inherited)`, `$(SDKROOT)`, ...) are kept verbatim.
|
|
83
|
+
*/
|
|
84
|
+
export declare function expandVariables(value: string, variables: Record<string, string>): string;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
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
|
+
/** Reads `<xcodeprojPath>/project.pbxproj` and interprets it. */
|
|
22
|
+
export function readXcodeProject(xcodeprojPath) {
|
|
23
|
+
const absolute = path.resolve(xcodeprojPath);
|
|
24
|
+
const text = fs.readFileSync(path.join(absolute, 'project.pbxproj'), 'utf8');
|
|
25
|
+
return xcodeProjectFromDocument(parsePbxproj(text), absolute);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Interprets an already parsed document as the project at `xcodeprojPath`
|
|
29
|
+
* (which need not exist; its parent directory is the source root).
|
|
30
|
+
*/
|
|
31
|
+
export function xcodeProjectFromDocument(doc, xcodeprojPath) {
|
|
32
|
+
return new ProjectReader(doc, path.resolve(xcodeprojPath)).read();
|
|
33
|
+
}
|
|
34
|
+
// ----- Field access helpers -------------------------------------------------
|
|
35
|
+
function str(value) {
|
|
36
|
+
return typeof value === 'string' ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
/** The string items of an array value (anything else yields no items). */
|
|
39
|
+
function strings(value) {
|
|
40
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
41
|
+
}
|
|
42
|
+
/** A build setting as one string: arrays (`GCC_PREPROCESSOR_DEFINITIONS`) are space-joined. */
|
|
43
|
+
function settingString(value) {
|
|
44
|
+
if (typeof value === 'string')
|
|
45
|
+
return value;
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return strings(value).join(' ');
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const SYNCHRONIZED_GROUP_ISAS = new Set(['PBXFileSystemSynchronizedRootGroup']);
|
|
51
|
+
const GROUP_ISAS = new Set(['PBXGroup', 'PBXVariantGroup', 'XCVersionGroup', ...SYNCHRONIZED_GROUP_ISAS]);
|
|
52
|
+
// ----- Reader -----------------------------------------------------------------
|
|
53
|
+
class ProjectReader {
|
|
54
|
+
doc;
|
|
55
|
+
xcodeprojPath;
|
|
56
|
+
objects;
|
|
57
|
+
project;
|
|
58
|
+
/** Source root: the directory containing the `.xcodeproj`. */
|
|
59
|
+
root;
|
|
60
|
+
/** Directory the main group resolves to: `root` plus the project's `projectDirPath` (normally empty). */
|
|
61
|
+
mainGroupDir;
|
|
62
|
+
/** Group id of every group's and file reference's parent, for `<group>`-relative paths. */
|
|
63
|
+
parentOf = new Map();
|
|
64
|
+
/**
|
|
65
|
+
* Position of every group and file reference in a depth-first walk of the main
|
|
66
|
+
* group: the order the Xcode navigator shows. Build phases list their files
|
|
67
|
+
* in creation order, which is meaningless, so we sort them by this instead.
|
|
68
|
+
*/
|
|
69
|
+
navigatorIndex = new Map();
|
|
70
|
+
resolvedPaths = new Map();
|
|
71
|
+
constructor(doc, xcodeprojPath) {
|
|
72
|
+
this.doc = doc;
|
|
73
|
+
this.xcodeprojPath = xcodeprojPath;
|
|
74
|
+
this.objects = doc.objects;
|
|
75
|
+
const project = this.objects[doc.rootObject];
|
|
76
|
+
if (!project || project.isa !== 'PBXProject') {
|
|
77
|
+
throw new Error(`project.pbxproj: rootObject ${doc.rootObject} is not a PBXProject`);
|
|
78
|
+
}
|
|
79
|
+
this.project = project;
|
|
80
|
+
this.root = path.dirname(this.xcodeprojPath);
|
|
81
|
+
this.mainGroupDir = path.resolve(this.root, str(project['projectDirPath']) ?? '');
|
|
82
|
+
for (const [id, object] of Object.entries(this.objects)) {
|
|
83
|
+
if (!GROUP_ISAS.has(object.isa))
|
|
84
|
+
continue;
|
|
85
|
+
for (const child of strings(object['children']))
|
|
86
|
+
this.parentOf.set(child, id);
|
|
87
|
+
}
|
|
88
|
+
const mainGroup = str(project['mainGroup']);
|
|
89
|
+
if (mainGroup)
|
|
90
|
+
this.indexGroup(mainGroup);
|
|
91
|
+
}
|
|
92
|
+
indexGroup(id) {
|
|
93
|
+
if (this.navigatorIndex.has(id))
|
|
94
|
+
return; // cyclic or shared child
|
|
95
|
+
this.navigatorIndex.set(id, this.navigatorIndex.size);
|
|
96
|
+
const object = this.objects[id];
|
|
97
|
+
if (object && GROUP_ISAS.has(object.isa)) {
|
|
98
|
+
for (const child of strings(object['children']))
|
|
99
|
+
this.indexGroup(child);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
read() {
|
|
103
|
+
const targets = [];
|
|
104
|
+
for (const id of strings(this.project['targets'])) {
|
|
105
|
+
const target = this.objects[id];
|
|
106
|
+
if (target?.isa === 'PBXNativeTarget')
|
|
107
|
+
targets.push(this.readTarget(id, target));
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
path: this.xcodeprojPath,
|
|
111
|
+
root: this.root,
|
|
112
|
+
objectVersion: this.doc.objectVersion,
|
|
113
|
+
targets,
|
|
114
|
+
appTargets: () => targets.filter((target) => target.productType === APPLICATION_PRODUCT_TYPE),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// --- Paths ---
|
|
118
|
+
/**
|
|
119
|
+
* Absolute path of a file reference or group, following `sourceTree`:
|
|
120
|
+
* `<group>` is relative to the parent group's directory, `SOURCE_ROOT` to the
|
|
121
|
+
* source root, `<absolute>` is absolute; anything else (`BUILT_PRODUCTS_DIR`,
|
|
122
|
+
* `SDKROOT`, `DEVELOPER_DIR`) falls back to the source root. Groups without a
|
|
123
|
+
* `path` resolve to their parent's directory.
|
|
124
|
+
*/
|
|
125
|
+
resolvePath(id) {
|
|
126
|
+
if (this.resolvedPaths.has(id))
|
|
127
|
+
return this.resolvedPaths.get(id);
|
|
128
|
+
this.resolvedPaths.set(id, undefined); // guards against cyclic groups
|
|
129
|
+
const object = this.objects[id];
|
|
130
|
+
if (!object)
|
|
131
|
+
return undefined;
|
|
132
|
+
const relative = str(object['path']);
|
|
133
|
+
let base;
|
|
134
|
+
switch (str(object['sourceTree']) ?? '<group>') {
|
|
135
|
+
case '<group>': {
|
|
136
|
+
const parent = this.parentOf.get(id);
|
|
137
|
+
base = (parent ? this.resolvePath(parent) : undefined) ?? this.mainGroupDir;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case '<absolute>':
|
|
141
|
+
base = '/';
|
|
142
|
+
break;
|
|
143
|
+
case 'SOURCE_ROOT':
|
|
144
|
+
default:
|
|
145
|
+
base = this.root;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
const resolved = relative ? path.resolve(base, relative) : base;
|
|
149
|
+
this.resolvedPaths.set(id, resolved);
|
|
150
|
+
return resolved;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Absolute paths of the files a build phase's entry refers to. A
|
|
154
|
+
* `PBXVariantGroup` (localized resources) contributes each of its variants.
|
|
155
|
+
*/
|
|
156
|
+
buildFilePaths(buildFileId) {
|
|
157
|
+
const buildFile = this.objects[buildFileId];
|
|
158
|
+
const fileRef = str(buildFile?.['fileRef']);
|
|
159
|
+
if (!fileRef)
|
|
160
|
+
return [];
|
|
161
|
+
const ref = this.objects[fileRef];
|
|
162
|
+
if (!ref)
|
|
163
|
+
return [];
|
|
164
|
+
if (ref.isa === 'PBXVariantGroup') {
|
|
165
|
+
return strings(ref['children'])
|
|
166
|
+
.map((child) => this.resolvePath(child))
|
|
167
|
+
.filter((p) => p !== undefined);
|
|
168
|
+
}
|
|
169
|
+
const resolved = this.resolvePath(fileRef);
|
|
170
|
+
return resolved ? [resolved] : [];
|
|
171
|
+
}
|
|
172
|
+
/** The files of the target's build phases with the given `isa`, in navigator order. */
|
|
173
|
+
buildPhaseFiles(target, isa) {
|
|
174
|
+
const buildFileIds = [];
|
|
175
|
+
for (const phaseId of strings(target['buildPhases'])) {
|
|
176
|
+
const phase = this.objects[phaseId];
|
|
177
|
+
if (phase?.isa === isa)
|
|
178
|
+
buildFileIds.push(...strings(phase['files']));
|
|
179
|
+
}
|
|
180
|
+
const position = (buildFileId) => {
|
|
181
|
+
const fileRef = str(this.objects[buildFileId]?.['fileRef']);
|
|
182
|
+
return (fileRef ? this.navigatorIndex.get(fileRef) : undefined) ?? Number.MAX_SAFE_INTEGER;
|
|
183
|
+
};
|
|
184
|
+
return buildFileIds
|
|
185
|
+
.map((id, order) => ({ id, order, position: position(id) }))
|
|
186
|
+
.sort((a, b) => a.position - b.position || a.order - b.order)
|
|
187
|
+
.flatMap((entry) => this.buildFilePaths(entry.id));
|
|
188
|
+
}
|
|
189
|
+
// --- Synchronized folders (Xcode 16) ---
|
|
190
|
+
/**
|
|
191
|
+
* The `.swift` files and `.xcassets` folders under the target's synchronized
|
|
192
|
+
* folders, read from disk (sorted, recursive), minus the target's membership
|
|
193
|
+
* exceptions. A folder missing on disk contributes nothing.
|
|
194
|
+
*/
|
|
195
|
+
synchronizedMembers(targetId, target) {
|
|
196
|
+
const sources = [];
|
|
197
|
+
const assetCatalogs = [];
|
|
198
|
+
for (const groupId of strings(target['fileSystemSynchronizedGroups'])) {
|
|
199
|
+
const group = this.objects[groupId];
|
|
200
|
+
if (!group || !SYNCHRONIZED_GROUP_ISAS.has(group.isa))
|
|
201
|
+
continue;
|
|
202
|
+
const dir = this.resolvePath(groupId);
|
|
203
|
+
if (!dir)
|
|
204
|
+
continue;
|
|
205
|
+
const excluded = new Set();
|
|
206
|
+
for (const exceptionId of strings(group['exceptions'])) {
|
|
207
|
+
const exceptions = this.objects[exceptionId];
|
|
208
|
+
if (exceptions?.isa !== 'PBXFileSystemSynchronizedBuildFileExceptionSet')
|
|
209
|
+
continue;
|
|
210
|
+
if (str(exceptions['target']) !== targetId)
|
|
211
|
+
continue;
|
|
212
|
+
for (const relative of strings(exceptions['membershipExceptions']))
|
|
213
|
+
excluded.add(path.resolve(dir, relative));
|
|
214
|
+
}
|
|
215
|
+
walkSynchronizedFolder(dir, (entry) => {
|
|
216
|
+
if (excluded.has(entry))
|
|
217
|
+
return false;
|
|
218
|
+
if (entry.endsWith('.swift'))
|
|
219
|
+
sources.push(entry);
|
|
220
|
+
else if (entry.endsWith('.xcassets'))
|
|
221
|
+
assetCatalogs.push(entry);
|
|
222
|
+
return true;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return { sources, assetCatalogs };
|
|
226
|
+
}
|
|
227
|
+
// --- Build settings ---
|
|
228
|
+
configurations(listId) {
|
|
229
|
+
const list = listId ? this.objects[listId] : undefined;
|
|
230
|
+
if (list?.isa !== 'XCConfigurationList')
|
|
231
|
+
return [];
|
|
232
|
+
return strings(list['buildConfigurations'])
|
|
233
|
+
.map((id) => this.objects[id])
|
|
234
|
+
.filter((config) => config?.isa === 'XCBuildConfiguration');
|
|
235
|
+
}
|
|
236
|
+
defaultConfigurationName(target) {
|
|
237
|
+
const listId = str(target['buildConfigurationList']);
|
|
238
|
+
const names = this.configurations(listId).map((config) => str(config['name']));
|
|
239
|
+
if (names.includes('Debug'))
|
|
240
|
+
return 'Debug';
|
|
241
|
+
const list = listId ? this.objects[listId] : undefined;
|
|
242
|
+
return str(list?.['defaultConfigurationName']) ?? names[0];
|
|
243
|
+
}
|
|
244
|
+
/** The `buildSettings` of the configuration named `name` in a configuration list. */
|
|
245
|
+
buildSettings(listId, name) {
|
|
246
|
+
const config = this.configurations(listId).find((candidate) => str(candidate['name']) === name);
|
|
247
|
+
const settings = config?.['buildSettings'];
|
|
248
|
+
return isDict(settings) ? settings : {};
|
|
249
|
+
}
|
|
250
|
+
/** Project-level then target-level settings for a configuration, as raw strings. */
|
|
251
|
+
rawSettings(target, configuration) {
|
|
252
|
+
const merged = {};
|
|
253
|
+
const layers = [
|
|
254
|
+
this.buildSettings(str(this.project['buildConfigurationList']), configuration),
|
|
255
|
+
this.buildSettings(str(target['buildConfigurationList']), configuration),
|
|
256
|
+
];
|
|
257
|
+
for (const layer of layers) {
|
|
258
|
+
for (const [key, value] of Object.entries(layer)) {
|
|
259
|
+
const text = settingString(value);
|
|
260
|
+
if (text !== undefined)
|
|
261
|
+
merged[key] = text;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return merged;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Expands the variables this package knows about in every value. `PRODUCT_NAME`
|
|
268
|
+
* is expanded first (with `$(TARGET_NAME)` only) because the other values may
|
|
269
|
+
* refer to it.
|
|
270
|
+
*/
|
|
271
|
+
expandSettings(targetName, raw) {
|
|
272
|
+
const base = {
|
|
273
|
+
TARGET_NAME: targetName,
|
|
274
|
+
SRCROOT: this.root,
|
|
275
|
+
PROJECT_DIR: this.root,
|
|
276
|
+
};
|
|
277
|
+
const productName = expandVariables(raw['PRODUCT_NAME'] ?? '$(TARGET_NAME)', base) || targetName;
|
|
278
|
+
const variables = { ...base, PRODUCT_NAME: productName };
|
|
279
|
+
const settings = {};
|
|
280
|
+
for (const [key, value] of Object.entries(raw))
|
|
281
|
+
settings[key] = expandVariables(value, variables);
|
|
282
|
+
return settings;
|
|
283
|
+
}
|
|
284
|
+
// --- Targets ---
|
|
285
|
+
readTarget(targetId, target) {
|
|
286
|
+
const name = str(target['name']) ?? str(target['productName']) ?? targetId;
|
|
287
|
+
const defaultConfiguration = this.defaultConfigurationName(target);
|
|
288
|
+
const settingsCache = new Map();
|
|
289
|
+
const settings = (configuration = defaultConfiguration ?? '') => {
|
|
290
|
+
let result = settingsCache.get(configuration);
|
|
291
|
+
if (!result) {
|
|
292
|
+
result = this.expandSettings(name, this.rawSettings(target, configuration));
|
|
293
|
+
settingsCache.set(configuration, result);
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
};
|
|
297
|
+
const defaults = settings();
|
|
298
|
+
const phaseSources = this.buildPhaseFiles(target, 'PBXSourcesBuildPhase').filter((p) => p.endsWith('.swift'));
|
|
299
|
+
const resources = this.buildPhaseFiles(target, 'PBXResourcesBuildPhase');
|
|
300
|
+
const synchronized = this.synchronizedMembers(targetId, target);
|
|
301
|
+
const productName = defaults['PRODUCT_NAME'] || str(target['productName']) || name;
|
|
302
|
+
const result = {
|
|
303
|
+
name,
|
|
304
|
+
productType: str(target['productType']) ?? '',
|
|
305
|
+
productName,
|
|
306
|
+
sources: [...phaseSources, ...synchronized.sources],
|
|
307
|
+
resources,
|
|
308
|
+
assetCatalogs: [...resources.filter((p) => p.endsWith('.xcassets')), ...synchronized.assetCatalogs],
|
|
309
|
+
dependencies: this.dependencyNames(target),
|
|
310
|
+
packageProducts: this.packageProductNames(target),
|
|
311
|
+
settings,
|
|
312
|
+
};
|
|
313
|
+
const bundleIdentifier = defaults['PRODUCT_BUNDLE_IDENTIFIER'];
|
|
314
|
+
if (bundleIdentifier)
|
|
315
|
+
result.bundleIdentifier = bundleIdentifier;
|
|
316
|
+
const displayName = defaults['INFOPLIST_KEY_CFBundleDisplayName'] ?? this.infoPlistDisplayName(defaults['INFOPLIST_FILE']);
|
|
317
|
+
if (displayName)
|
|
318
|
+
result.displayName = displayName;
|
|
319
|
+
if (defaults['ASSETCATALOG_COMPILER_APPICON_NAME'])
|
|
320
|
+
result.appIconName = defaults['ASSETCATALOG_COMPILER_APPICON_NAME'];
|
|
321
|
+
if (defaults['SWIFT_VERSION'])
|
|
322
|
+
result.swiftVersion = defaults['SWIFT_VERSION'];
|
|
323
|
+
if (defaults['IPHONEOS_DEPLOYMENT_TARGET'])
|
|
324
|
+
result.deploymentTarget = defaults['IPHONEOS_DEPLOYMENT_TARGET'];
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
/** `CFBundleDisplayName` from an `INFOPLIST_FILE` (relative to the source root) when it is a readable XML plist. */
|
|
328
|
+
infoPlistDisplayName(infoPlistFile) {
|
|
329
|
+
if (!infoPlistFile)
|
|
330
|
+
return undefined;
|
|
331
|
+
let text;
|
|
332
|
+
try {
|
|
333
|
+
text = fs.readFileSync(path.resolve(this.root, infoPlistFile), 'utf8');
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
const plist = parseXmlPlist(text);
|
|
339
|
+
if (typeof plist !== 'object' || plist === null || Array.isArray(plist))
|
|
340
|
+
return undefined;
|
|
341
|
+
const displayName = plist['CFBundleDisplayName'];
|
|
342
|
+
return typeof displayName === 'string' && displayName !== '' ? displayName : undefined;
|
|
343
|
+
}
|
|
344
|
+
/** Names of the target dependencies: the referenced target's name, else the dependency's own `name`. */
|
|
345
|
+
dependencyNames(target) {
|
|
346
|
+
const names = [];
|
|
347
|
+
for (const dependencyId of strings(target['dependencies'])) {
|
|
348
|
+
const dependency = this.objects[dependencyId];
|
|
349
|
+
if (dependency?.isa !== 'PBXTargetDependency')
|
|
350
|
+
continue;
|
|
351
|
+
const dependedId = str(dependency['target']);
|
|
352
|
+
const depended = dependedId ? this.objects[dependedId] : undefined;
|
|
353
|
+
const name = str(depended?.['name']) ?? str(dependency['name']) ?? this.packageProductName(str(dependency['productRef']));
|
|
354
|
+
if (name)
|
|
355
|
+
names.push(name);
|
|
356
|
+
}
|
|
357
|
+
return names;
|
|
358
|
+
}
|
|
359
|
+
packageProductName(productRefId) {
|
|
360
|
+
const product = productRefId ? this.objects[productRefId] : undefined;
|
|
361
|
+
return product?.isa === 'XCSwiftPackageProductDependency' ? str(product['productName']) : undefined;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Swift package products the target links: its `packageProductDependencies`,
|
|
365
|
+
* plus any `productRef` of a build file in its Frameworks phase (older projects).
|
|
366
|
+
*/
|
|
367
|
+
packageProductNames(target) {
|
|
368
|
+
const names = new Set();
|
|
369
|
+
for (const id of strings(target['packageProductDependencies'])) {
|
|
370
|
+
const name = this.packageProductName(id);
|
|
371
|
+
if (name)
|
|
372
|
+
names.add(name);
|
|
373
|
+
}
|
|
374
|
+
for (const phaseId of strings(target['buildPhases'])) {
|
|
375
|
+
const phase = this.objects[phaseId];
|
|
376
|
+
if (phase?.isa !== 'PBXFrameworksBuildPhase')
|
|
377
|
+
continue;
|
|
378
|
+
for (const buildFileId of strings(phase['files'])) {
|
|
379
|
+
const name = this.packageProductName(str(this.objects[buildFileId]?.['productRef']));
|
|
380
|
+
if (name)
|
|
381
|
+
names.add(name);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return [...names];
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// ----- Helpers ----------------------------------------------------------------
|
|
388
|
+
/**
|
|
389
|
+
* Replaces `$(NAME)` and `${NAME}` for the names in `variables`; other
|
|
390
|
+
* references (`$(inherited)`, `$(SDKROOT)`, ...) are kept verbatim.
|
|
391
|
+
*/
|
|
392
|
+
export function expandVariables(value, variables) {
|
|
393
|
+
return value.replace(/\$[({]([A-Za-z_][A-Za-z0-9_]*)[)}]/g, (whole, name) => variables[name] ?? whole);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Visits every entry under `dir` in sorted order, depth first. `visit` returns
|
|
397
|
+
* whether to descend into a directory; `.xcassets` bundles and dot-entries are
|
|
398
|
+
* never descended into. Unreadable directories are skipped.
|
|
399
|
+
*/
|
|
400
|
+
function walkSynchronizedFolder(dir, visit) {
|
|
401
|
+
let entries;
|
|
402
|
+
try {
|
|
403
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
409
|
+
for (const entry of entries) {
|
|
410
|
+
if (entry.name.startsWith('.'))
|
|
411
|
+
continue;
|
|
412
|
+
const full = path.join(dir, entry.name);
|
|
413
|
+
const descend = visit(full);
|
|
414
|
+
if (descend && entry.isDirectory() && !entry.name.endsWith('.xcassets'))
|
|
415
|
+
walkSynchronizedFolder(full, visit);
|
|
416
|
+
}
|
|
417
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swiftbrowser/xcodeproj",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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/dabbott/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
|
+
}
|