lecodes-cli 2.4.1 → 2.4.3

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.
Files changed (54) hide show
  1. package/dist/index.js +36134 -11203
  2. package/package.json +10 -9
  3. package/runtime/sdk-types.json +1 -1
  4. package/runtime/web/VERSION.json +3 -3
  5. package/runtime/web/assets/ar-D6oDL4pw.js +1 -0
  6. package/runtime/web/assets/ar-mVXX3kRQ.js +2 -0
  7. package/runtime/web/assets/qrAnchorWorker-Cb5E8cF-.js +1 -0
  8. package/runtime/web/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
  9. package/runtime/web/index.html +1 -0
  10. package/runtime/web/player.js +12 -12
  11. package/runtime/web-lite/assets/{index-B2zEJlWS.js → index-oPsDD47E.js} +1 -1
  12. package/runtime/web-lite/assets/scene3d-BAl_QQyi.js +1105 -0
  13. package/runtime/web-lite/features.json +2 -2
  14. package/runtime/web-lite/index.html +2 -1
  15. package/runtime/web-lite/lite.js +9 -9
  16. package/runtime/web-lite/plugins/ar/_virtual_lecodes-half-resources-CNYmVR88.js +1 -0
  17. package/runtime/web-lite/plugins/ar/assets/qrAnchorWorker-DcxVKAqo.js +1 -0
  18. package/runtime/web-lite/plugins/ar/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
  19. package/runtime/web-lite/plugins/ar/index-C2uuuLhz.js +2 -0
  20. package/runtime/web-lite/plugins.json +1 -1
  21. package/src/commands/app/android.ts +68 -19
  22. package/src/commands/app/index.ts +84 -17
  23. package/src/commands/app/shared.ts +157 -24
  24. package/src/commands/app/splash.ts +251 -0
  25. package/src/commands/app/templates/android.ts +148 -25
  26. package/src/commands/app/templates/ios.ts +123 -12
  27. package/src/commands/init/index.ts +195 -195
  28. package/src/commands/plugin.ts +3 -3
  29. package/src/commands/push.ts +2 -2
  30. package/src/commands/shared.ts +79 -79
  31. package/src/commands/thumbs.ts +185 -185
  32. package/src/commands/web.ts +12 -5
  33. package/src/compile/collect.ts +2 -3
  34. package/src/compile/collectLocal.ts +13 -2
  35. package/src/compile/designCompile.ts +2 -3
  36. package/src/compile/projectCompile.ts +3 -0
  37. package/src/compile/screenEntry.ts +123 -123
  38. package/src/dev/devServer.ts +63 -7
  39. package/src/dev/https.ts +50 -0
  40. package/src/dev/serve.ts +15 -9
  41. package/src/dev/webRunner.ts +11 -3
  42. package/src/platform/serverDiff.ts +2 -3
  43. package/src/plugin/emitPackage.ts +18 -5
  44. package/src/plugin/emitWeb.ts +17 -8
  45. package/src/plugin/firstParty.ts +30 -0
  46. package/src/plugin/generate.ts +52 -24
  47. package/src/plugin/manifest.ts +66 -7
  48. package/src/project/ignore.ts +164 -164
  49. package/src/project/localFiles.ts +44 -7
  50. package/src/web/build.ts +33 -9
  51. package/src/web/halves.ts +76 -0
  52. package/runtime/web/assets/ar-CHvaraG4.js +0 -1
  53. package/runtime/web-lite/assets/scene3d-Bew8g7Ws.js +0 -1081
  54. package/runtime/web-lite/plugins/ar/index-CHvaraG4.js +0 -1
@@ -358,18 +358,27 @@ export interface PluginGL {
358
358
  /** The pose of the scene's camera (its world matrix). */
359
359
  setCameraMatrix(scene: number, m16: ArrayLike<number>): void
360
360
  /** A projection the half computes (column-major, OpenGL clip conventions), with the near / far it
361
- * was built with, in place of the scene's fov projection. */
361
+ * was built with, in place of the scene's fov projection: drawn as it is — an off-centre one (a
362
+ * camera's intrinsics) too. Made for one view: a change of \`viewport\` wants a new one. */
362
363
  setProjection(scene: number, m16: ArrayLike<number>, near: number, far: number): void
363
364
  /** Back to the scene's own (fov) projection. */
364
365
  clearProjection(scene: number): void
365
- /** The scene's background: a playing video behind the scene, through \`uv\` — the affine map from
366
- * the viewport's normalized coordinates (0,0 top-left) to the video's, row-major
367
- * [a, b, tx, c, d, ty] (identity by default). null = none. */
368
- setBackground(scene: number, video: HTMLVideoElement | null, uv?: ArrayLike<number>): void
369
- /** The web has no external textures: a video is handed to setBackground as it is. Always null. */
366
+ /** The scene's background, behind the scene: a playing video (a camera's stream), or a canvas the
367
+ * half (or an engine it runs) draws the camera's picture on; null = none. \`uv\` = the affine map
368
+ * from the viewport's normalized coordinates (0,0 top-left) to the picture's, row-major
369
+ * [a, b, tx, c, d, ty] (identity by default). Web Lite shows the element itself: moved under the
370
+ * scene's canvas (its inline style replaced: absolute, the scene's box filled, \`object-fit: cover\`
371
+ * — \`uv\` is not read; a canvas sized to \`viewport\` × devicePixelRatio is shown 1:1), taken out of
372
+ * the page when it is replaced or cleared. The web player (a preview) shows a video only. */
373
+ setBackground(scene: number, source: HTMLVideoElement | HTMLCanvasElement | null, uv?: ArrayLike<number>): void
374
+ /** The web has no external textures: a video or a canvas is handed to setBackground as it is.
375
+ * Always null. */
370
376
  createExternalTexture(): null
371
- /** \`body\` before each frame (the page's animation frame), until the returned function is called —
372
- * or the world swap. */
377
+ /** \`body\` before each frame of the 3D engine, until the returned function is called — or the
378
+ * world swap: in the host's own animation frame, before the tick and the render, so what a body
379
+ * sets (a pose, a projection) is that frame's picture. A requestAnimationFrame callback of the
380
+ * half's own is a frame late unless the browser runs it before the host's (callbacks run in the
381
+ * order they were asked for). Web Lite runs the bodies while a scene is on show. */
373
382
  onFrame(body: () => void): () => void
374
383
  /** The composite of the scene's next frame — the 3D picture, not the UI over it: RGBA8, top row
375
384
  * first, width, height; null (0 × 0) when the scene is not the one presented. */
@@ -0,0 +1,30 @@
1
+ // The first-party plugins as the SDK carries them: `plugins/gen/<id>/` of its source tree — the
2
+ // contract.d.ts and the app's wrapper (sdk/), copies of the plugin repo's (core's
3
+ // scripts/vendor-plugins.ts). A project's HALF FOLDER (`implements` in its manifest) implements one
4
+ // of these contracts: the one of the SDK this CLI compiles with, so the glue `lecodes plugin gen`
5
+ // writes for the half and the wrapper in the bundle are of one contract.
6
+
7
+ import { existsSync, readFileSync, readdirSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import { sdkSourceDir } from "../compile/projectCompile"
10
+
11
+ const ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
12
+
13
+ /** The SDK's copy of a first-party plugin's contract, or null when the SDK carries no such plugin. */
14
+ export const firstPartyContractFile = (id: string): string | null => {
15
+ if (!sdkSourceDir || !ID.test(id)) return null
16
+ const file = join(sdkSourceDir, "plugins", "gen", id, "contract.d.ts")
17
+ return existsSync(file) ? file : null
18
+ }
19
+
20
+ /** The first-party plugins the SDK carries, by id. */
21
+ export const firstPartyIds = (): string[] => {
22
+ if (!sdkSourceDir) return []
23
+ const gen = join(sdkSourceDir, "plugins", "gen")
24
+ return existsSync(gen) ? readdirSync(gen).filter((id) => existsSync(join(gen, id, "contract.d.ts"))).sort() : []
25
+ }
26
+
27
+ /** A copy's text as the plugin wrote it: the vendoring points the import of the SDK at its own file
28
+ * (`../../../plugin`), a contract reads `lecodes-sdk/plugin` and nothing else. */
29
+ export const firstPartyContractText = (file: string): string =>
30
+ readFileSync(file, "utf8").replace(/from "(?:\.\.\/)+plugin"/g, `from "lecodes-sdk/plugin"`)
@@ -16,7 +16,8 @@ import { EmitError, emitSdk } from "./emitSdk"
16
16
  import { emitSwift } from "./emitSwift"
17
17
  import { emitWeb } from "./emitWeb"
18
18
  import { lower } from "./lower"
19
- import { MANIFEST_FILE, ManifestError, readManifestIfAny, type PluginManifest } from "./manifest"
19
+ import { CONTRACT_FILE, MANIFEST_FILE, ManifestError, readManifestIfAny, type PluginManifest } from "./manifest"
20
+ import { firstPartyContractFile, firstPartyContractText } from "./firstParty"
20
21
  import { missingDeclarations, usesFeatures, usesPermissions, type PermissionKind } from "./permissions"
21
22
  import { DEFAULT_SDK_PATH, PACKAGE_FILE, PackageError, emitPackageSwift } from "./emitPackage"
22
23
 
@@ -24,19 +25,31 @@ import { DEFAULT_SDK_PATH, PACKAGE_FILE, PackageError, emitPackageSwift } from "
24
25
  // compared and pasted; `\` is Windows' own).
25
26
  const shown = (from: string, to: string): string => relative(from, to).split(sep).join("/")
26
27
 
27
- export const CONTRACT_FILE = "contract.d.ts"
28
+ export { CONTRACT_FILE }
28
29
 
29
30
  export type PluginDir = { id: string, dir: string }
30
31
 
31
- /** The plugins under `path`: itself when it holds a contract, else every `plugins/<id>/` that does. */
32
+ /** The first-party plugin a folder's manifest says it implements (a project's half folder), or null.
33
+ * Read loosely: what the manifest holds is readManifest's to say. */
34
+ const implementsOf = (dir: string): string | null => {
35
+ try {
36
+ const raw = JSON.parse(readFileSync(join(dir, MANIFEST_FILE), "utf8").replace(/^/, "")) as { implements?: unknown }
37
+ return typeof raw.implements === "string" ? raw.implements : null
38
+ } catch { return null }
39
+ }
40
+
41
+ /** A plugin's folder: one with its contract, or a half folder that implements a first-party one's. */
42
+ const isPluginDir = (dir: string): boolean => existsSync(join(dir, CONTRACT_FILE)) || implementsOf(dir) !== null
43
+
44
+ /** The plugins under `path`: itself when it is one, else every `plugins/<id>/` that is. */
32
45
  export const findPlugins = (path: string): PluginDir[] => {
33
46
  const root = resolve(path)
34
- if (existsSync(join(root, CONTRACT_FILE))) return [{ id: basename(root), dir: root }]
47
+ if (isPluginDir(root)) return [{ id: basename(root), dir: root }]
35
48
  const holder = join(root, "plugins")
36
49
  if (!existsSync(holder)) return []
37
50
  return readdirSync(holder).sort()
38
51
  .map(id => ({ id, dir: join(holder, id) }))
39
- .filter(p => statSync(p.dir).isDirectory() && existsSync(join(p.dir, CONTRACT_FILE)))
52
+ .filter(p => statSync(p.dir).isDirectory() && isPluginDir(p.dir))
40
53
  }
41
54
 
42
55
  /** The CLI carries no TypeScript: a contract is read with the one its author has — the plugin's own
@@ -54,34 +67,39 @@ export const loadTypescript = (dir: string): typeof tsModule => {
54
67
 
55
68
  export type Generated = { file: string, text: string }
56
69
 
57
- /** Everything one plugin's contract generates, as `path → text`. Throws ContractError / EmitError. */
70
+ /** Everything one plugin's contract generates, as `path → text`. Throws ContractError / EmitError /
71
+ * ManifestError. A half folder (`implements`) is generated from the contract of the plugin it
72
+ * implements, the SDK's copy (firstParty.ts), and under that plugin's id: `web/ar.gen.ts` registers
73
+ * the service "ar"; it has no sdk/ — the app's side is the SDK's wrapper. */
58
74
  export const generatePlugin = (ts: typeof tsModule, plugin: PluginDir): { contract: Contract, manifest: PluginManifest | null, files: Generated[] } => {
59
- const file = join(plugin.dir, CONTRACT_FILE)
60
- const contract = readContract(ts, file, readFileSync(file, "utf8"))
61
75
  // The manifest is held to the folders the plugin has: a half the manifest does not name is built
62
76
  // into no host.
63
77
  const manifest = readManifestIfAny(plugin.dir, plugin.id)
78
+ const implemented = manifest?.implements
79
+ const file = implemented ? firstPartyContractFile(implemented)! : join(plugin.dir, CONTRACT_FILE)
80
+ const contract = readContract(ts, file, implemented ? firstPartyContractText(file) : readFileSync(file, "utf8"))
64
81
  checkPermissions(plugin, contract, manifest)
65
- const handWritten = existsSync(join(plugin.dir, "sdk", `${plugin.id}.ts`))
82
+ const id = implemented ?? plugin.id
83
+ const handWritten = existsSync(join(plugin.dir, "sdk", `${id}.ts`))
66
84
  const single = contract.channels.length === 1
67
- const files: Generated[] = contract.channels.map(channel => ({
85
+ const files: Generated[] = implemented ? [] : contract.channels.map(channel => ({
68
86
  // One channel is the rule; a contract with several names each file after its channel.
69
- file: join(plugin.dir, "sdk", single ? `${plugin.id}.gen.ts` : `${plugin.id}.${channel.iface}.gen.ts`),
70
- text: emitSdk(contract, channel, { id: plugin.id, handWritten }),
87
+ file: join(plugin.dir, "sdk", single ? `${id}.gen.ts` : `${id}.${channel.iface}.gen.ts`),
88
+ text: emitSdk(contract, channel, { id, handWritten }),
71
89
  }))
72
90
  // A platform's file is generated where its FOLDER exists: the plugin says which platforms it has
73
91
  // by having them.
74
92
  if (existsSync(join(plugin.dir, "ios"))) {
75
93
  const name = single ? contract.channels[0]!.iface : contract.channels[0]!.iface + "Plugin"
76
- files.push({ file: join(plugin.dir, "ios", `${name}.gen.swift`), text: emitSwift(lower(contract), { id: plugin.id }) })
94
+ files.push({ file: join(plugin.dir, "ios", `${name}.gen.swift`), text: emitSwift(lower(contract), { id }) })
77
95
  }
78
96
  if (existsSync(join(plugin.dir, "android"))) {
79
97
  const name = single ? contract.channels[0]!.iface : contract.channels[0]!.iface + "Plugin"
80
- const pkg = kotlinPackage(join(plugin.dir, "android"))
81
- files.push({ file: join(pkg.dir, `${name}.gen.kt`), text: emitKotlin(lower(contract), { id: plugin.id, package: pkg.name }) })
98
+ const pkg = kotlinPackage(join(plugin.dir, "android"), manifest?.android?.register)
99
+ files.push({ file: join(pkg.dir, `${name}.gen.kt`), text: emitKotlin(lower(contract), { id, package: pkg.name }) })
82
100
  }
83
101
  if (existsSync(join(plugin.dir, "web"))) {
84
- files.push({ file: join(plugin.dir, "web", `${plugin.id}.gen.ts`), text: emitWeb(lower(contract), { id: plugin.id }) })
102
+ files.push({ file: join(plugin.dir, "web", `${id}.gen.ts`), text: emitWeb(lower(contract), { id }) })
85
103
  const foreign = hostImports(join(plugin.dir, "web"))
86
104
  if (foreign.length) throw new EmitError(`web/ imports the host — a web half depends on its generated file, the document it is handed and the browser:\n${foreign.map(f => ` ${f}`).join("\n")}`)
87
105
  }
@@ -131,9 +149,17 @@ export const hostImports = (web: string): string[] => {
131
149
 
132
150
  /** Where a plugin's Kotlin lives: `android/` is laid out as a Gradle module (its manifest and
133
151
  * resources travel with the sources), and the generated file is a file of the plugin's PACKAGE —
134
- * the one folder under `src/main/java` that the path narrows down to. */
135
- export const kotlinPackage = (android: string): { dir: string, name: string } => {
152
+ * the package of the type that registers the half (the manifest's `android.register`, which is
153
+ * also the module's namespace), when its folder is there; else the one folder under
154
+ * `src/main/java` that the path narrows down to (a plugin with no manifest yet). A half may hold
155
+ * other packages beside its own (a vendored library's JNI class): they do not move the file. */
156
+ export const kotlinPackage = (android: string, register?: string): { dir: string, name: string } => {
136
157
  const root = join(android, "src", "main", "java")
158
+ if (register) {
159
+ const name = register.split(".").slice(0, -1).join(".")
160
+ const dir = join(root, ...name.split("."))
161
+ if (name && existsSync(dir)) return { dir, name }
162
+ }
137
163
  let dir = root
138
164
  while (existsSync(dir)) {
139
165
  const entries = readdirSync(dir).filter(e => !e.startsWith("."))
@@ -164,7 +190,8 @@ export const repoSdkPath = (root: string): string => {
164
190
  }
165
191
 
166
192
  export type GenReport = {
167
- plugins: { id: string, contract: Contract, written: string[], stale: string[], fresh: string[] }[]
193
+ /** `implements`: a half folder's first-party plugin, whose contract it was generated from. */
194
+ plugins: { id: string, implements?: string, contract: Contract, written: string[], stale: string[], fresh: string[] }[]
168
195
  /** The repo's own files (its Package.swift), when the path is a repo of plugins. */
169
196
  repo: { written: string[], stale: string[], fresh: string[] }
170
197
  /** Contracts that did not read or emit, as printable text. */
@@ -175,7 +202,7 @@ export type GenReport = {
175
202
  * to `path`. A failed contract does not stop the others. */
176
203
  export const generate = (path: string, opts: { check?: boolean } = {}): GenReport => {
177
204
  const plugins = findPlugins(path)
178
- if (plugins.length === 0) throw new CliError(`No plugin under ${resolve(path)}: a plugin is a directory with a ${CONTRACT_FILE} (one, or plugins/<id>/ of a repo).`)
205
+ if (plugins.length === 0) throw new CliError(`No plugin under ${resolve(path)}: a plugin is a directory with a ${CONTRACT_FILE}, or a half folder whose ${MANIFEST_FILE} says what it \`implements\` (one, or plugins/<id>/ of a repo or a project).`)
179
206
  const ts = loadTypescript(plugins[0]!.dir)
180
207
  const root = resolve(path)
181
208
  const report: GenReport = { plugins: [], repo: { written: [], stale: [], fresh: [] }, failures: [] }
@@ -194,7 +221,7 @@ export const generate = (path: string, opts: { check?: boolean } = {}): GenRepor
194
221
  try {
195
222
  const { contract, manifest, files } = generatePlugin(ts, plugin)
196
223
  if (manifest) manifests.push({ id: plugin.id, manifest })
197
- const entry = { id: plugin.id, contract, written: [] as string[], stale: [] as string[], fresh: [] as string[] }
224
+ const entry = { id: plugin.id, implements: manifest?.implements, contract, written: [] as string[], stale: [] as string[], fresh: [] as string[] }
198
225
  for (const f of files) place(f, entry)
199
226
  report.plugins.push(entry)
200
227
  } catch (e) {
@@ -206,9 +233,10 @@ export const generate = (path: string, opts: { check?: boolean } = {}): GenRepor
206
233
  }
207
234
 
208
235
  // A repo of plugins (not one plugin): its Package.swift, from every manifest that read. Written
209
- // only when every contract made it — a repo with a broken plugin keeps the package it has.
210
- const isRepo = !existsSync(join(root, CONTRACT_FILE))
211
- if (isRepo && report.failures.length === 0 && manifests.some(m => m.manifest.ios)) {
236
+ // only when every contract made it — a repo with a broken plugin keeps the package it has — and
237
+ // only when one has an iOS half: a project whose plugins are web halves has no package to make.
238
+ const isRepo = !(plugins.length === 1 && plugins[0]!.dir === root)
239
+ if (isRepo && report.failures.length === 0 && manifests.some(m => m.manifest.ios !== null)) {
212
240
  try {
213
241
  place({ file: join(root, PACKAGE_FILE), text: emitPackageSwift(manifests, repoSdkPath(root)) }, report.repo)
214
242
  } catch (e) {
@@ -1,19 +1,26 @@
1
1
  // A plugin's manifest (`lecodes-plugin.json`, 2.x): what a host needs to BUILD the plugin in — the
2
2
  // type that registers each half, what a half asks of its platform (usage strings, entitlements,
3
- // packages, Gradle dependencies, bundler resources). What the plugin DOES is not here: the channels,
4
- // their names and their types are the contract's (contract.d.ts), the folders are the convention
5
- // (`sdk/`, `ios/`, `android/`, `web/`).
3
+ // packages, prebuilt binaries, Gradle dependencies, bundler resources). What the plugin DOES is not
4
+ // here: the channels, their names and their types are the contract's (contract.d.ts), the folders
5
+ // are the convention (`sdk/`, `ios/`, `android/`, `web/`).
6
6
  //
7
7
  // A platform is a key of the manifest, ALWAYS: an object where the plugin has a half, `null` where
8
8
  // it has none. A missing key is refused — "not supported" is a statement, not an omission.
9
9
  //
10
10
  // Desktop plugins are another system (a prebuilt library behind a C ABI) with a manifest of their
11
11
  // own shape; nothing of it is read here.
12
+ //
13
+ // A HALF FOLDER of a project (`implements: "<first-party id>"`) has no contract and no sdk/ of its
14
+ // own: it implements the contract of a first-party plugin the SDK carries, for the platforms it has
15
+ // a half of — app.json names it for those (`"ar": { "web": "./plugins/ar-8thwall" }`), the
16
+ // first-party halves stay everywhere else.
12
17
 
13
18
  import { existsSync, readFileSync, statSync } from "node:fs"
14
- import { join } from "node:path"
19
+ import { isAbsolute, join } from "node:path"
20
+ import { firstPartyContractFile, firstPartyIds } from "./firstParty"
15
21
 
16
22
  export const MANIFEST_FILE = "lecodes-plugin.json"
23
+ export const CONTRACT_FILE = "contract.d.ts"
17
24
 
18
25
  /** A SwiftPM dependency the iOS half links. */
19
26
  export type IosPackage = { url: string, exact?: string, from?: string, products: string[] }
@@ -30,6 +37,9 @@ export type PluginManifest = {
30
37
  * contract between a bundle, the runtime and a half. */
31
38
  sdk: string
32
39
  description?: string
40
+ /** A HALF FOLDER: the first-party plugin whose contract the folder implements (it has no
41
+ * contract.d.ts and no sdk/ of its own — the app's side is the SDK's wrapper). */
42
+ implements?: string
33
43
  ios: {
34
44
  /** The Swift module the half is built as in a shell. Default: `LeCodes` + the id in PascalCase. */
35
45
  module?: string
@@ -38,6 +48,12 @@ export type PluginManifest = {
38
48
  infoPlist?: Record<string, string>
39
49
  entitlements?: Record<string, unknown>
40
50
  packages?: IosPackage[]
51
+ /** Prebuilt xcframeworks the half links, by their paths in the plugin
52
+ * (`binaries/ios/QrAnchorFFI.xcframework`): folders of the plugin, never under ios/ — SwiftPM
53
+ * takes a binary as a target of its own, beside the sources. Each is a binary target named after
54
+ * its folder (`binaryName`), which the half's target depends on; a name is one in a package
55
+ * (two plugins may not carry one alike). */
56
+ binaries?: string[]
41
57
  } | null
42
58
  android: {
43
59
  /** The Gradle module the half is built as in a shell. Default: `lecodes-` + the id. */
@@ -56,6 +72,11 @@ export type PluginManifest = {
56
72
  dependencies?: Record<string, string>
57
73
  /** The half's bundler resources, by the name `register` receives them under. */
58
74
  resources?: Record<string, WebResource>
75
+ /** A folder of the plugin (relative to it) put beside the built module AS IS, names unhashed: a
76
+ * runtime the half loads by its own url (`new URL("<static>/x.js", import.meta.url)`), which
77
+ * fetches its files relative to itself. A project's half; the first-party halves are built in
78
+ * Web Lite's page with `resources`. */
79
+ static?: string
59
80
  } | null
60
81
  }
61
82
 
@@ -69,16 +90,16 @@ const ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
69
90
  const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/
70
91
  const RANGE = /^[\^~]?\d+\.\d+\.\d+$/
71
92
  const PLATFORMS = ["ios", "android", "web"] as const
72
- const KNOWN = new Set(["id", "name", "version", "sdk", "description", ...PLATFORMS])
93
+ const KNOWN = new Set(["id", "name", "version", "sdk", "description", "implements", ...PLATFORMS])
73
94
  const GONE: Record<string, string> = {
74
95
  provides: "the channels are the contract's (contract.d.ts)",
75
96
  js: "the wrapper is found by convention (sdk/)",
76
97
  desktop: "desktop plugins are another system, with a manifest of their own",
77
98
  }
78
99
  const BRANCH: Record<(typeof PLATFORMS)[number], Set<string>> = {
79
- ios: new Set(["module", "register", "infoPlist", "entitlements", "packages"]),
100
+ ios: new Set(["module", "register", "infoPlist", "entitlements", "packages", "binaries"]),
80
101
  android: new Set(["module", "register", "dependencies", "gradlePlugins"]),
81
- web: new Set(["register", "dependencies", "resources"]),
102
+ web: new Set(["register", "dependencies", "resources", "static"]),
82
103
  }
83
104
 
84
105
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
@@ -86,6 +107,12 @@ const isFolder = (path: string): boolean => existsSync(path) && statSync(path).i
86
107
 
87
108
  export const pascal = (id: string): string => id.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join("")
88
109
 
110
+ const XCFRAMEWORK = ".xcframework"
111
+
112
+ /** The binary target of an `ios.binaries` path: its folder's name without `.xcframework`
113
+ * (`binaries/ios/QrAnchorFFI.xcframework` → `QrAnchorFFI`). */
114
+ export const binaryName = (path: string): string => path.split(/[\\/]/).pop()!.slice(0, -XCFRAMEWORK.length)
115
+
89
116
  /** The manifest of the plugin in `dir` (its folder's name is its id). Every problem of the file is
90
117
  * reported at once. */
91
118
  export const readManifest = (dir: string, id: string): PluginManifest => {
@@ -116,6 +143,13 @@ export const readManifest = (dir: string, id: string): PluginManifest => {
116
143
  const sdk = text("sdk")
117
144
  if (sdk !== undefined && !RANGE.test(sdk)) problems.push(`sdk: "${sdk}" — a range of the SDK's version: ^2.0.0`)
118
145
  if (raw.description !== undefined && typeof raw.description !== "string") problems.push("description: a text")
146
+ if (raw.implements !== undefined) {
147
+ const impl = raw.implements
148
+ if (typeof impl !== "string" || !ID.test(impl)) problems.push(`implements: the id of the first-party plugin whose contract the folder implements ("ar")`)
149
+ else if (!firstPartyContractFile(impl)) problems.push(`implements: "${impl}" is no first-party plugin of this SDK (${firstPartyIds().join(", ") || "it carries none"})`)
150
+ if (existsSync(join(dir, CONTRACT_FILE))) problems.push(`implements: the folder implements another plugin's contract — it has no ${CONTRACT_FILE} of its own`)
151
+ if (isFolder(join(dir, "sdk"))) problems.push("implements: the folder implements another plugin — its app side is the SDK's, it has no sdk/ of its own")
152
+ }
119
153
 
120
154
  for (const platform of PLATFORMS) {
121
155
  const branch = raw[platform]
@@ -159,6 +193,23 @@ export const readManifest = (dir: string, id: string): PluginManifest => {
159
193
  }
160
194
  })
161
195
  }
196
+ if (ios.binaries !== undefined) {
197
+ if (!Array.isArray(ios.binaries)) problems.push(`ios.binaries: a list of the plugin's xcframeworks, by their paths in it ("binaries/ios/X.xcframework")`)
198
+ else {
199
+ const named = new Map<string, string>()
200
+ ios.binaries.forEach((path, i) => {
201
+ const segments = typeof path === "string" ? path.split(/[\\/]/) : []
202
+ if (typeof path !== "string" || path.trim() === "" || isAbsolute(path) || segments.some(s => s === ".." || s === "." || s === "")) {
203
+ problems.push(`ios.binaries[${i}]: a folder of the plugin, by its path in it ("binaries/ios/X.xcframework")`)
204
+ } else if (!path.endsWith(XCFRAMEWORK)) problems.push(`ios.binaries[${i}]: "${path}" — an ${XCFRAMEWORK} folder`)
205
+ else if (segments[0] === "ios") problems.push(`ios.binaries[${i}]: ${path} is in ios/, the half's sources — a binary stands beside them ("binaries/ios/X.xcframework")`)
206
+ else if (!/^[A-Za-z_]\w*$/.test(binaryName(path))) problems.push(`ios.binaries[${i}]: "${path}" — the folder's name is its binary target's: a Swift name before ${XCFRAMEWORK}`)
207
+ else if (!isFolder(join(dir, path))) problems.push(`ios.binaries[${i}]: ${path} is not a folder of the plugin`)
208
+ else if (named.has(binaryName(path))) problems.push(`ios.binaries[${i}]: ${path} is named "${binaryName(path)}" like ${named.get(binaryName(path))} — a name is one binary target`)
209
+ else named.set(binaryName(path), path)
210
+ })
211
+ }
212
+ }
162
213
  }
163
214
  const android = isObject(raw.android) ? raw.android : null
164
215
  if (android) {
@@ -181,6 +232,14 @@ export const readManifest = (dir: string, id: string): PluginManifest => {
181
232
  }
182
233
  }
183
234
  }
235
+ const folder = web.static
236
+ if (folder !== undefined) {
237
+ const segments = typeof folder === "string" ? folder.split(/[\\/]/) : []
238
+ if (typeof folder !== "string" || folder.trim() === "" || isAbsolute(folder) || segments.some((s) => s === ".." || s === "." || s === "")) {
239
+ problems.push(`web.static: a folder of the plugin, by its path in it ("xr-runtime")`)
240
+ } else if (!isFolder(join(dir, folder))) problems.push(`web.static: ${folder} is not a folder of the plugin`)
241
+ else if (folder === "index.js") problems.push(`web.static: "index.js" is the built module's name`)
242
+ }
184
243
  }
185
244
 
186
245
  if (problems.length) throw new ManifestError(file, problems)