@ham2k/extension-tools 1.16.0 → 1.17.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 +3 -1
- package/h2kext-build.mjs +53 -5
- package/h2kext-dev.mjs +21 -8
- package/h2kext.mjs +41 -2
- 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, HALO_GLOBAL_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.
|
|
@@ -353,10 +353,58 @@ export async function buildExtension(esbuild, { dir, entry = "src/index.ts", out
|
|
|
353
353
|
// The packer reads both from one directory, so the manifest the bundle was
|
|
354
354
|
// built against is the manifest that ships with it. Copied rather than left
|
|
355
355
|
// for the author to remember: a stale manifest beside a fresh bundle is a
|
|
356
|
-
// declared dependency list that no longer describes the code.
|
|
357
|
-
|
|
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`)
|
|
358
361
|
|
|
359
|
-
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
|
|
360
408
|
}
|
|
361
409
|
|
|
362
410
|
/// Fails the build when the bundle registers a `settingsPanel` hook its
|
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
|
|
@@ -104,6 +104,9 @@ export function parseArgs(argv) {
|
|
|
104
104
|
case "--dir":
|
|
105
105
|
options.dir = resolve(value())
|
|
106
106
|
break
|
|
107
|
+
case "--force-name":
|
|
108
|
+
options.forceName = true
|
|
109
|
+
break
|
|
107
110
|
case "--help":
|
|
108
111
|
case "-h":
|
|
109
112
|
options.help = true
|
|
@@ -115,7 +118,7 @@ export function parseArgs(argv) {
|
|
|
115
118
|
return options
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
export const USAGE = `usage: h2kext-dev [--port <n>] [--host <address>] [--entry <file>] [--inline <pkg,…>] [--dir <path>]
|
|
121
|
+
export const USAGE = `usage: h2kext-dev [--port <n>] [--host <address>] [--entry <file>] [--inline <pkg,…>] [--dir <path>] [--force-name]
|
|
119
122
|
|
|
120
123
|
Serves the extension in the current directory to the app's Extension Dev
|
|
121
124
|
Server (Settings → Developer), rebuilding it as you save and printing what it
|
|
@@ -125,7 +128,8 @@ logs. Point the app at one of the addresses it prints.
|
|
|
125
128
|
--host the address to bind (default 0.0.0.0, so a phone on the LAN can connect)
|
|
126
129
|
--entry the entry point (default src/index.ts); pass what build.mjs passes
|
|
127
130
|
--inline shared libraries to bundle rather than take from the app; pass what build.mjs passes
|
|
128
|
-
--dir the extension directory (default the current directory)
|
|
131
|
+
--dir the extension directory (default the current directory)
|
|
132
|
+
--force-name Ham2K's own extensions: waives the key convention, as h2kext-pack's does`
|
|
129
133
|
|
|
130
134
|
/// The author's own esbuild, resolved from their project rather than from
|
|
131
135
|
/// wherever this file is installed: the preset is written to use the caller's
|
|
@@ -164,6 +168,7 @@ export async function createDevServer({
|
|
|
164
168
|
port = DEFAULT_PORT,
|
|
165
169
|
entry = "src/index.ts",
|
|
166
170
|
inline = [],
|
|
171
|
+
forceName = false,
|
|
167
172
|
log = console.log,
|
|
168
173
|
warn = console.error,
|
|
169
174
|
}) {
|
|
@@ -193,7 +198,10 @@ export async function createDevServer({
|
|
|
193
198
|
/// the extension from the manifest as it stands and the bundle route serves
|
|
194
199
|
/// [EMPTY_BUNDLE], so the app connects and hears the error.
|
|
195
200
|
let source = null
|
|
196
|
-
|
|
201
|
+
// Folded now as well, so an index served before the first good build lists
|
|
202
|
+
// the extension as it will ship; a problem with the files is the first
|
|
203
|
+
// build's to report.
|
|
204
|
+
let servedManifest = await withAboutFiles(startManifest, dir).catch(() => startManifest)
|
|
197
205
|
let servedManifestText = null
|
|
198
206
|
let buildError = null
|
|
199
207
|
const clients = new Set()
|
|
@@ -220,10 +228,14 @@ export async function createDevServer({
|
|
|
220
228
|
} catch (e) {
|
|
221
229
|
throw new Error(`${MANIFEST_ENTRY}: ${e.message}`)
|
|
222
230
|
}
|
|
223
|
-
|
|
231
|
+
// As the build folds them in, so the dialog shows what will ship. The
|
|
232
|
+
// text compared for "unchanged" is the folded one: an edit to ABOUT.md
|
|
233
|
+
// alone is a reload too.
|
|
234
|
+
parsed = await withAboutFiles(parsed, dir)
|
|
235
|
+
const problems = validateManifest(parsed, { forceName })
|
|
224
236
|
if (problems.length) throw new Error(problems.join("\n"))
|
|
225
237
|
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 }
|
|
238
|
+
return { parsed, text: JSON.stringify(parsed) }
|
|
227
239
|
}
|
|
228
240
|
|
|
229
241
|
/// The esbuild context, rebuilt from scratch when the manifest changes in
|
|
@@ -326,12 +338,13 @@ export async function createDevServer({
|
|
|
326
338
|
// The manifest may change without changing any bundle input (an entry that
|
|
327
339
|
// does not import it), and an editor that saves by writing a temporary file
|
|
328
340
|
// 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
|
|
341
|
+
// would lose. So the directory is watched, for that one name — and for the
|
|
342
|
+
// ABOUT files, which reach the manifest the same way.
|
|
330
343
|
const manifestWatch = new AbortController()
|
|
331
344
|
;(async () => {
|
|
332
345
|
try {
|
|
333
346
|
for await (const event of watch(dir, { signal: manifestWatch.signal })) {
|
|
334
|
-
if (event.filename !== MANIFEST_ENTRY) continue
|
|
347
|
+
if (event.filename !== MANIFEST_ENTRY && !ABOUT_FILE.test(event.filename ?? "")) continue
|
|
335
348
|
await restarting
|
|
336
349
|
await ctx?.rebuild().catch(() => {})
|
|
337
350
|
}
|
package/h2kext.mjs
CHANGED
|
@@ -57,8 +57,10 @@ import yazl from "yazl"
|
|
|
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
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
|
-
|
|
60
|
+
/// and keeps `__polo` only for a bundle that declares a lower level; 17
|
|
61
|
+
/// makes a spectrum's gestures the host's — tuning, zoom, pan and an axis —
|
|
62
|
+
/// unless its scene's `gestures` and `scale` say otherwise.
|
|
63
|
+
export const H2KEXT_API_VERSION = 17
|
|
62
64
|
|
|
63
65
|
/// The first API with `host.webSocket`. A manifest declaring `webSockets`
|
|
64
66
|
/// under an older one is refused: an older app would install it and fail only
|
|
@@ -148,6 +150,39 @@ export function keyProblem(key, { forceName = false } = {}) {
|
|
|
148
150
|
return null
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/// The longest `about` the packer, the app and the catalog accept, in each
|
|
154
|
+
/// language: room for several paragraphs, not for a manual. It travels on
|
|
155
|
+
/// every catalog listing entry, so its size is everyone's download.
|
|
156
|
+
export const ABOUT_MAX_LENGTH = 10000
|
|
157
|
+
|
|
158
|
+
/// What is wrong with [value] as an `about` text, or null. Markdown, at most
|
|
159
|
+
/// [ABOUT_MAX_LENGTH] characters, and not blank: an empty one would draw an
|
|
160
|
+
/// empty section where the app would otherwise draw none.
|
|
161
|
+
export function aboutProblem(value) {
|
|
162
|
+
if (typeof value !== "string") return "must be a string of markdown"
|
|
163
|
+
if (value.trim() === "") return "is empty — leave it out instead"
|
|
164
|
+
if (value.length > ABOUT_MAX_LENGTH) return `is ${value.length} characters; at most ${ABOUT_MAX_LENGTH} are allowed`
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// Every problem with [manifest]'s `about` and its translations' `about`, as
|
|
169
|
+
/// "'<field>' <problem>" — one list the packer and the built-in build share.
|
|
170
|
+
export function aboutProblems(manifest) {
|
|
171
|
+
const problems = []
|
|
172
|
+
if (manifest?.about !== undefined) {
|
|
173
|
+
const problem = aboutProblem(manifest.about)
|
|
174
|
+
if (problem) problems.push(`'about' ${problem}`)
|
|
175
|
+
}
|
|
176
|
+
if (typeof manifest?.translations === "object" && manifest.translations !== null) {
|
|
177
|
+
for (const [locale, fields] of Object.entries(manifest.translations)) {
|
|
178
|
+
if (fields?.about === undefined) continue
|
|
179
|
+
const problem = aboutProblem(fields.about)
|
|
180
|
+
if (problem) problems.push(`'translations.${locale}.about' ${problem}`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return problems
|
|
184
|
+
}
|
|
185
|
+
|
|
151
186
|
/// The only entries a bundle may contain, beyond `assets/`.
|
|
152
187
|
export const MANIFEST_ENTRY = "manifest.json"
|
|
153
188
|
export const BUNDLE_ENTRY = "index.js"
|
|
@@ -197,6 +232,7 @@ SHARED_MODULES_BY_API[13] = SHARED_MODULES_BY_API[12]
|
|
|
197
232
|
SHARED_MODULES_BY_API[14] = SHARED_MODULES_BY_API[13]
|
|
198
233
|
SHARED_MODULES_BY_API[15] = SHARED_MODULES_BY_API[14]
|
|
199
234
|
SHARED_MODULES_BY_API[16] = SHARED_MODULES_BY_API[15]
|
|
235
|
+
SHARED_MODULES_BY_API[17] = SHARED_MODULES_BY_API[16]
|
|
200
236
|
|
|
201
237
|
/// What a scaffolded extension declares on day one: the eight modules the SDK
|
|
202
238
|
/// barrel itself reaches, so a project that has written no code yet still
|
|
@@ -489,6 +525,8 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
|
|
|
489
525
|
say("'experiments' refers to the app's own experiment catalog and would hide this extension permanently — remove it")
|
|
490
526
|
}
|
|
491
527
|
|
|
528
|
+
for (const problem of aboutProblems(manifest)) say(problem)
|
|
529
|
+
|
|
492
530
|
// The name `relevance` replaced. Nothing reads `geo`, and nothing here
|
|
493
531
|
// rejects unknown top-level keys, so a manifest still carrying it would
|
|
494
532
|
// pack, publish and install as WORLDWIDE — the author's audience silently
|
|
@@ -685,6 +723,7 @@ export function indexEntryFor(key, m) {
|
|
|
685
723
|
enabledByDefault: m.enabledByDefault ?? true,
|
|
686
724
|
...(m.icon ? { icon: m.icon } : {}),
|
|
687
725
|
...(m.description ? { description: m.description } : {}),
|
|
726
|
+
...(m.about ? { about: m.about } : {}),
|
|
688
727
|
...(m.accentColor ? { accentColor: m.accentColor } : {}),
|
|
689
728
|
...(m.keywords ? { keywords: m.keywords } : {}),
|
|
690
729
|
...(m.hooks ? { hooks: m.hooks } : {}),
|