@oss-idle/addon-sdk 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,8 +22,11 @@ That emits one self-contained ES module. Paste it into the in-game add-on editor
22
22
  ## The contract
23
23
 
24
24
  The game loads an add-on by importing its source as a Blob ES module and calling
25
- `mount` / `unmount`. Nothing is injected; add-ons run unsandboxed in the page
26
- realm and reach for what they need themselves.
25
+ `mount` / `unmount`. `mount` is handed the game's host (`this.host` in an
26
+ `Addon`): its version, its React, and the editor for add-ons of the Editor type.
27
+ Add-ons run unsandboxed in the page realm and reach for the rest themselves. The
28
+ full guide, the editor API included, is at
29
+ https://oss.idle.rhythmgamers.net/web/docs/addons.
27
30
 
28
31
  ```ts
29
32
  import { Addon, defineAddon } from '@oss-idle/addon-sdk';
@@ -49,6 +52,35 @@ Two things follow from that, and they shape the whole SDK:
49
52
  through `this.on`, `this.interval`, `this.timeout` or `this.own` is torn down
50
53
  for you, in reverse order, even if one teardown throws.
51
54
 
55
+ ## Editor add-ons
56
+
57
+ An add-on of the Editor type (picked when you make it in game) reaches the map
58
+ editor while it is open. Everything registered through the editor's API goes when
59
+ the editor closes or the add-on unmounts.
60
+
61
+ ```ts
62
+ class Mirror extends Addon {
63
+ constructor() { super({ name: 'Mirror' }); }
64
+
65
+ protected onMount() {
66
+ this.editor(editor => {
67
+ editor.addAction({
68
+ id: 'mirror-selection', label: 'Mirror the selected notes', keys: ['Alt+M'],
69
+ run: () => {
70
+ const picked = new Set(editor.selection());
71
+ editor.edit({
72
+ notes: editor.notes().map(note => picked.has(note)
73
+ ? { ...note, column: editor.keys - 1 - note.column } : note),
74
+ });
75
+ },
76
+ });
77
+ });
78
+ }
79
+ }
80
+ ```
81
+
82
+ `EditorApi` and the other host types are exported from the package.
83
+
52
84
  ## React overlays
53
85
 
54
86
  ```tsx
@@ -197,6 +229,7 @@ moderator before they go live.
197
229
  | `gameRoot` → resolves `@oss-idle/shared/*` | The game's package is unpublished, so its TypeScript is compiled from a checkout (`$OSS_IDLE_PATH`). |
198
230
  | `dedupe` | Your sources and the game's come from different checkouts, so shared packages would otherwise be bundled twice - which silently breaks prototype patches and `instanceof`. |
199
231
  | `stubModules` | Trims a heavy dependency a game module imports but your add-on never calls. Stubs **throw** if reached. |
232
+ | `plugins` | Extra esbuild plugins for the add-on and its workers, run before the SDK's own - e.g. to redirect one of the game's imports to your own module. |
200
233
 
201
234
  ### Using the game's own code
202
235
 
@@ -218,6 +251,13 @@ Doing so makes your add-on a derivative work of oss!idle, which is **AGPL-3.0**
218
251
  so it must be AGPL-3.0 too. Pin the game version you built against; the sources
219
252
  are internal and do move between releases.
220
253
 
254
+ ### The game's React
255
+
256
+ Set `"sharedReact": true` in `addon.config.json` (the template does) and the
257
+ bundle reads React from the game instead of shipping its own: one React on the
258
+ page, and a smaller add-on. A game too old to hand out its React refuses the
259
+ add-on with a message; leave the option off to support those.
260
+
221
261
  ## Licence
222
262
 
223
263
  AGPL-3.0-only.
package/bin/cli.mjs CHANGED
@@ -110,6 +110,7 @@ const run = async () => {
110
110
  stubModules: config.stubModules,
111
111
  dedupe: config.dedupe,
112
112
  dedupeFrom: cwd,
113
+ sharedReact: !!config.sharedReact,
113
114
  meta: {
114
115
  name: config.name,
115
116
  version: pkgVersion,
package/bin/scaffold.mjs CHANGED
@@ -138,6 +138,7 @@ export const scaffold = async ({
138
138
  build: 'oss-idle-addon build',
139
139
  dev: 'oss-idle-addon build --watch',
140
140
  typecheck: 'tsc -p tsconfig.json --noEmit',
141
+ lint: 'eslint .',
141
142
  },
142
143
  dependencies: {
143
144
  '@oss-idle/addon-sdk': await sdkVersion(),
@@ -145,7 +146,9 @@ export const scaffold = async ({
145
146
  },
146
147
  devDependencies: {
147
148
  ...(react ? { '@types/react': '^18.3.12', '@types/react-dom': '^18.3.1' } : {}),
149
+ eslint: '^10.13.0',
148
150
  typescript: '^5.6.3',
151
+ 'typescript-eslint': '^8.72.0',
149
152
  },
150
153
  };
151
154
  await writeFile(join(target, 'package.json'), JSON.stringify(pkg, null, 2) + '\n', 'utf8');
@@ -13,12 +13,13 @@
13
13
  * moderator before they go live, and a single minified line is a poor thing to
14
14
  * ask anyone to review.
15
15
  */
16
+ import { type Plugin } from 'esbuild';
16
17
  /**
17
18
  * The individual esbuild plugins, for a build that needs to assemble its own
18
19
  * pipeline rather than call {@link bundleAddon} - compiling a slice of an add-on
19
20
  * for a test, say, or bundling a worker on its own.
20
21
  */
21
- export { assetQueryPlugin, cssTextPlugin, dedupePlugin, inlineWorkerPlugin, sharedSourcePlugin, stubModulePlugin, tsExtensionPlugin, } from './plugins.js';
22
+ export { assetQueryPlugin, cssTextPlugin, dedupePlugin, hostReactPlugin, inlineWorkerPlugin, sharedSourcePlugin, stubModulePlugin, tsExtensionPlugin, } from './plugins.js';
22
23
  export type BundleOptions = {
23
24
  /** Entry module, exporting `mount` / `unmount` (usually via `defineAddon`). */
24
25
  entry: string;
@@ -49,6 +50,18 @@ export type BundleOptions = {
49
50
  dedupe?: readonly string[];
50
51
  /** Directory the deduped packages resolve from. Defaults to the entry's directory. */
51
52
  dedupeFrom?: string;
53
+ /**
54
+ * Extra esbuild plugins, for the add-on and every inlined worker. They run
55
+ * before the SDK's own, so an `onResolve` here can redirect an import -
56
+ * one of the game's own modules, say - before the SDK resolves it.
57
+ */
58
+ plugins?: readonly Plugin[];
59
+ /**
60
+ * Use the game's React (`globalThis.ossIdle.react`) instead of bundling one:
61
+ * a much smaller add-on, and one React on the page. Needs an oss!idle that
62
+ * hands its React to add-ons; an older one refuses the add-on with a message.
63
+ */
64
+ sharedReact?: boolean;
52
65
  };
53
66
  export type BundleResult = {
54
67
  outfile: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/build/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAgBH;;;;GAIG;AACH,OAAO,EACN,gBAAgB,EAChB,aAAa,EACb,YAAY,EACZ,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,GACjB,MAAM,cAAc,CAAC;AAEtB,MAAM,MAAM,aAAa,GAAG;IAC3B,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACnF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IAChD;;;;;OAKG;IACH,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,sFAAsF;IACtF,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AA2DF,MAAM,MAAM,YAAY,GAAG;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;CACd,CAAC;AASF,oEAAoE;AACpE,eAAO,MAAM,WAAW,GAAU,SAAS,aAAa,KAAG,OAAO,CAAC,YAAY,CA+C9E,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/build/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAuC,KAAK,MAAM,EAAE,MAAM,SAAS,CAAC;AAe3E;;;;GAIG;AACH,OAAO,EACN,gBAAgB,EAChB,aAAa,EACb,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,GACjB,MAAM,cAAc,CAAC;AAEtB,MAAM,MAAM,aAAa,GAAG;IAC3B,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACnF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IAChD;;;;;OAKG;IACH,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,sFAAsF;IACtF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AA8DF,MAAM,MAAM,YAAY,GAAG;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;CACd,CAAC;AASF,oEAAoE;AACpE,eAAO,MAAM,WAAW,GAAU,SAAS,aAAa,KAAG,OAAO,CAAC,YAAY,CAiD9E,CAAC"}
@@ -17,16 +17,16 @@ import { build as esbuild } from 'esbuild';
17
17
  import { createRequire } from 'node:module';
18
18
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
19
19
  import { dirname, resolve } from 'node:path';
20
- import { assetQueryPlugin, cssTextPlugin, dedupePlugin, inlineWorkerPlugin, sharedSourcePlugin, stubModulePlugin, tsExtensionPlugin, } from './plugins.js';
20
+ import { assetQueryPlugin, cssTextPlugin, dedupePlugin, hostReactPlugin, inlineWorkerPlugin, sharedSourcePlugin, stubModulePlugin, tsExtensionPlugin, } from './plugins.js';
21
21
  /**
22
22
  * The individual esbuild plugins, for a build that needs to assemble its own
23
23
  * pipeline rather than call {@link bundleAddon} - compiling a slice of an add-on
24
24
  * for a test, say, or bundling a worker on its own.
25
25
  */
26
- export { assetQueryPlugin, cssTextPlugin, dedupePlugin, inlineWorkerPlugin, sharedSourcePlugin, stubModulePlugin, tsExtensionPlugin, } from './plugins.js';
26
+ export { assetQueryPlugin, cssTextPlugin, dedupePlugin, hostReactPlugin, inlineWorkerPlugin, sharedSourcePlugin, stubModulePlugin, tsExtensionPlugin, } from './plugins.js';
27
27
  const sharedSrcOf = (gameRoot) => resolve(gameRoot, 'packages/shared/src');
28
28
  /** esbuild settings shared by the add-on bundle and any inlined worker. */
29
- const baseOptions = (gameRoot, minify, stubModules = {}, dedupe = [], dedupeFrom = process.cwd()) => ({
29
+ const baseOptions = (gameRoot, minify, stubModules = {}, dedupe = [], dedupeFrom = process.cwd(), plugins = []) => ({
30
30
  bundle: true,
31
31
  format: 'esm',
32
32
  platform: 'browser',
@@ -42,6 +42,7 @@ const baseOptions = (gameRoot, minify, stubModules = {}, dedupe = [], dedupeFrom
42
42
  loader: { '.wasm': 'base64' },
43
43
  minify,
44
44
  plugins: [
45
+ ...plugins,
45
46
  // Strip asset suffixes before anything tries to resolve them as modules.
46
47
  assetQueryPlugin(),
47
48
  // Dedupe first: it must see a bare package name before anything else
@@ -54,9 +55,9 @@ const baseOptions = (gameRoot, minify, stubModules = {}, dedupe = [], dedupeFrom
54
55
  ],
55
56
  });
56
57
  /** Build one worker entry to a self-contained string for inlining. */
57
- const buildWorkerSource = async (entry, gameRoot, minify, stubModules, dedupe, dedupeFrom) => {
58
+ const buildWorkerSource = async (entry, gameRoot, minify, stubModules, dedupe, dedupeFrom, plugins) => {
58
59
  const result = await esbuild({
59
- ...baseOptions(gameRoot, minify, stubModules, dedupe, dedupeFrom),
60
+ ...baseOptions(gameRoot, minify, stubModules, dedupe, dedupeFrom, plugins),
60
61
  entryPoints: [entry],
61
62
  write: false,
62
63
  // A worker built as ESM would need `type: 'module'` support *and* would
@@ -72,12 +73,14 @@ const buildWorkerSource = async (entry, gameRoot, minify, stubModules, dedupe, d
72
73
  const sdkVersion = createRequire(import.meta.url)('@oss-idle/addon-sdk/package.json').version;
73
74
  /** Bundle an add-on into the single ES module the runtime loads. */
74
75
  export const bundleAddon = async (options) => {
75
- const { entry, outfile, gameRoot, meta, minify = false, sourcemap = false, stubModules = {}, dedupe = [], dedupeFrom = dirname(resolve(entry)), } = options;
76
+ const { entry, outfile, gameRoot, meta, minify = false, sourcemap = false, stubModules = {}, dedupe = [], dedupeFrom = dirname(resolve(entry)), plugins = [], sharedReact = false, } = options;
76
77
  // The SDK's React modules are imported from wherever npm put the SDK, so
77
78
  // `react` resolves from there unless it is pointed back at the add-on that
78
- // installed it - and two Reacts in one bundle break hooks outright.
79
- const names = [...new Set(['react', 'react-dom', ...dedupe])];
80
- const base = baseOptions(gameRoot, minify, stubModules, names, dedupeFrom);
79
+ // installed it - and two Reacts in one bundle break hooks outright. With the
80
+ // game's React there is none to bundle.
81
+ const names = [...new Set([...sharedReact ? [] : ['react', 'react-dom'], ...dedupe])];
82
+ const ownPlugins = sharedReact ? [hostReactPlugin(dedupeFrom), ...plugins] : plugins;
83
+ const base = baseOptions(gameRoot, minify, stubModules, names, dedupeFrom, ownPlugins);
81
84
  const result = await esbuild({
82
85
  ...base,
83
86
  // Read back at runtime by `buildMeta()`.
@@ -86,7 +89,7 @@ export const bundleAddon = async (options) => {
86
89
  write: false,
87
90
  sourcemap: sourcemap ? 'inline' : false,
88
91
  plugins: [
89
- inlineWorkerPlugin(workerEntry => buildWorkerSource(workerEntry, gameRoot, minify, stubModules, dedupe, dedupeFrom)),
92
+ inlineWorkerPlugin(workerEntry => buildWorkerSource(workerEntry, gameRoot, minify, stubModules, dedupe, dedupeFrom, plugins)),
90
93
  ...(base.plugins ?? []),
91
94
  ],
92
95
  });
@@ -85,4 +85,11 @@ export declare const dedupePlugin: (names: readonly string[], fromDir: string) =
85
85
  * (`.wasm` -> base64) applies as normal.
86
86
  */
87
87
  export declare const assetQueryPlugin: () => Plugin;
88
+ /**
89
+ * React from the game instead of a bundled copy: each React import becomes a small
90
+ * module reading the game's own React off `globalThis.ossIdle.react`, which the game
91
+ * sets before it loads any add-on. Its named exports are the installed React's, read
92
+ * here at build time, so `import { useState } from 'react'` keeps working.
93
+ */
94
+ export declare const hostReactPlugin: (fromDir: string) => Plugin;
88
95
  //# sourceMappingURL=plugins.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugins.d.ts","sourceRoot":"","sources":["../../src/build/plugins.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEtC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,GAAI,WAAW,MAAM,KAAG,MAgBrD,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,QAAO,MAanC,CAAC;AAEH;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,GAAI,aAAa,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,KAAG,MA0BnF,CAAC;AAEH,oFAAoF;AACpF,eAAO,MAAM,aAAa,QAAO,MAQ/B,CAAC;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,KAAG,MAsB1E,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,SAAS,MAAM,EAAE,EAAE,SAAS,MAAM,KAAG,MAmBvE,CAAC;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,gBAAgB,QAAO,MAgBlC,CAAC"}
1
+ {"version":3,"file":"plugins.d.ts","sourceRoot":"","sources":["../../src/build/plugins.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEtC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,GAAI,WAAW,MAAM,KAAG,MAgBrD,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,QAAO,MAanC,CAAC;AAEH;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,GAAI,aAAa,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,KAAG,MA0BnF,CAAC;AAEH,oFAAoF;AACpF,eAAO,MAAM,aAAa,QAAO,MAQ/B,CAAC;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,KAAG,MAsB1E,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,SAAS,MAAM,EAAE,EAAE,SAAS,MAAM,KAAG,MAmBvE,CAAC;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,gBAAgB,QAAO,MAgBlC,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,SAAS,MAAM,KAAG,MAmBhD,CAAC"}
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  import { dirname, resolve } from 'node:path';
4
5
  /**
5
6
  * Resolve `@oss-idle/shared/*` against a checkout of the game.
@@ -211,3 +212,29 @@ export const assetQueryPlugin = () => ({
211
212
  });
212
213
  },
213
214
  });
215
+ /**
216
+ * React from the game instead of a bundled copy: each React import becomes a small
217
+ * module reading the game's own React off `globalThis.ossIdle.react`, which the game
218
+ * sets before it loads any add-on. Its named exports are the installed React's, read
219
+ * here at build time, so `import { useState } from 'react'` keeps working.
220
+ */
221
+ export const hostReactPlugin = (fromDir) => ({
222
+ name: 'host-react',
223
+ setup(build) {
224
+ const requireFrom = createRequire(resolve(fromDir, 'package.json'));
225
+ build.onResolve({ filter: /^react(-dom)?(\/(client|jsx-runtime))?$/ }, args => ({
226
+ path: args.path, namespace: 'oss-idle-react',
227
+ }));
228
+ build.onLoad({ filter: /.*/, namespace: 'oss-idle-react' }, args => {
229
+ const names = Object.keys(requireFrom(args.path))
230
+ .filter(name => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name));
231
+ const contents = [
232
+ `const m = globalThis.ossIdle?.react?.[${JSON.stringify(args.path)}];`,
233
+ 'if (!m) throw new Error("This add-on uses the game\'s React: update oss!idle to run it");',
234
+ 'export default m;',
235
+ ...names.map(name => `export const ${name} = m.${name};`),
236
+ ].join('\n');
237
+ return { contents, loader: 'js' };
238
+ });
239
+ },
240
+ });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { Addon, type AddonMeta, type Disposer } from './runtime/addon.js';
2
2
  export { defineAddon, type AddonModule } from './runtime/define.js';
3
+ export { gameHost, type AddonHost, type EditorApi, type EditorMetadata, type EditorNote, type EditorRender, type EditorTimingPoint, } from './runtime/host.js';
3
4
  export { buildMeta, type BuildMeta } from './runtime/buildMeta.js';
4
5
  export { HotkeyManager, isolateInput, parseHotkey, type Hotkey, } from './runtime/input.js';
5
6
  export { createSettings, type SettingsStore } from './runtime/settings.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnE,OAAO,EACN,aAAa,EACb,YAAY,EACZ,WAAW,EACX,KAAK,MAAM,GACX,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3E,OAAO,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EACN,YAAY,EACZ,KAAK,aAAa,EAClB,KAAK,aAAa,GAClB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EACN,QAAQ,EACR,KAAK,SAAS,EACd,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACtB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnE,OAAO,EACN,aAAa,EACb,YAAY,EACZ,WAAW,EACX,KAAK,MAAM,GACX,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3E,OAAO,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EACN,YAAY,EACZ,KAAK,aAAa,EAClB,KAAK,aAAa,GAClB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { Addon } from './runtime/addon.js';
2
2
  export { defineAddon } from './runtime/define.js';
3
+ export { gameHost, } from './runtime/host.js';
3
4
  export { buildMeta } from './runtime/buildMeta.js';
4
5
  export { HotkeyManager, isolateInput, parseHotkey, } from './runtime/input.js';
5
6
  export { createSettings } from './runtime/settings.js';
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * oss!idle loads an add-on by turning its `source` into a Blob URL and
5
5
  * `import()`ing it in the page realm, then calling the module's `mount` /
6
- * `unmount` exports - that pair *is* the contract, and nothing else is passed
7
- * in. Everything a useful add-on needs (the game's database, its beatmap store,
8
- * the DOM) it reaches for itself, because it runs unsandboxed on the page.
6
+ * `unmount` exports - that pair *is* the contract. `mount` is handed the game's
7
+ * host (its React, the editor for add-ons of the Editor type: {@link host});
8
+ * the rest (the game's database, its beatmap store, the DOM) an add-on reaches
9
+ * for itself, because it runs unsandboxed on the page.
9
10
  *
10
11
  * Two consequences shape this class:
11
12
  *
@@ -18,6 +19,7 @@
18
19
  * hands out registration helpers that record their own teardown, and reverses
19
20
  * them for you.
20
21
  */
22
+ import { type AddonHost, type EditorApi } from './host.js';
21
23
  /** A teardown returned by a registration; running it undoes that registration. */
22
24
  export type Disposer = () => void;
23
25
  export type AddonMeta = {
@@ -70,7 +72,16 @@ export declare abstract class Addon {
70
72
  * Public only because `defineAddon` lives outside the class and TypeScript
71
73
  * has no package-private visibility; the underscore marks it as off-limits.
72
74
  */
73
- _mount(): Promise<void>;
75
+ _mount(host?: AddonHost): Promise<void>;
76
+ private handed?;
77
+ /** The game's host: React, the editor. Undefined on a game too old to hand one. */
78
+ protected get host(): AddonHost | undefined;
79
+ /**
80
+ * Work with the editor while it is open (an add-on of the Editor type):
81
+ * `onOpen` runs each time it opens, with its API. What is registered through
82
+ * that API goes when the editor closes or this add-on unmounts.
83
+ */
84
+ protected editor(onOpen: (editor: EditorApi) => void, onClose?: () => void): void;
74
85
  /**
75
86
  * Internal: called by {@link defineAddon}, not by add-on code.
76
87
  *
@@ -1 +1 @@
1
- {"version":3,"file":"addon.d.ts","sourceRoot":"","sources":["../../src/runtime/addon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,kFAAkF;AAClF,MAAM,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC;AAElC,MAAM,MAAM,SAAS,GAAG;IACvB,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;GAOG;AACH,8BAAsB,KAAK;IAE1B,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAEnC,mEAAmE;IACnE,OAAO,CAAC,SAAS,CAAkB;IAEnC,OAAO,CAAC,OAAO,CAAS;gBAEZ,IAAI,EAAE,SAAS;IAI3B,8EAA8E;IAC9E,SAAS,CAAC,QAAQ,CAAC,OAAO,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAElD,6EAA6E;IAC7E,SAAS,CAAC,SAAS,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAE3C,oFAAoF;IACpF,SAAS,CAAC,GAAG,CAAC,CAAC,SAAS,QAAQ,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC;IAKhD;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,SAAS,MAAM,cAAc,EAC1C,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,CAAC,CAAC,CAAC,KAAK,IAAI,EAC3C,OAAO,CAAC,EAAE,uBAAuB,GAC/B,QAAQ;IACX,SAAS,CAAC,EAAE,CACX,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,kCAAkC,EAC3C,OAAO,CAAC,EAAE,uBAAuB,GAC/B,QAAQ;IAWX,gDAAgD;IAChD,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,QAAQ;IAK7D,uEAAuE;IACvE,SAAS,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,QAAQ;IAK5D,8EAA8E;IAC9E,SAAS,KAAK,GAAG;wBAGC,OAAO,EAAE;wBACT,OAAO,EAAE;yBACR,OAAO,EAAE;MAE3B;IAED;;;;;OAKG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAM7B;;;;;;OAMG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAkB/B"}
1
+ {"version":3,"file":"addon.d.ts","sourceRoot":"","sources":["../../src/runtime/addon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,EAEN,KAAK,SAAS,EACd,KAAK,SAAS,EACd,MAAM,WAAW,CAAC;AAEnB,kFAAkF;AAClF,MAAM,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC;AAElC,MAAM,MAAM,SAAS,GAAG;IACvB,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;GAOG;AACH,8BAAsB,KAAK;IAE1B,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAEnC,mEAAmE;IACnE,OAAO,CAAC,SAAS,CAAkB;IAEnC,OAAO,CAAC,OAAO,CAAS;gBAEZ,IAAI,EAAE,SAAS;IAI3B,8EAA8E;IAC9E,SAAS,CAAC,QAAQ,CAAC,OAAO,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAElD,6EAA6E;IAC7E,SAAS,CAAC,SAAS,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAE3C,oFAAoF;IACpF,SAAS,CAAC,GAAG,CAAC,CAAC,SAAS,QAAQ,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC;IAKhD;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,SAAS,MAAM,cAAc,EAC1C,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,CAAC,CAAC,CAAC,KAAK,IAAI,EAC3C,OAAO,CAAC,EAAE,uBAAuB,GAC/B,QAAQ;IACX,SAAS,CAAC,EAAE,CACX,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,kCAAkC,EAC3C,OAAO,CAAC,EAAE,uBAAuB,GAC/B,QAAQ;IAWX,gDAAgD;IAChD,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,QAAQ;IAK7D,uEAAuE;IACvE,SAAS,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,QAAQ;IAK5D,8EAA8E;IAC9E,SAAS,KAAK,GAAG;wBAGC,OAAO,EAAE;wBACT,OAAO,EAAE;yBACR,OAAO,EAAE;MAE3B;IAED;;;;;OAKG;IACG,MAAM,CAAC,IAAI,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAO7C,OAAO,CAAC,MAAM,CAAC,CAAY;IAE3B,mFAAmF;IACnF,SAAS,KAAK,IAAI,IAAI,SAAS,GAAG,SAAS,CAE1C;IAED;;;;OAIG;IACH,SAAS,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,EAAE,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI;IASjF;;;;;;OAMG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAkB/B"}
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * oss!idle loads an add-on by turning its `source` into a Blob URL and
5
5
  * `import()`ing it in the page realm, then calling the module's `mount` /
6
- * `unmount` exports - that pair *is* the contract, and nothing else is passed
7
- * in. Everything a useful add-on needs (the game's database, its beatmap store,
8
- * the DOM) it reaches for itself, because it runs unsandboxed on the page.
6
+ * `unmount` exports - that pair *is* the contract. `mount` is handed the game's
7
+ * host (its React, the editor for add-ons of the Editor type: {@link host});
8
+ * the rest (the game's database, its beatmap store, the DOM) an add-on reaches
9
+ * for itself, because it runs unsandboxed on the page.
9
10
  *
10
11
  * Two consequences shape this class:
11
12
  *
@@ -19,6 +20,7 @@
19
20
  * them for you.
20
21
  */
21
22
  import { buildMeta } from './buildMeta.js';
23
+ import { gameHost, } from './host.js';
22
24
  /**
23
25
  * Base class for an add-on. Subclass it, implement {@link onMount}, and export
24
26
  * the pair the runtime wants with {@link defineAddon}.
@@ -71,12 +73,31 @@ export class Addon {
71
73
  * Public only because `defineAddon` lives outside the class and TypeScript
72
74
  * has no package-private visibility; the underscore marks it as off-limits.
73
75
  */
74
- async _mount() {
76
+ async _mount(host) {
75
77
  if (this.mounted)
76
78
  return;
77
79
  this.mounted = true;
80
+ this.handed = host;
78
81
  await this.onMount();
79
82
  }
83
+ handed;
84
+ /** The game's host: React, the editor. Undefined on a game too old to hand one. */
85
+ get host() {
86
+ return this.handed ?? gameHost();
87
+ }
88
+ /**
89
+ * Work with the editor while it is open (an add-on of the Editor type):
90
+ * `onOpen` runs each time it opens, with its API. What is registered through
91
+ * that API goes when the editor closes or this add-on unmounts.
92
+ */
93
+ editor(onOpen, onClose) {
94
+ const host = this.host;
95
+ if (!host) {
96
+ this.log.warn('this game hands add-ons no editor: update oss!idle');
97
+ return;
98
+ }
99
+ this.own(host.editor.watch(onOpen, onClose));
100
+ }
80
101
  /**
81
102
  * Internal: called by {@link defineAddon}, not by add-on code.
82
103
  *
@@ -1,8 +1,9 @@
1
1
  import { Addon } from './addon.js';
2
+ import type { AddonHost } from './host.js';
2
3
  /** The module shape oss!idle's add-on runtime imports and drives. */
3
4
  export type AddonModule = {
4
- mount: () => void;
5
- unmount: () => void;
5
+ mount: (meta?: unknown, host?: AddonHost) => void;
6
+ unmount: () => Promise<void>;
6
7
  };
7
8
  /**
8
9
  * Turn an {@link Addon} subclass into the `mount` / `unmount` pair the runtime
@@ -12,9 +13,11 @@ export type AddonModule = {
12
13
  * export const { mount, unmount } = defineAddon(() => new MyAddon());
13
14
  * ```
14
15
  *
15
- * The runtime calls both synchronously and ignores what they return, so an
16
+ * The runtime calls `mount` synchronously and ignores what it returns, so an
16
17
  * async `onMount` is started and left to run - its failure is reported here
17
18
  * rather than surfacing as an unhandled rejection in the player's console.
19
+ * `unmount` returns its teardown, which the runtime awaits before mounting an
20
+ * updated version.
18
21
  *
19
22
  * A mount still settling when `unmount` arrives (disable clicked mid-boot) is
20
23
  * awaited first, so teardown never races a half-built overlay.
@@ -1 +1 @@
1
- {"version":3,"file":"define.d.ts","sourceRoot":"","sources":["../../src/runtime/define.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,qEAAqE;AACrE,MAAM,MAAM,WAAW,GAAG;IACzB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,OAAO,EAAE,MAAM,IAAI,CAAC;CACpB,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,MAAM,KAAK,KAAG,WAqBjD,CAAC"}
1
+ {"version":3,"file":"define.d.ts","sourceRoot":"","sources":["../../src/runtime/define.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE3C,qEAAqE;AACrE,MAAM,MAAM,WAAW,GAAG;IACzB,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS,KAAK,IAAI,CAAC;IAClD,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,MAAM,KAAK,KAAG,WAsBjD,CAAC"}
@@ -6,9 +6,11 @@
6
6
  * export const { mount, unmount } = defineAddon(() => new MyAddon());
7
7
  * ```
8
8
  *
9
- * The runtime calls both synchronously and ignores what they return, so an
9
+ * The runtime calls `mount` synchronously and ignores what it returns, so an
10
10
  * async `onMount` is started and left to run - its failure is reported here
11
11
  * rather than surfacing as an unhandled rejection in the player's console.
12
+ * `unmount` returns its teardown, which the runtime awaits before mounting an
13
+ * updated version.
12
14
  *
13
15
  * A mount still settling when `unmount` arrives (disable clicked mid-boot) is
14
16
  * awaited first, so teardown never races a half-built overlay.
@@ -17,21 +19,22 @@ export const defineAddon = (create) => {
17
19
  let instance;
18
20
  let settling = Promise.resolve();
19
21
  return {
20
- mount: () => {
22
+ mount: (_meta, host) => {
21
23
  instance = create();
22
24
  const addon = instance;
23
- settling = addon._mount().catch((e) => {
25
+ settling = addon._mount(host).catch((e) => {
24
26
  console.error('[addon] mount failed', e);
25
27
  });
26
28
  },
27
29
  unmount: () => {
28
30
  const addon = instance;
29
31
  if (!addon)
30
- return;
32
+ return settling;
31
33
  instance = undefined;
32
34
  settling = settling
33
35
  .then(() => addon._unmount())
34
36
  .catch((e) => console.error('[addon] unmount failed', e));
37
+ return settling;
35
38
  },
36
39
  };
37
40
  };
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What the game hands an add-on: `mount(meta, host)`, and the same object on
3
+ * `globalThis.ossIdle` for code that runs before `mount` (the `sharedReact`
4
+ * build reads React there). Mirrors the game's `addons/host.ts` and
5
+ * `editor/editorApi.ts`; an older game hands none.
6
+ */
7
+ /** A teardown; running it undoes what returned it. */
8
+ type Disposer = () => void;
9
+ /** A note as the editor holds it; `end` makes it a long note. */
10
+ export type EditorNote = {
11
+ time: number;
12
+ column: number;
13
+ end?: number;
14
+ /** the .osu hitSound bits, as written */
15
+ hitSound: string;
16
+ /** the .osu sample fields, as written */
17
+ sample: string;
18
+ };
19
+ export type EditorTimingPoint = {
20
+ time: number;
21
+ /** ms per beat for a BPM point; -100 / the multiplier for a scroll speed one */
22
+ beatLength: number;
23
+ meter: number;
24
+ sound: string;
25
+ effects: string;
26
+ };
27
+ export type EditorMetadata = {
28
+ title: string;
29
+ titleUnicode: string;
30
+ artist: string;
31
+ artistUnicode: string;
32
+ creator: string;
33
+ version: string;
34
+ source: string;
35
+ tags: string;
36
+ previewTime: number;
37
+ };
38
+ /** Draws into `container`; what it returns runs when it goes. */
39
+ export type EditorRender = (container: HTMLElement) => void | (() => void);
40
+ /**
41
+ * The open editor. Notes and points are the editor's own objects: read them,
42
+ * never change them, and hand new ones to `edit`. Everything registered here
43
+ * goes when the editor closes or the add-on stops.
44
+ */
45
+ export type EditorApi = {
46
+ readonly keys: number;
47
+ notes(): readonly EditorNote[];
48
+ timing(): readonly EditorTimingPoint[];
49
+ scroll(): readonly EditorTimingPoint[];
50
+ metadata(): Readonly<EditorMetadata>;
51
+ bookmarks(): readonly number[];
52
+ text(): string;
53
+ edit(change: {
54
+ notes?: EditorNote[];
55
+ timing?: EditorTimingPoint[];
56
+ scroll?: EditorTimingPoint[];
57
+ bookmarks?: number[];
58
+ metadata?: Partial<EditorMetadata>;
59
+ }, select?: EditorNote[]): void;
60
+ undo(): void;
61
+ redo(): void;
62
+ save(): void;
63
+ selection(): EditorNote[];
64
+ select(notes: EditorNote[]): void;
65
+ now(): number;
66
+ seek(ms: number): void;
67
+ play(): void;
68
+ pause(): void;
69
+ isPlaying(): boolean;
70
+ snap(ms: number): number;
71
+ divisor(): number;
72
+ setDivisor(divisor: number): void;
73
+ onChange(callback: () => void): Disposer;
74
+ addAction(action: {
75
+ id: string;
76
+ label: string;
77
+ keys?: string[];
78
+ run: () => void;
79
+ }): Disposer;
80
+ addTab(tab: {
81
+ id: string;
82
+ label: string;
83
+ render: EditorRender;
84
+ }): Disposer;
85
+ addPanel(panel: {
86
+ id: string;
87
+ title?: string;
88
+ render: EditorRender;
89
+ }): Disposer;
90
+ openWindow(window: {
91
+ title: string;
92
+ width?: number;
93
+ height?: number;
94
+ render: EditorRender;
95
+ onClose?: () => void;
96
+ }): {
97
+ close(): void;
98
+ };
99
+ };
100
+ export type AddonHost = {
101
+ /** the game's version */
102
+ version: string;
103
+ /** the game's React modules by import path */
104
+ react: Record<string, unknown>;
105
+ editor: {
106
+ /** `onOpen` runs whenever the editor opens (now, if it is open), `onClose` when it closes. */
107
+ watch(onOpen: (editor: EditorApi) => void, onClose?: () => void): Disposer;
108
+ };
109
+ };
110
+ /** The game's host, if it hands one out (older games do not). */
111
+ export declare const gameHost: () => AddonHost | undefined;
112
+ export {};
113
+ //# sourceMappingURL=host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../../src/runtime/host.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * What the game hands an add-on: `mount(meta, host)`, and the same object on
3
+ * `globalThis.ossIdle` for code that runs before `mount` (the `sharedReact`
4
+ * build reads React there). Mirrors the game's `addons/host.ts` and
5
+ * `editor/editorApi.ts`; an older game hands none.
6
+ */
7
+ /** The game's host, if it hands one out (older games do not). */
8
+ export const gameHost = () => globalThis.ossIdle;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-idle/addon-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "oss!idle Addon SDK - build oss!idle add-ons in TypeScript. An Addon base class with automatic teardown, shadow-root React overlays, bridges to the game's database and beatmap store, and a bundler that emits the single-file ES module the add-on runtime loads.",
@@ -15,6 +15,25 @@ into the in-game add-on editor (Options → Add-ons → Manage) and enable it.
15
15
 
16
16
  `npm run dev` rebuilds on save; re-paste to see changes.
17
17
 
18
+ ## Publish from a repository
19
+
20
+ Instead of pasting, oss!idle can build your add-on from a public git repository
21
+ (any host: GitHub, GitLab, Codeberg...):
22
+
23
+ 1. Push this folder with its `package-lock.json` (`dist/` stays out of git).
24
+ 2. In game, create an add-on, choose **Built from a git repository** and give the
25
+ repository's https address.
26
+ 3. Add the webhook address it shows to your repository as a push webhook.
27
+ 4. Release by tagging: `npm version patch && git push --follow-tags`.
28
+
29
+ oss!idle refuses a built file that can run code nobody reviewed: `eval`, `new Function`,
30
+ `import()`, imports from an address, script elements, string timers and the like.
31
+ `npm run lint` flags them in your own code before you push.
32
+
33
+ Each new version tag is built by oss!idle itself (`npm ci`, then `npm run build`,
34
+ offline, with install scripts skipped) and goes to review; the moderator reads the
35
+ repository's changes, never the bundle.
36
+
18
37
  ## How an add-on runs
19
38
 
20
39
  The game imports your built file as an ES module from a Blob URL and calls its
@@ -2,5 +2,6 @@
2
2
  "name": "__ADDON_NAME__",
3
3
  "license": "__LICENSE__",
4
4
  "entry": "src/index.ts",
5
- "outfile": "dist/__SLUG__.js"
5
+ "outfile": "dist/__SLUG__.js",
6
+ "sharedReact": true
6
7
  }
@@ -0,0 +1,54 @@
1
+ import tseslint from 'typescript-eslint';
2
+
3
+ // oss!idle refuses an add-on whose built file can run code nobody reviewed: text turned
4
+ // into code, or code loaded from elsewhere. It checks the bundle when you publish;
5
+ // `npm run lint` tells you sooner, in your own code.
6
+ const NOT_REVIEWED = 'oss!idle refuses code that runs what was not reviewed';
7
+
8
+ export default tseslint.config(
9
+ { ignores: ['dist/', 'node_modules/'] },
10
+ {
11
+ files: ['**/*.{js,jsx,mjs,ts,tsx,mts}'],
12
+ languageOptions: {
13
+ parser: tseslint.parser,
14
+ // no-implied-eval only knows the timers as globals
15
+ globals: Object.fromEntries(['window', 'self', 'globalThis', 'setTimeout', 'setInterval']
16
+ .map(name => [name, 'readonly'])),
17
+ },
18
+ rules: {
19
+ 'no-eval': 'error',
20
+ 'no-implied-eval': 'error',
21
+ 'no-new-func': 'error',
22
+ 'no-script-url': 'error',
23
+ 'no-restricted-globals': ['error', 'eval', 'Function', 'importScripts'],
24
+ 'no-restricted-properties': ['error',
25
+ { object: 'document', property: 'write', message: NOT_REVIEWED },
26
+ { object: 'document', property: 'writeln', message: NOT_REVIEWED },
27
+ { property: 'eval', message: NOT_REVIEWED },
28
+ { property: 'Function', message: NOT_REVIEWED },
29
+ { property: 'srcdoc', message: NOT_REVIEWED },
30
+ { property: 'createContextualFragment', message: NOT_REVIEWED },
31
+ { property: 'serviceWorker', message: NOT_REVIEWED },
32
+ ],
33
+ 'no-restricted-syntax': ['error',
34
+ { selector: 'ImportExpression', message: `import() loads code from elsewhere: ${NOT_REVIEWED}` },
35
+ {
36
+ selector: 'ImportDeclaration[source.value=/^([a-z]+:|\\/\\/)/i]',
37
+ message: `Imports from an address load code from elsewhere: ${NOT_REVIEWED}`,
38
+ },
39
+ {
40
+ selector: 'CallExpression[callee.property.name=/^createElement(NS)?$/] > Literal[value=/^\\s*script\\s*$/i]',
41
+ message: `A script element loads or runs code: ${NOT_REVIEWED}`,
42
+ },
43
+ {
44
+ selector: 'CallExpression[callee.property.name=/^setAttribute(NS)?$/] > Literal[value=/^on/i]',
45
+ message: `An on… attribute runs its text as code: use addEventListener. ${NOT_REVIEWED}`,
46
+ },
47
+ {
48
+ selector: 'CallExpression[callee.property.name="constructor"], NewExpression[callee.property.name="constructor"]',
49
+ message: `A function's constructor builds code from text: ${NOT_REVIEWED}`,
50
+ },
51
+ ],
52
+ },
53
+ },
54
+ );