@m4l-jweb/build 1.2.1 → 1.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "m4l-jweb: the CLI that builds and packages a device repo into installable Max for Live devices.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,6 +20,7 @@
20
20
  "./chains": "./src/chains.mjs",
21
21
  "./surface": "./src/surface.mjs",
22
22
  "./watch": "./src/watch.mjs",
23
+ "./files": "./src/files.mjs",
23
24
  "./amxd": "./src/amxd.mjs",
24
25
  "./init": "./src/init.mjs"
25
26
  },
@@ -34,6 +35,6 @@
34
35
  "archiver": "^7.0.1",
35
36
  "esbuild": "^0.25.0",
36
37
  "typescript": "^5.7.0",
37
- "@m4l-jweb/wrapper": "1.2.1"
38
+ "@m4l-jweb/wrapper": "1.3.1"
38
39
  }
39
40
  }
package/src/files.mjs ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * files.mjs - the build side of defineFiles().
3
+ *
4
+ * The third of the same pipeline: import a device's declaration, and turn it into
5
+ * the two things Max needs. Unlike watch.mjs it produces BOTH kinds of output -
6
+ * data (the FILES_SPEC banner the wrapper reads) and a patcher CHAIN - because a
7
+ * device that writes files needs [maxurl] in the box graph and a folder in the
8
+ * page, and those two travelling separately is the failure the declaration exists
9
+ * to make impossible.
10
+ */
11
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import path from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+
16
+ /** The chain that owns [maxurl]. Every file-writing device needs it - see defineFiles. */
17
+ export const FILES_CHAIN = "download";
18
+
19
+ /**
20
+ * Load a device's `src/app/<uiDir>/files.ts`, or null if it declares none.
21
+ *
22
+ * Bundled with esbuild exactly like loadSurface and loadWatch, for the same
23
+ * reason: the declaration is TypeScript importing @m4l-jweb/surface, and Node
24
+ * cannot import that directly.
25
+ */
26
+ export async function loadFiles(root, uiDir) {
27
+ const src = path.join(root, "src", "app", uiDir, "files.ts");
28
+ if (!existsSync(src)) return null;
29
+
30
+ const { build } = await import("esbuild");
31
+ const tmp = mkdtempSync(path.join(tmpdir(), "m4l-files-"));
32
+ const out = path.join(tmp, "files.mjs");
33
+ try {
34
+ await build({
35
+ entryPoints: [src],
36
+ outfile: out,
37
+ bundle: true,
38
+ format: "esm",
39
+ platform: "node",
40
+ logLevel: "silent",
41
+ external: ["react", "react-dom"],
42
+ });
43
+ const mod = await import(pathToFileURL(out).href);
44
+ const files = mod.default;
45
+ if (!files || typeof files.saves !== "boolean" || typeof files.fetches !== "boolean") {
46
+ throw new Error(`${src} must \`export default defineFiles({...})\``);
47
+ }
48
+ return files;
49
+ } finally {
50
+ rmSync(tmp, { recursive: true, force: true });
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The chain list this device is actually built with.
56
+ *
57
+ * A declared file device gets `download` whether or not the manifest asked for it,
58
+ * appended LAST so it cannot displace an audio stage: `download` claims no stage of
59
+ * the signal path, but chain order IS the signal path, and inserting anywhere else
60
+ * would be a silent re-routing of a device that merely started writing files.
61
+ *
62
+ * Idempotent. A manifest that still lists `download` keeps exactly one - running the
63
+ * chain twice would emit the same box ids twice, which assertUniqueBoxIds rejects.
64
+ */
65
+ export function effectiveChains(chains, files) {
66
+ const declared = chains ?? [];
67
+ if (!files) return declared;
68
+ return declared.includes(FILES_CHAIN) ? declared : [...declared, FILES_CHAIN];
69
+ }
70
+
71
+ /**
72
+ * The `var FILES_SPEC = {...}` banner prepended to a device's wrapper.js.
73
+ *
74
+ * Only what the wrapper ACTS on travels. `tellPage` gates the `device_folder`
75
+ * message at ui_ready; `saves` and `fetches` ride along because a device that has
76
+ * never written anything still says so in the Max console, and a wrapper reporting
77
+ * "this device writes no files" next to a save error is the cheapest possible answer
78
+ * to "is it even wired for this".
79
+ *
80
+ * A device with no declaration gets no banner ("") - `typeof FILES_SPEC === "undefined"`
81
+ * is exactly the guard the wrapper checks.
82
+ */
83
+ export function filesSpecBanner(files) {
84
+ if (!files) return "";
85
+ const spec = { saves: !!files.saves, fetches: !!files.fetches, tellPage: !!files.tellPage };
86
+ return `var FILES_SPEC = ${JSON.stringify(spec)};\n`;
87
+ }
package/src/index.mjs CHANGED
@@ -18,6 +18,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { AMXD_TYPES, assertES5, buildAmxd, extraPayloadsJs, payloadJs } from "./amxd.mjs";
19
19
  import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
20
20
  import { applySurface, applyWindows, applyPersistence, loadSurface, parameterRegistry, surfaceContext } from "./surface.mjs";
21
+ import { effectiveChains, filesSpecBanner, loadFiles } from "./files.mjs";
21
22
  import { loadWatch, watchSpecsBanner } from "./watch.mjs";
22
23
 
23
24
  const require = createRequire(import.meta.url);
@@ -122,6 +123,26 @@ async function readManifest(root) {
122
123
  return (await import(pathToFileURL(p).href)).default;
123
124
  }
124
125
 
126
+ /**
127
+ * Files that ride along with the release without being part of any device.
128
+ *
129
+ * A user manual, a licence, a chart - things a person opens, not things Max loads. They
130
+ * are declared as a named `docs` export next to the manifest:
131
+ *
132
+ * export const docs = ["USERSMANUAL.md", "dist/manual/USERSMANUAL.pdf"];
133
+ *
134
+ * MISSING IS NOT FATAL, and that is the whole reason this is a list rather than a
135
+ * `looseFiles` entry: a generated doc (a PDF rendered by a headless browser) may not
136
+ * exist on a machine that has no browser, and a manual is never worth failing a build
137
+ * that produced every device correctly. What is missing is named in the log.
138
+ */
139
+ async function readDocs(root) {
140
+ const p = path.join(root, "patcher", "devices.mjs");
141
+ if (!existsSync(p)) return [];
142
+ const mod = await import(pathToFileURL(p).href);
143
+ return Array.isArray(mod.docs) ? mod.docs : [];
144
+ }
145
+
125
146
  /** patcher/base.json in the device repo wins; otherwise the packaged template. */
126
147
  function readBase(root) {
127
148
  const local = path.join(root, "patcher", "base.json");
@@ -157,7 +178,8 @@ async function loadDeviceChains(root) {
157
178
  *
158
179
  * openAudio the device's plugin~/plugout~, created ONCE, before any chain -
159
180
  * so a chain is a stage in the signal path, not the owner of it.
160
- * the chains in declaration order, each taking what the last one left.
181
+ * the chains in declaration order, each taking what the last one left, plus
182
+ * the `download` chain a files.ts declaration derives (files.mjs).
161
183
  * applySurface LAST of the message-stream claimants: it routes every `set_<id>`
162
184
  * off the app's stream and passes on what nobody claimed
163
185
  * (ui_ready, ...) to the wrapper. Doing it last means no chain has
@@ -166,7 +188,7 @@ async function loadDeviceChains(root) {
166
188
  * assertUnique two boxes with one id is a malformed patcher, and Max resolves
167
189
  * it however it likes. Nothing else would report it.
168
190
  */
169
- export function composePatcher(base, d, surface) {
191
+ export function composePatcher(base, d, surface, files = null) {
170
192
  const amxdtype = AMXD_TYPES[d.type];
171
193
  if (!amxdtype) throw new Error(`unknown type "${d.type}" for device "${d.name}" (midi | audio | instrument)`);
172
194
 
@@ -220,7 +242,7 @@ export function composePatcher(base, d, surface) {
220
242
 
221
243
  openAudio(ctx);
222
244
 
223
- for (const name of d.chains ?? []) {
245
+ for (const name of effectiveChains(d.chains, files)) {
224
246
  const chain = CHAINS[name];
225
247
  if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
226
248
  chain(ctx);
@@ -266,7 +288,8 @@ export async function generatePatchers(root) {
266
288
  }
267
289
 
268
290
  const surface = await loadSurface(root, d.ui ?? d.name);
269
- const p = composePatcher(base, d, surface);
291
+ const files = await loadFiles(root, d.ui ?? d.name);
292
+ const p = composePatcher(base, d, surface, files);
270
293
 
271
294
  // A chain's frozen dependencies (e.g. a [poly~] voice patch) are written beside
272
295
  // the device patcher and their names recorded in a sidecar, so packageDevices -
@@ -279,7 +302,11 @@ export async function generatePatchers(root) {
279
302
 
280
303
  writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
281
304
  const params = surface ? surface.ids.join(", ") : "none";
282
- console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"}, params: ${params || "none"})`);
305
+ // The chains it was BUILT with, not the ones the manifest listed - a derived
306
+ // `download` that never appeared in the log would be the same invisible wiring
307
+ // this feature exists to end.
308
+ const chains = effectiveChains(d.chains, files).join(", ") || "none";
309
+ console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${chains}, params: ${params || "none"})`);
283
310
  }
284
311
  return devices;
285
312
  }
@@ -383,7 +410,11 @@ export async function packageDevices(root) {
383
410
  // The device's declared watches ride in as a data banner, like the build stamp:
384
411
  // WATCH_SPECS is what the packaged wrapper's setupWatches() attaches observers from.
385
412
  const watch = await loadWatch(root, d.ui ?? d.name);
386
- let wrapperData = banner + watchSpecsBanner(watch) + siteWindowsBanner(root, outDir, d, await loadSurface(root, d.ui ?? d.name)) + wrapperJs;
413
+ // ...and so do its declared files: FILES_SPEC is what tells the packaged wrapper
414
+ // this device writes to disk, and therefore to hand the page its device folder.
415
+ const files = await loadFiles(root, d.ui ?? d.name);
416
+ let wrapperData =
417
+ banner + watchSpecsBanner(watch) + filesSpecBanner(files) + siteWindowsBanner(root, outDir, d, await loadSurface(root, d.ui ?? d.name)) + wrapperJs;
387
418
  const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
388
419
 
389
420
  // Main UI payload
@@ -446,6 +477,19 @@ export async function packageDevices(root) {
446
477
  console.log(`m4l-jweb: ${f} -> dist/${name}/ (preset)`);
447
478
  }
448
479
 
480
+ // Documentation for the human, alongside the devices for Max. See readDocs.
481
+ const docs = [];
482
+ for (const f of await readDocs(root)) {
483
+ const from = path.join(root, f);
484
+ if (!existsSync(from)) {
485
+ console.warn(`m4l-jweb: doc ${f} is not there - skipped (it is not part of any device)`);
486
+ continue;
487
+ }
488
+ await copyFile(from, path.join(outDir, path.basename(f)));
489
+ docs.push(path.basename(f));
490
+ console.log(`m4l-jweb: ${path.basename(f)} -> dist/${name}/ (doc)`);
491
+ }
492
+
449
493
  // Installers go next to the devices so `dist/install-*.ps1` just works.
450
494
  const installers = ["install-windows.ps1", "install-mac.sh"];
451
495
  for (const f of installers) await copyFile(path.join(templates, f), path.join(dist, f));
@@ -462,6 +506,7 @@ export async function packageDevices(root) {
462
506
  ...devices.map((d) => `${d.name}.html`), // each device's own UI, for inspection
463
507
  ...loose.map((f) => path.basename(f)),
464
508
  ...presets,
509
+ ...docs,
465
510
  "wrapper.js",
466
511
  ];
467
512
  for (const f of files) {
@@ -44,7 +44,9 @@ if [ ! -d "$user_lib" ]; then
44
44
  fi
45
45
 
46
46
  dest="$user_lib/Max For Live/$device_name"
47
- rm -rf "$dest"
47
+ # The folder is NOT wiped first. It is the same folder the devices SAVE INTO - exports,
48
+ # downloaded samples, anything a user dragged out of it - so clearing it to get a clean
49
+ # install threw away their work. Overwrite what this build produces; leave the rest.
48
50
  mkdir -p "$dest"
49
51
 
50
52
  # Each .amxd is self-contained: the UI rides inside it as a payload in wrapper.js.
@@ -67,6 +69,9 @@ done
67
69
  # empty, and the wrapper says so in the Max console.
68
70
  for d in "$src"/*-site; do
69
71
  [ -d "$d" ] || continue
72
+ # This one IS replaced wholesale: it is entirely build output, and a file dropped
73
+ # from the site between builds would otherwise linger and be served.
74
+ rm -rf "$dest/$(basename "$d")"
70
75
  cp -R "$d" "$dest/"
71
76
  echo " installed $(basename "$d")/ (site sidecar)"
72
77
  done
@@ -49,13 +49,20 @@ if (-not (Test-Path $userLib)) {
49
49
  }
50
50
 
51
51
  $dest = Join-Path $userLib "Max For Live\$deviceName"
52
- if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
52
+ # The folder is NOT wiped first. It is the same folder the devices SAVE INTO - exports,
53
+ # downloaded samples, anything a user dragged out of it - so clearing it to get a clean
54
+ # install threw away their work, and failed outright the moment Live held one of those
55
+ # files open. Overwrite what this build produces; leave everything else alone.
53
56
  New-Item -ItemType Directory -Force $dest | Out-Null
54
57
 
55
58
  # Each .amxd is self-contained: the UI rides inside it as a payload in wrapper.js.
56
59
  foreach ($f in $devices) {
57
- Copy-Item $f.FullName $dest -Force
58
- Write-Host " installed $($f.Name)"
60
+ try {
61
+ Copy-Item $f.FullName $dest -Force -ErrorAction Stop
62
+ Write-Host " installed $($f.Name)"
63
+ } catch {
64
+ Write-Error "Could not replace $($f.Name) - it is open in Live. Close the set (or remove the device from the track) and run this again."
65
+ }
59
66
  }
60
67
 
61
68
  # Presets (hand-saved Live racks, packaged next to the devices by the build) go in
@@ -70,6 +77,10 @@ foreach ($f in @(Get-ChildItem (Join-Path $src "*.adg") -ErrorAction SilentlyCon
70
77
  # installed with it. Without the folder the device still plays; that window opens
71
78
  # empty, and the wrapper says so in the Max console.
72
79
  foreach ($d in @(Get-ChildItem (Join-Path $src "*-site") -Directory -ErrorAction SilentlyContinue)) {
80
+ # This one IS replaced wholesale: it is entirely build output, and a file dropped
81
+ # from the site between builds would otherwise linger and be served.
82
+ $siteTarget = Join-Path $dest $d.Name
83
+ if (Test-Path $siteTarget) { Remove-Item $siteTarget -Recurse -Force }
73
84
  Copy-Item $d.FullName $dest -Recurse -Force
74
85
  Write-Host " installed $($d.Name)/ (site sidecar)"
75
86
  }
@@ -17,13 +17,13 @@
17
17
  "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"scripts/*.mjs\" \"patcher/*.mjs\""
18
18
  },
19
19
  "dependencies": {
20
- "@m4l-jweb/bridge": "^1.2.1",
21
- "@m4l-jweb/surface": "^1.2.1",
20
+ "@m4l-jweb/bridge": "^1.3.0",
21
+ "@m4l-jweb/surface": "^1.3.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@m4l-jweb/build": "^1.2.1",
26
+ "@m4l-jweb/build": "^1.3.0",
27
27
  "@types/node": "^22.0.0",
28
28
  "@types/react": "^19.0.0",
29
29
  "@types/react-dom": "^19.0.0",