@ham2k/extension-tools 1.15.0 → 1.16.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/README.md +3 -1
- package/h2kext-build.mjs +90 -20
- package/h2kext-dev.mjs +13 -5
- package/h2kext.mjs +50 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,7 +46,9 @@ await buildExtension(build, { dir: import.meta.dirname })
|
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Reads your `manifest.json`, compiles `src/index.ts` into `build/index.js`, and
|
|
49
|
-
copies the manifest alongside it
|
|
49
|
+
copies the manifest alongside it — with the text of an `ABOUT.md` beside it as
|
|
50
|
+
the manifest's `about`, and of each `ABOUT.<locale>.md` as that translation's
|
|
51
|
+
(see the manifest docs). The esbuild settings it applies are
|
|
50
52
|
requirements of the runtime rather than preferences: one IIFE because the
|
|
51
53
|
sandbox evaluates a script and has no module loader, `neutral` because there is
|
|
52
54
|
no Node and no DOM, and `es2020` because that is what its engine speaks.
|
package/h2kext-build.mjs
CHANGED
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
// path a third party takes from being the untested one.
|
|
21
21
|
|
|
22
22
|
import { readFileSync } from "node:fs"
|
|
23
|
-
import { copyFile, mkdir, readFile } from "node:fs/promises"
|
|
23
|
+
import { copyFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises"
|
|
24
24
|
import { createRequire } from "node:module"
|
|
25
25
|
import { dirname, join, resolve } from "node:path"
|
|
26
26
|
import vm from "node:vm"
|
|
27
27
|
|
|
28
|
-
import { H2KEXT_API_VERSION, MANIFEST_ENTRY, TIMERS_API_VERSION, sharedModulesFor } from "./h2kext.mjs"
|
|
28
|
+
import { H2KEXT_API_VERSION, HALO_GLOBAL_API_VERSION, MANIFEST_ENTRY, TIMERS_API_VERSION, aboutProblem, sharedModulesFor } from "./h2kext.mjs"
|
|
29
29
|
|
|
30
30
|
/// What an extension's own `build.mjs` says, for everything that has to write
|
|
31
31
|
/// one: `h2kext-init`'s scaffold, and the samples the SDK ships.
|
|
@@ -160,7 +160,8 @@ export function sharedModulesPlugin({ sharedDependencies = {}, api = H2KEXT_API_
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
build.onLoad({ filter: /.*/, namespace: "ham2k-shared-module" }, (args) => ({
|
|
163
|
-
|
|
163
|
+
// `__polo` is the kernel's name on an app before extension API 16.
|
|
164
|
+
contents: `module.exports = (globalThis.__halo ?? globalThis.__polo).sharedModules[${JSON.stringify(args.path)}];`,
|
|
164
165
|
loader: "js",
|
|
165
166
|
}))
|
|
166
167
|
},
|
|
@@ -346,15 +347,64 @@ export async function buildExtension(esbuild, { dir, entry = "src/index.ts", out
|
|
|
346
347
|
}),
|
|
347
348
|
)
|
|
348
349
|
|
|
350
|
+
await checkKernelName({ bundle: join(out, "index.js"), manifest })
|
|
349
351
|
await checkSettingsPanelDeclared({ bundle: join(out, "index.js"), manifest, dir })
|
|
350
352
|
|
|
351
353
|
// The packer reads both from one directory, so the manifest the bundle was
|
|
352
354
|
// built against is the manifest that ships with it. Copied rather than left
|
|
353
355
|
// for the author to remember: a stale manifest beside a fresh bundle is a
|
|
354
|
-
// declared dependency list that no longer describes the code.
|
|
355
|
-
|
|
356
|
+
// declared dependency list that no longer describes the code. With ABOUT
|
|
357
|
+
// files it is written instead, carrying their text.
|
|
358
|
+
const shipped = await withAboutFiles(manifest, dir)
|
|
359
|
+
if (shipped === manifest) await copyFile(join(dir, MANIFEST_ENTRY), join(out, MANIFEST_ENTRY))
|
|
360
|
+
else await writeFile(join(out, MANIFEST_ENTRY), `${JSON.stringify(shipped, null, 2)}\n`)
|
|
356
361
|
|
|
357
|
-
return { outDir: out, manifest }
|
|
362
|
+
return { outDir: out, manifest: shipped }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/// `ABOUT.md`, and `ABOUT.<locale>.md` for a translation (`ABOUT.es.md`,
|
|
366
|
+
/// `ABOUT.pt-BR.md`), beside `manifest.json`.
|
|
367
|
+
export const ABOUT_FILE = /^ABOUT(?:\.([A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*))?\.md$/
|
|
368
|
+
|
|
369
|
+
/// [manifest] with the text of the ABOUT files in [dir] as its `about` and
|
|
370
|
+
/// its translations' `about` — or [manifest] itself, the same object, when
|
|
371
|
+
/// there are none. Markdown several paragraphs long is miserable to write as
|
|
372
|
+
/// one JSON string, so an author can keep it in a file of its own; the
|
|
373
|
+
/// bundle carries it in the manifest either way, where the app and the
|
|
374
|
+
/// catalog read it.
|
|
375
|
+
///
|
|
376
|
+
/// A text given both ways is refused rather than one silently winning: the
|
|
377
|
+
/// author would be editing a copy that never ships.
|
|
378
|
+
export async function withAboutFiles(manifest, dir) {
|
|
379
|
+
const names = (await readdir(dir)).filter((name) => ABOUT_FILE.test(name)).sort()
|
|
380
|
+
if (!names.length) return manifest
|
|
381
|
+
const folded = { ...manifest }
|
|
382
|
+
for (const name of names) {
|
|
383
|
+
const text = (await readFile(join(dir, name), "utf8")).trim()
|
|
384
|
+
// Checked here, naming the file: the packer would say the same about
|
|
385
|
+
// `'about'` in a manifest.json the author never wrote it into.
|
|
386
|
+
const problem = aboutProblem(text)
|
|
387
|
+
if (problem) throw new BuildError(`${name} ${problem.replace("leave it out instead", "delete the file instead")}`)
|
|
388
|
+
const locale = name.match(ABOUT_FILE)[1]
|
|
389
|
+
if (!locale) {
|
|
390
|
+
if (manifest.about != null) throw new BuildError(`both ${MANIFEST_ENTRY} and ${name} give 'about' — keep one`)
|
|
391
|
+
folded.about = text
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
const translations = folded.translations
|
|
395
|
+
if (translations !== undefined && (typeof translations !== "object" || translations === null || Array.isArray(translations))) {
|
|
396
|
+
throw new BuildError(`${name} is a translation, but ${MANIFEST_ENTRY}'s 'translations' is not an object of locales`)
|
|
397
|
+
}
|
|
398
|
+
const fields = translations?.[locale]
|
|
399
|
+
if (fields !== undefined && (typeof fields !== "object" || fields === null || Array.isArray(fields))) {
|
|
400
|
+
throw new BuildError(`${name} is the '${locale}' translation, but ${MANIFEST_ENTRY}'s 'translations.${locale}' is not an object`)
|
|
401
|
+
}
|
|
402
|
+
if (fields?.about != null) {
|
|
403
|
+
throw new BuildError(`both ${MANIFEST_ENTRY} 'translations.${locale}.about' and ${name} give it — keep one`)
|
|
404
|
+
}
|
|
405
|
+
folded.translations = { ...translations, [locale]: { ...translations?.[locale], about: text } }
|
|
406
|
+
}
|
|
407
|
+
return folded
|
|
358
408
|
}
|
|
359
409
|
|
|
360
410
|
/// Fails the build when the bundle registers a `settingsPanel` hook its
|
|
@@ -376,6 +426,22 @@ export async function buildExtension(esbuild, { dir, entry = "src/index.ts", out
|
|
|
376
426
|
/// told the check did not run.
|
|
377
427
|
const ACTIVATION_DEADLINE_MS = 2000
|
|
378
428
|
|
|
429
|
+
/// Refuses a bundle that declares extension API 16 or more but was built with
|
|
430
|
+
/// an SDK that reaches the kernel only as \`__polo\`, which such an app answers
|
|
431
|
+
/// for nothing that declares that level. The SDK and the tools are separate
|
|
432
|
+
/// packages, so a project can update one and not the other.
|
|
433
|
+
async function checkKernelName({ bundle, manifest }) {
|
|
434
|
+
const api = manifest.api ?? H2KEXT_API_VERSION
|
|
435
|
+
if (api < HALO_GLOBAL_API_VERSION) return
|
|
436
|
+
const source = await readFile(bundle, "utf8")
|
|
437
|
+
if (source.includes("__halo") || !source.includes("__polo")) return
|
|
438
|
+
throw new BuildError(
|
|
439
|
+
`${MANIFEST_ENTRY} declares extension API ${api}, where the app's kernel is \`__halo\`, but this bundle's ` +
|
|
440
|
+
`@ham2k/extension-sdk reaches it only as \`__polo\`, which the app keeps for bundles declaring a level below ` +
|
|
441
|
+
`${HALO_GLOBAL_API_VERSION}. Update @ham2k/extension-sdk to 1.${HALO_GLOBAL_API_VERSION} or later, or declare a lower "api".`,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
379
445
|
async function checkSettingsPanelDeclared({ bundle, manifest, dir }) {
|
|
380
446
|
const require = createRequire(resolve(dir, "noop.js"))
|
|
381
447
|
const sharedModules = new Proxy(
|
|
@@ -395,23 +461,27 @@ async function checkSettingsPanelDeclared({ bundle, manifest, dir }) {
|
|
|
395
461
|
)
|
|
396
462
|
const hostCall = async (method) => (method === "getSettings" ? {} : method.startsWith("dbLookup") ? [] : null)
|
|
397
463
|
const definitions = []
|
|
464
|
+
const kernel = {
|
|
465
|
+
defineExtension: (definition) => definitions.push(definition),
|
|
466
|
+
hostCall,
|
|
467
|
+
log: () => {},
|
|
468
|
+
invokeLocal: async () => [],
|
|
469
|
+
invokeLocalSequential: async () => [],
|
|
470
|
+
updateInterpretation: () => {},
|
|
471
|
+
getAccountKvKey: (extensionKey) => extensionKey,
|
|
472
|
+
registerDynamicForm: () => {},
|
|
473
|
+
unregisterDynamicForm: () => {},
|
|
474
|
+
host: { showForm: async () => null },
|
|
475
|
+
sharedModules,
|
|
476
|
+
sharedVersions: {},
|
|
477
|
+
}
|
|
478
|
+
// The names the app answers for the level the manifest declares: `__polo`
|
|
479
|
+
// only below HALO_GLOBAL_API_VERSION, whose SDKs may predate `__halo`.
|
|
398
480
|
const sandbox = {
|
|
399
481
|
console: { log() {}, info() {}, warn() {}, error() {}, debug() {} },
|
|
400
482
|
sendMessage: () => {},
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
hostCall,
|
|
404
|
-
log: () => {},
|
|
405
|
-
invokeLocal: async () => [],
|
|
406
|
-
invokeLocalSequential: async () => [],
|
|
407
|
-
updateInterpretation: () => {},
|
|
408
|
-
getAccountKvKey: (extensionKey) => extensionKey,
|
|
409
|
-
registerDynamicForm: () => {},
|
|
410
|
-
unregisterDynamicForm: () => {},
|
|
411
|
-
host: { showForm: async () => null },
|
|
412
|
-
sharedModules,
|
|
413
|
-
sharedVersions: {},
|
|
414
|
-
},
|
|
483
|
+
__halo: kernel,
|
|
484
|
+
...((manifest.api ?? H2KEXT_API_VERSION) < HALO_GLOBAL_API_VERSION ? { __polo: kernel } : {}),
|
|
415
485
|
}
|
|
416
486
|
sandbox.globalThis = sandbox
|
|
417
487
|
|
package/h2kext-dev.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import { networkInterfaces } from "node:os"
|
|
|
39
39
|
import { join, resolve } from "node:path"
|
|
40
40
|
import { pathToFileURL } from "node:url"
|
|
41
41
|
|
|
42
|
-
import { buildOptionsFor } from "./h2kext-build.mjs"
|
|
42
|
+
import { ABOUT_FILE, buildOptionsFor, withAboutFiles } from "./h2kext-build.mjs"
|
|
43
43
|
import { indexEntryFor, MANIFEST_ENTRY, validateManifest } from "./h2kext.mjs"
|
|
44
44
|
|
|
45
45
|
/// The protocol the app's dev client speaks. Bumped when the shape below
|
|
@@ -193,7 +193,10 @@ export async function createDevServer({
|
|
|
193
193
|
/// the extension from the manifest as it stands and the bundle route serves
|
|
194
194
|
/// [EMPTY_BUNDLE], so the app connects and hears the error.
|
|
195
195
|
let source = null
|
|
196
|
-
|
|
196
|
+
// Folded now as well, so an index served before the first good build lists
|
|
197
|
+
// the extension as it will ship; a problem with the files is the first
|
|
198
|
+
// build's to report.
|
|
199
|
+
let servedManifest = await withAboutFiles(startManifest, dir).catch(() => startManifest)
|
|
197
200
|
let servedManifestText = null
|
|
198
201
|
let buildError = null
|
|
199
202
|
const clients = new Set()
|
|
@@ -220,10 +223,14 @@ export async function createDevServer({
|
|
|
220
223
|
} catch (e) {
|
|
221
224
|
throw new Error(`${MANIFEST_ENTRY}: ${e.message}`)
|
|
222
225
|
}
|
|
226
|
+
// As the build folds them in, so the dialog shows what will ship. The
|
|
227
|
+
// text compared for "unchanged" is the folded one: an edit to ABOUT.md
|
|
228
|
+
// alone is a reload too.
|
|
229
|
+
parsed = await withAboutFiles(parsed, dir)
|
|
223
230
|
const problems = validateManifest(parsed)
|
|
224
231
|
if (problems.length) throw new Error(problems.join("\n"))
|
|
225
232
|
if (parsed.key !== key) throw new Error(`${MANIFEST_ENTRY}: 'key' changed from ${key} to ${parsed.key}; restart h2kext-dev to serve it`)
|
|
226
|
-
return { parsed, text }
|
|
233
|
+
return { parsed, text: JSON.stringify(parsed) }
|
|
227
234
|
}
|
|
228
235
|
|
|
229
236
|
/// The esbuild context, rebuilt from scratch when the manifest changes in
|
|
@@ -326,12 +333,13 @@ export async function createDevServer({
|
|
|
326
333
|
// The manifest may change without changing any bundle input (an entry that
|
|
327
334
|
// does not import it), and an editor that saves by writing a temporary file
|
|
328
335
|
// and renaming it replaces the file's inode, which a watch on the file alone
|
|
329
|
-
// would lose. So the directory is watched, for that one name
|
|
336
|
+
// would lose. So the directory is watched, for that one name — and for the
|
|
337
|
+
// ABOUT files, which reach the manifest the same way.
|
|
330
338
|
const manifestWatch = new AbortController()
|
|
331
339
|
;(async () => {
|
|
332
340
|
try {
|
|
333
341
|
for await (const event of watch(dir, { signal: manifestWatch.signal })) {
|
|
334
|
-
if (event.filename !== MANIFEST_ENTRY) continue
|
|
342
|
+
if (event.filename !== MANIFEST_ENTRY && !ABOUT_FILE.test(event.filename ?? "")) continue
|
|
335
343
|
await restarting
|
|
336
344
|
await ctx?.rebuild().catch(() => {})
|
|
337
345
|
}
|
package/h2kext.mjs
CHANGED
|
@@ -56,8 +56,9 @@ import yazl from "yazl"
|
|
|
56
56
|
/// `radio.status` — `when` on layers and controls, and the `radio` panel
|
|
57
57
|
/// trigger; 14 adds `host.fyi`, messages on the FYI line under the call
|
|
58
58
|
/// info; 15 adds `incomplete` on a live scoring verdict, the fields a
|
|
59
|
-
/// contact still needs before Enter logs it
|
|
60
|
-
|
|
59
|
+
/// contact still needs before Enter logs it; 16 names the kernel `__halo`,
|
|
60
|
+
/// and keeps `__polo` only for a bundle that declares a lower level.
|
|
61
|
+
export const H2KEXT_API_VERSION = 16
|
|
61
62
|
|
|
62
63
|
/// The first API with `host.webSocket`. A manifest declaring `webSockets`
|
|
63
64
|
/// under an older one is refused: an older app would install it and fail only
|
|
@@ -80,6 +81,12 @@ export const CLUSTER_API_VERSION = 10
|
|
|
80
81
|
/// install it and refuse every raw command it sends.
|
|
81
82
|
export const RADIO_RAW_API_VERSION = 11
|
|
82
83
|
|
|
84
|
+
/// The first API whose app names the kernel `__halo`. Below it, the app also
|
|
85
|
+
/// answers `__polo`; from it, nothing answers that name for the bundle. A
|
|
86
|
+
/// bundle declaring this level or more whose SDK reaches only `__polo` is
|
|
87
|
+
/// refused at build: it would fail to load on the app it declares.
|
|
88
|
+
export const HALO_GLOBAL_API_VERSION = 16
|
|
89
|
+
|
|
83
90
|
/// The first API with timers. The build points a bundle's bare `setTimeout`
|
|
84
91
|
/// and friends at the SDK's only from here on: an older app's kernel hands an
|
|
85
92
|
/// extension no timers, and a bundle declaring less says it runs there.
|
|
@@ -141,6 +148,39 @@ export function keyProblem(key, { forceName = false } = {}) {
|
|
|
141
148
|
return null
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
/// The longest `about` the packer, the app and the catalog accept, in each
|
|
152
|
+
/// language: room for several paragraphs, not for a manual. It travels on
|
|
153
|
+
/// every catalog listing entry, so its size is everyone's download.
|
|
154
|
+
export const ABOUT_MAX_LENGTH = 10000
|
|
155
|
+
|
|
156
|
+
/// What is wrong with [value] as an `about` text, or null. Markdown, at most
|
|
157
|
+
/// [ABOUT_MAX_LENGTH] characters, and not blank: an empty one would draw an
|
|
158
|
+
/// empty section where the app would otherwise draw none.
|
|
159
|
+
export function aboutProblem(value) {
|
|
160
|
+
if (typeof value !== "string") return "must be a string of markdown"
|
|
161
|
+
if (value.trim() === "") return "is empty — leave it out instead"
|
|
162
|
+
if (value.length > ABOUT_MAX_LENGTH) return `is ${value.length} characters; at most ${ABOUT_MAX_LENGTH} are allowed`
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Every problem with [manifest]'s `about` and its translations' `about`, as
|
|
167
|
+
/// "'<field>' <problem>" — one list the packer and the built-in build share.
|
|
168
|
+
export function aboutProblems(manifest) {
|
|
169
|
+
const problems = []
|
|
170
|
+
if (manifest?.about !== undefined) {
|
|
171
|
+
const problem = aboutProblem(manifest.about)
|
|
172
|
+
if (problem) problems.push(`'about' ${problem}`)
|
|
173
|
+
}
|
|
174
|
+
if (typeof manifest?.translations === "object" && manifest.translations !== null) {
|
|
175
|
+
for (const [locale, fields] of Object.entries(manifest.translations)) {
|
|
176
|
+
if (fields?.about === undefined) continue
|
|
177
|
+
const problem = aboutProblem(fields.about)
|
|
178
|
+
if (problem) problems.push(`'translations.${locale}.about' ${problem}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return problems
|
|
182
|
+
}
|
|
183
|
+
|
|
144
184
|
/// The only entries a bundle may contain, beyond `assets/`.
|
|
145
185
|
export const MANIFEST_ENTRY = "manifest.json"
|
|
146
186
|
export const BUNDLE_ENTRY = "index.js"
|
|
@@ -148,7 +188,7 @@ export const ASSETS_PREFIX = "assets/"
|
|
|
148
188
|
|
|
149
189
|
/// The libraries the extension host carries, per API version, so a bundle does
|
|
150
190
|
/// not have to ship its own copy of each. The kernel holds one instance and
|
|
151
|
-
/// hands it to every extension through `
|
|
191
|
+
/// hands it to every extension through `__halo.sharedModules`.
|
|
152
192
|
///
|
|
153
193
|
/// NAMES only. The VERSION a given app carries is that build's business, which
|
|
154
194
|
/// is the whole reason a bundle declares a RANGE in `sharedDependencies` and
|
|
@@ -189,6 +229,7 @@ SHARED_MODULES_BY_API[12] = SHARED_MODULES_BY_API[11]
|
|
|
189
229
|
SHARED_MODULES_BY_API[13] = SHARED_MODULES_BY_API[12]
|
|
190
230
|
SHARED_MODULES_BY_API[14] = SHARED_MODULES_BY_API[13]
|
|
191
231
|
SHARED_MODULES_BY_API[15] = SHARED_MODULES_BY_API[14]
|
|
232
|
+
SHARED_MODULES_BY_API[16] = SHARED_MODULES_BY_API[15]
|
|
192
233
|
|
|
193
234
|
/// What a scaffolded extension declares on day one: the eight modules the SDK
|
|
194
235
|
/// barrel itself reaches, so a project that has written no code yet still
|
|
@@ -481,6 +522,8 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
|
|
|
481
522
|
say("'experiments' refers to the app's own experiment catalog and would hide this extension permanently — remove it")
|
|
482
523
|
}
|
|
483
524
|
|
|
525
|
+
for (const problem of aboutProblems(manifest)) say(problem)
|
|
526
|
+
|
|
484
527
|
// The name `relevance` replaced. Nothing reads `geo`, and nothing here
|
|
485
528
|
// rejects unknown top-level keys, so a manifest still carrying it would
|
|
486
529
|
// pack, publish and install as WORLDWIDE — the author's audience silently
|
|
@@ -511,7 +554,8 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
|
|
|
511
554
|
}
|
|
512
555
|
|
|
513
556
|
/// Every library a bundle takes from the host rather than carrying its own
|
|
514
|
-
/// copy — the `
|
|
557
|
+
/// copy — the `sharedModules[...]` lookups on the kernel its build emitted,
|
|
558
|
+
/// under either of the kernel's names.
|
|
515
559
|
///
|
|
516
560
|
/// Taking them is the supported path and worth roughly thirty times the bundle
|
|
517
561
|
/// size. What the caller checks is that each one was DECLARED: the host carries
|
|
@@ -522,7 +566,7 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
|
|
|
522
566
|
/// else's app.
|
|
523
567
|
export function findSharedModuleReferences(source) {
|
|
524
568
|
const hits = []
|
|
525
|
-
const pattern = /
|
|
569
|
+
const pattern = /__(?:halo|polo)\s*\)?\s*\.\s*sharedModules\s*\[\s*["']([^"']+)["']\s*\]/g
|
|
526
570
|
for (const match of source.matchAll(pattern)) hits.push(match[1])
|
|
527
571
|
return [...new Set(hits)]
|
|
528
572
|
}
|
|
@@ -676,6 +720,7 @@ export function indexEntryFor(key, m) {
|
|
|
676
720
|
enabledByDefault: m.enabledByDefault ?? true,
|
|
677
721
|
...(m.icon ? { icon: m.icon } : {}),
|
|
678
722
|
...(m.description ? { description: m.description } : {}),
|
|
723
|
+
...(m.about ? { about: m.about } : {}),
|
|
679
724
|
...(m.accentColor ? { accentColor: m.accentColor } : {}),
|
|
680
725
|
...(m.keywords ? { keywords: m.keywords } : {}),
|
|
681
726
|
...(m.hooks ? { hooks: m.hooks } : {}),
|