@ham2k/extension-tools 1.16.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 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. The esbuild settings it applies are
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
- await copyFile(join(dir, MANIFEST_ENTRY), join(out, MANIFEST_ENTRY))
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
@@ -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
- let servedManifest = startManifest
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
@@ -148,6 +148,39 @@ export function keyProblem(key, { forceName = false } = {}) {
148
148
  return null
149
149
  }
150
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
+
151
184
  /// The only entries a bundle may contain, beyond `assets/`.
152
185
  export const MANIFEST_ENTRY = "manifest.json"
153
186
  export const BUNDLE_ENTRY = "index.js"
@@ -489,6 +522,8 @@ export function validateManifest(manifest, { source = MANIFEST_ENTRY, forceName
489
522
  say("'experiments' refers to the app's own experiment catalog and would hide this extension permanently — remove it")
490
523
  }
491
524
 
525
+ for (const problem of aboutProblems(manifest)) say(problem)
526
+
492
527
  // The name `relevance` replaced. Nothing reads `geo`, and nothing here
493
528
  // rejects unknown top-level keys, so a manifest still carrying it would
494
529
  // pack, publish and install as WORLDWIDE — the author's audience silently
@@ -685,6 +720,7 @@ export function indexEntryFor(key, m) {
685
720
  enabledByDefault: m.enabledByDefault ?? true,
686
721
  ...(m.icon ? { icon: m.icon } : {}),
687
722
  ...(m.description ? { description: m.description } : {}),
723
+ ...(m.about ? { about: m.about } : {}),
688
724
  ...(m.accentColor ? { accentColor: m.accentColor } : {}),
689
725
  ...(m.keywords ? { keywords: m.keywords } : {}),
690
726
  ...(m.hooks ? { hooks: m.hooks } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-tools",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
4
4
  "description": "Build and package extensions for the Ham2K Logger",
5
5
  "keywords": [
6
6
  "ham2k",