@bridgeline-digital/hawk-app-cli 0.1.2-dev.20261027234716 → 0.1.2-dev.20261028113950

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
@@ -648,6 +648,25 @@ and a version whose functions changed **always needs review**, even on an auto-a
648
648
  5. Call it from the app: `await app.functions.call('sync-feed', { since: '2026-09-01' })`. Like queries, functions
649
649
  always run from a **deployed** version, also in dev mode (`?devUrl=`).
650
650
 
651
+ **Contracts.** Declare what a function takes and returns with zod (`npm install zod`), and deploy sends them with the
652
+ function: the platform then refuses a call whose input doesn't match (400 `invalid_function_input`) before it runs.
653
+
654
+ ```ts
655
+ import { z } from 'zod';
656
+ import { defineFunction } from '@bridgeline-digital/hawk-app-bridge/server';
657
+ export const handler = defineFunction({
658
+ input: z.object({ name: z.string() }),
659
+ output: z.object({ greeting: z.string() }),
660
+ async handler(input, ctx) { return { greeting: `Hello ${input.name} from ${ctx.client.id}` }; },
661
+ });
662
+ ```
663
+
664
+ `npx hawk-app types` writes `hawk-functions.d.ts` (in `src/`, else next to `hawk-app.json`) from the contracts, so
665
+ pages get `app.functions.call('echo', { name })` typed without a type argument; `hawk-app dev` refreshes it when it
666
+ starts, and deploy warns when it's out of date. Deploy prints `contracts: <names>` and stops when a contract can't be
667
+ read (a schema that isn't zod, or larger than 6 KB as JSON Schema). Client API endpoints get an OpenAPI document from
668
+ them, and approvers see contract changes that may break callers.
669
+
651
670
  `hawk-app validate` and `hawk-app deploy` check, with the platform's rules:
652
671
  - at most 10 functions; names are lowercase letters, digits and single dashes, up to 40 characters;
653
672
  - `job` true or false; `timeoutSeconds` 1–25 (1–900 for a job), `memoryMb` 128–1024, `secrets` up to 20 unique
@@ -785,7 +804,9 @@ param is read:
785
804
  Deploy; once the version is approved, the buttons appear in HawkSearch with no change there. `actions` always needs an
786
805
  approver (never auto-approve): reviewers see each target and what it reads. Pick stable selectors (ids, `data-*`
787
806
  attributes, element names): if HawkSearch changes its markup, deploy new targets. A param can also name a `data-*`
788
- attribute (`"data": "hawk-keyword"`), used as is when the page has it, with its selector and pattern as the fallback. The view reads
807
+ attribute (`"data": "hawk-keyword"`), used as is when the page has it, with its selector and pattern as the fallback.
808
+ A target's `when` shows the button only where the values read meet conditions (`equals`, `in` or `pattern`, ignoring
809
+ case), e.g. `"when": { "category": { "in": ["Synonym", "Redirect"] } }`. The view reads
789
810
  `app.context.view.params` and `app.context.view.action`, and calls `app.action.done({ changed })` to close the dialog
790
811
  (the bridge README's **Actions**, with every target setting). `hawk-app validate` checks the targets (paths and
791
812
  patterns are regular expressions, params the view declares); `hawk-app status` lists the actions. `hawk-app dev` has an
package/agents/AGENTS.md CHANGED
@@ -148,6 +148,11 @@ With the UI kit: `usePageContext(rows ? { page: '<the page>', dateRange, totals,
148
148
  `app.assistant.setContext({ ... })` whenever the data or filters change. Plain names and numbers of what's on screen,
149
149
  at most 50 KB; summarize long tables. `hawk-app validate` warns when an app never sets one.
150
150
 
151
+ **Help** (`HELP.md` next to hawk-app.json): the app's guide for its users, which HawkSearch's Help tab answers "How do
152
+ I…?" from. Keep it in step with the app: when you add or change a screen, a button or a workflow, update the steps in
153
+ HELP.md too, naming buttons and fields exactly as the UI shows them. Plain Markdown for merchandisers (not developers),
154
+ at most 32 KB; it ships with each deploy. HTML comments are notes for the team and are left out.
155
+
151
156
  **Comments** (scope `comments`): `<HawkComments target="keyword:…" label="…" />` from the UI kit on what people discuss
152
157
  (a row, a widget, `page:summary`); targets are stable ids, never personal data. Declare `config.commentEmails`
153
158
  (`{ "type": "emails" }`) so new comments are emailed (`hawk-app config set commentEmails …`, a person or a Slack
@@ -175,8 +180,9 @@ UI kit in each tab (or `app.usage.screen`), and mark the main buttons `data-hawk
175
180
  **Actions** (`actions` in `hawk-app.json`) add a button to HawkSearch's own pages that opens a view in a dialog:
176
181
  `{ "<name>": { "label": "Fix with …", "view": "fix", "targets": { "hawksearch": [{ "path": "<regex on the page path>",
177
182
  "selector": "<CSS: each thing>", "anchor"?: "<CSS inside it>", "position"?: "after", "params": { "keyword": { "data"?,
178
- "selector"?, "attribute"?, "pattern"? } } }] } } }`. `"data": "keyword"` reads `data-keyword` as is when the page has it,
179
- else the rest of the reader applies. Prefer stable selectors (ids, `data-*`, element names); the view must list the
183
+ "selector"?, "attribute"?, "pattern"? } }, "when"?: { "<param>": { "equals" | "in" | "pattern": … } } }] } } }`.
184
+ `"data": "keyword"` reads `data-keyword` as is when the page has it, else the rest of the reader applies. Use `when` so
185
+ the button shows only where the action applies (conditions on the values read, ignoring case), not on every match. Prefer stable selectors (ids, `data-*`, element names); the view must list the
180
186
  params in its `params` and handle them missing. The view reads `app.context.view.params` (and `context.view.action`:
181
187
  `{ name }`), its toasts show, and `app.action.done({ changed: true })` closes the dialog once the work is saved. Actions
182
188
  always need an approver. Try it with `hawk-app dev`'s Action menu.
@@ -195,6 +201,11 @@ page, at the slot's height, so don't rely on the full window either.
195
201
  `@bridgeline-digital/hawk-app-bridge/server`. Use `ctx.platform` (same APIs as `app`), `ctx.secrets.NAME`,
196
202
  `ctx.client.id`, `ctx.user`. Stateless; 25 s, or 900 s for a job (`"job": true`). Engineering enables functions per
197
203
  app.
204
+ - **Contracts** (prefer for every function): `export const handler = defineFunction({ input: z.object({…}), output:
205
+ z.object({…}), async handler(input, ctx) {…} })` with zod (add it to the app). The platform refuses a call whose
206
+ input doesn't match (400 `invalid_function_input`) before the function runs. Run `npx hawk-app types` after changing
207
+ a contract: it writes `hawk-functions.d.ts`, so pages call `app.functions.call('<name>', input)` without a type
208
+ argument and get the result typed. Client API endpoints get an OpenAPI document from them.
198
209
 
199
210
  ## Project
200
211
 
@@ -176,6 +176,11 @@ buttons appear in HawkSearch: nothing changes in HawkSearch for it.
176
176
  as is (no pattern); when it's missing or empty, the param's `selector`, `attribute` and `pattern` read it as above.
177
177
  So a target can be ready for an attribute HawkSearch adds later, e.g. `"keyword": { "data": "hawk-keyword",
178
178
  "selector": "td.table__cell:first-child" }`. With `data` alone, nothing is read until the attribute is there.
179
+ - **Only where it applies (optional):** `when` shows the button only on the things whose values meet every condition,
180
+ by param (one of the target's `params`): `{ "equals": "…" }`, `{ "in": ["…", …] }` (at most 20) or
181
+ `{ "pattern": "<regex>" }`, all ignoring case. A param that couldn't be read fails its condition, so no button goes
182
+ where a value is missing. Use it for what a CSS selector can't say: a cell's text, a value a pattern pulls out, e.g.
183
+ `"when": { "category": { "in": ["Zero results", "Poor results"] }, "keyword": { "pattern": "\\S" } }`.
179
184
  - **Pick stable selectors:** ids, `data-*` attributes and element names over styling classes. HawkSearch doesn't know
180
185
  about your selectors: when it changes its markup, your buttons stop appearing until you deploy new targets.
181
186
  - **Approval:** `actions` is a setting that always needs an approver (never auto-approve): reviewers see each target and
@@ -577,6 +582,42 @@ const { items } = await app.functions.call<{ items: unknown[] }>('sync-feed', {
577
582
  what `handler` returned (up to 6 MB). It waits up to `timeoutMs` (default 30 s) and retries while the platform answers
578
583
  429 (too many calls running), like data queries.
579
584
 
585
+ ### Contracts: `defineFunction`
586
+
587
+ Say what a function takes and returns with zod schemas (add `zod` to the app), and every caller agrees on one shape:
588
+
589
+ ```ts
590
+ // functions/price-check.ts
591
+ import { z } from 'zod';
592
+ import { defineFunction } from '@bridgeline-digital/hawk-app-bridge/server';
593
+
594
+ export const handler = defineFunction({
595
+ input: z.object({ sku: z.string().min(1), limit: z.number().int().default(5) }),
596
+ output: z.object({ ours: z.number(), lowest: z.number().nullable() }),
597
+ async handler({ sku, limit }, ctx) { // typed from input: limit is a number (its default applied)
598
+ return { ours: 10, lowest: null };
599
+ },
600
+ });
601
+ ```
602
+
603
+ ```ts
604
+ // In the app, after `npx hawk-app types` (hawk-app dev runs it when it starts)
605
+ const price = await app.functions.call('price-check', { sku: 'abc' }); // input checked, result typed
606
+ ```
607
+
608
+ - **The platform checks every call's input** against the contract before the function runs: a mismatch is
609
+ `HawkApiError` 400 `invalid_function_input`, with `errors` saying what's wrong (`"/sku: …"`); nothing runs or is
610
+ billed. Jobs (`start`) and the app's client API endpoints are checked the same way.
611
+ - **Typed calls:** `hawk-app types` writes `hawk-functions.d.ts` from the contracts; `call` and `start` are then typed
612
+ for those functions, and a function without a contract stays `call<T>(name, input)`. Deploy warns when the file is
613
+ behind the contracts.
614
+ - **Client API:** an endpoint's function contract types a GET's query (`?limit=5` is a number) and describes the
615
+ endpoint in the OpenAPI document the API access screen offers.
616
+ - **Review:** a contract change that may break callers (a newly required input field, a removed output field, a
617
+ changed type) is flagged for the approver.
618
+ - Any Standard Schema works for the input check in the function, but deploy describes only zod schemas (zod 4's
619
+ `toJSONSchema`). Keep each schema to the fields callers send and read (at most 6 KB as JSON Schema).
620
+
580
621
  **`ctx`** (`HawkFunctionContext`)
581
622
 
582
623
  | | |
@@ -1566,6 +1607,20 @@ Hosts: `<hawk-app-explain for="<frame id>">` (registered with `<hawk-app-frame>`
1566
1607
  the frame's `pageContext`, `hawk-page-context` event and `explainPage()` are there for hosts that build their own. See
1567
1608
  [HOSTS.md](HOSTS.md).
1568
1609
 
1610
+ ### Help: the app's guide, `HELP.md`
1611
+
1612
+ The same panel has a **Help** tab where users ask "How do I…?" and the platform's AI answers from your app's guide:
1613
+ `HELP.md`, next to hawk-app.json. No code and no scope; the button shows for Help alone on pages that set no context.
1614
+
1615
+ - **Write it for the people who use the app:** what it's for in two or three sentences, each task as numbered steps that
1616
+ name the buttons and fields as the app shows them, what its numbers and statuses mean, and common questions with their
1617
+ answers. Plain Markdown, at most 32 KB. HTML comments are notes for your team: Help leaves them out.
1618
+ - **It ships with each deploy** (privately, not on the CDN), so every version answers from its own guide; deploy after
1619
+ changing it. `hawk-app create` starts one with only comments, and Help stays off until you write it.
1620
+ - **Answers** use the guide and the page context (above), so "what does this column mean?" gets an answer about the
1621
+ page the user is on. They never invent features; a question the guide doesn't cover is pointed to "Report a problem".
1622
+ Help and Explain share one daily budget per client.
1623
+
1569
1624
  ### Export a page as a PDF
1570
1625
 
1571
1626
  Pages worth keeping (a report, a summary for a meeting) can offer **Export as PDF**: while the page allows it, the
@@ -1726,7 +1781,7 @@ Which clients have the app is not in this file: manage them in the Hawk Analytic
1726
1781
  | `functions` | Your [functions](#functions-functionsinvoke) and their settings: `{ "<name>": { "job", "timeoutSeconds", "memoryMb", "secrets" } }`. Recorded with each version. |
1727
1782
  | `schedules` | [Schedules](#schedules) for job functions: `{ "<name>": { "function", "cron", "timezone" } }`, at most 5, at most hourly. Needs `functions:invoke`. Reviewed like scopes. |
1728
1783
  | `events` | [Events](#events): a job function per event (`client.added`, `config.changed`, `version.released`, `hawksearch.changed`, `index.rebuilt`), `{ "<event>": { "function" } }` (`hawksearch.changed` also takes `sections`). Needs `functions:invoke`. Reviewed like scopes. |
1729
- | `actions` | [Actions](#actions-start-the-app-from-hawksearchs-screens): buttons in HawkSearch's pages that open a view in a dialog, `{ "<name>": { "label", "view", "targets": { "hawksearch": [{ "path", "selector", "anchor"?, "position"?, "params" }] } } }` (at most 10). Always reviewed by an approver. |
1784
+ | `actions` | [Actions](#actions-start-the-app-from-hawksearchs-screens): buttons in HawkSearch's pages that open a view in a dialog, `{ "<name>": { "label", "view", "targets": { "hawksearch": [{ "path", "selector", "anchor"?, "position"?, "params", "when"? }] } } }` (at most 10). Always reviewed by an approver. |
1730
1785
  | `api` | [Client API](#client-api): endpoints the client's systems call with their HawkSearch site API key, `{ "<name>": { "function", "method"?: "GET" \| "POST", "description"? } }`, each a function that isn't a job (at most 10). Needs `functions:invoke`. Reviewed like scopes. |
1731
1786
  | `systemParameters` | The HawkSearch [system parameters](#hawksearch-system-parameters-hawksearchsysparamswrite) the app may read and set, e.g. `["Timezone"]`. Needs `hawksearch:sysparams:write`. Reviewed like scopes. |
1732
1787
  | `config` | [App config](#app-config) keys: `{ "<key>": { "type", "label", "description", "default" } }`, at most 50. The values are set outside the file (portal or `npx hawk-app config`) and aren't secret. Reviewed like scopes. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridgeline-digital/hawk-app-cli",
3
- "version": "0.1.2-dev.20261027234716",
3
+ "version": "0.1.2-dev.20261028113950",
4
4
  "description": "For Bridgeline developers: create, deploy and register Hawk Apps (Services-built tools embedded in HawkSearch). Requires a Hawk Apps publisher key.",
5
5
  "author": "Bridgeline Digital",
6
6
  "license": "UNLICENSED",
@@ -236,6 +236,23 @@
236
236
  "pattern": { "type": "string", "minLength": 1, "maxLength": 300 }
237
237
  }
238
238
  }
239
+ },
240
+ "when": {
241
+ "description": "Shows the button only on the things whose values meet every condition, by param (one of this target's params), ignoring case. A param that couldn't be read fails. E.g. { \"category\": { \"in\": [\"Synonym\", \"Redirect\"] } }.",
242
+ "type": "object",
243
+ "minProperties": 1,
244
+ "maxProperties": 10,
245
+ "additionalProperties": {
246
+ "type": "object",
247
+ "minProperties": 1,
248
+ "maxProperties": 1,
249
+ "additionalProperties": false,
250
+ "properties": {
251
+ "equals": { "description": "The value is this.", "type": "string", "minLength": 1, "maxLength": 100 },
252
+ "in": { "description": "The value is one of these.", "type": "array", "minItems": 1, "maxItems": 20, "items": { "type": "string", "minLength": 1, "maxLength": 100 } },
253
+ "pattern": { "description": "The value matches this regular expression.", "type": "string", "minLength": 1, "maxLength": 300 }
254
+ }
255
+ }
239
256
  }
240
257
  }
241
258
  }
package/src/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { writeFile } from 'node:fs/promises';
3
- import { basename, dirname, join, resolve } from 'node:path';
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { TEMPLATES, createProject, titleCase, toSlug } from './create.mjs';
5
5
  import { addAgentFiles } from './agents.mjs';
6
6
  import { confirmVulnerabilities, deploy, npmRunBuild, submissionClearedMessage } from './deploy.mjs';
@@ -16,8 +16,10 @@ import { DEFAULT_ICON_SVG, checkIcon } from './app-icon.mjs';
16
16
  import { checkScreenshots } from './store-listing.mjs';
17
17
  import { platformClient } from './platform.mjs';
18
18
  import { dataCommand } from './data.mjs';
19
+ import { hasZod, readContracts, TYPES_FILE, writeTypes } from './contracts.mjs';
20
+ import { HELP_FILE, loadHelp } from './help.mjs';
19
21
  import { DATA_SCOPE, QUERIES_DIR, loadQueries } from './queries.mjs';
20
- import { FUNCTIONS_DIR, checkBridge, describeFunctions, loadFunctions } from './functions.mjs';
22
+ import { BRIDGE_PACKAGE, FUNCTIONS_DIR, checkBridge, describeFunctions, loadFunctions } from './functions.mjs';
21
23
  import { secretsCommand } from './secrets.mjs';
22
24
  import { configCommand } from './config.mjs';
23
25
  import { clientsCommand, pickDevClient, rememberDevClient } from './clients.mjs';
@@ -249,6 +251,12 @@ Functions (server-side code, scope functions:invoke):
249
251
  functions/__recordings__/). --record calls the platform once
250
252
  (as the test's client, HAWKSEARCH_API_KEY for HawkSearch) and
251
253
  saves its answers
254
+ hawk-app types Write hawk-functions.d.ts from the functions' contracts
255
+ (defineFunction's zod input and output), so pages get typed
256
+ app.functions.call. hawk-app dev refreshes it when it starts
257
+ Contracts: export const handler = defineFunction({ input, output, handler }) with zod schemas. Deploy sends them;
258
+ the platform refuses a call whose input doesn't match (400 invalid_function_input) before the function runs, and
259
+ the API access screen offers the client API's OpenAPI document.
252
260
  Background jobs: "job": true on a function (up to 900 s), started with app.functions.start. "schedules" in
253
261
  hawk-app.json run job functions on an EventBridge cron (at most hourly) for clients where a user turns them on.
254
262
  "events" in hawk-app.json run a job function when something happens for a client: client.added (a client got the
@@ -455,6 +463,7 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
455
463
  const queries = await checkQueries(manifest, manifestFile);
456
464
  const functions = await checkFunctions(manifest, manifestFile);
457
465
  const projectDir = dirname(resolve(manifestFile));
466
+ const help = await loadHelp(projectDir);
458
467
  const project = await checkProject(projectDir);
459
468
  const { url } = resolveSettings({ flagPlatform: flags.platform, flagEnv: flags.env });
460
469
  const channelWarning = devAppOnProd(projectDir, url);
@@ -468,9 +477,9 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
468
477
  const { errors: buildErrors, warnings: buildWarnings } = flags.build
469
478
  ? await checkBuild({ projectDir, manifest, folder: positional[0], runBuild, log })
470
479
  : { errors: [], warnings: [] };
471
- const ok = `✔ ${manifestFile} looks good.${queries ? ` ${QUERIES_DIR}/: ${describeQueryCount(queries.queries.length)}.` : ''}${functions ? ` ${FUNCTIONS_DIR}/: ${describeFunctions(functions.functions.length)}.` : ''}${flags.build ? ' The build\'s URLs are under its base path.' : ''}`;
480
+ const ok = `✔ ${manifestFile} looks good.${queries ? ` ${QUERIES_DIR}/: ${describeQueryCount(queries.queries.length)}.` : ''}${functions ? ` ${FUNCTIONS_DIR}/: ${describeFunctions(functions.functions.length)}.` : ''}${help?.file ? ` ${HELP_FILE}: the Help tab answers from it.` : ''}${flags.build ? ' The build\'s URLs are under its base path.' : ''}`;
472
481
  return report(
473
- { errors: [...result.errors, ...icon.errors, ...screenshots.errors, ...(queries?.errors ?? []), ...(functions?.errors ?? []), ...buildErrors], warnings: [...result.warnings, ...project.warnings, ...buildWarnings] },
482
+ { errors: [...result.errors, ...icon.errors, ...screenshots.errors, ...(queries?.errors ?? []), ...(functions?.errors ?? []), ...(help?.errors ?? []), ...buildErrors], warnings: [...result.warnings, ...project.warnings, ...buildWarnings] },
474
483
  ok,
475
484
  log,
476
485
  );
@@ -513,6 +522,11 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
513
522
  for (const error of functions.errors) log(`✖ ${error}`);
514
523
  throw new Error(`Fix the functions first.`);
515
524
  }
525
+ const help = await loadHelp(projectDir);
526
+ if (help?.errors.length) {
527
+ for (const error of help.errors) log(`✖ ${error}`);
528
+ throw new Error(`Fix ${HELP_FILE} first.`);
529
+ }
516
530
  const alias = flags.alias === undefined ? undefined : checkAlias(flags.alias);
517
531
  // hawk-app.json as the platform takes it ($schema and template are for local tools).
518
532
  const { $schema: _schema, template: _template, ...settings } = manifest;
@@ -536,7 +550,7 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
536
550
  storeFiles: screenshots.files,
537
551
  dir: positional[0] ?? resolve(projectDir, target.folder),
538
552
  build: target.build && !flags['no-build'],
539
- serverFiles: queries?.files ?? [],
553
+ serverFiles: [...(queries?.files ?? []), ...(help?.file ? [help.file] : [])],
540
554
  functions: functions?.functions ?? [],
541
555
  projectDir,
542
556
  audit: !flags['no-audit'],
@@ -647,9 +661,36 @@ export async function main(argv, { fetchImpl = fetch, log = console.log, runBuil
647
661
  await client().register(manifest.slug, settings, false);
648
662
  log(`✔ Registered ${manifest.slug} on the platform (not released: only Bridgeline staff see it).`);
649
663
  }
664
+ // Typed function calls follow the contracts as they are now (never stops dev).
665
+ await refreshTypes(projectDir, manifest, log).catch(() => undefined);
650
666
  return devCommand({ client: client(), manifest, flags, projectDir, ...(env ? { env } : {}), signal, log });
651
667
  }
652
668
 
669
+ case 'types': {
670
+ const manifest = await loadManifest(manifestFile);
671
+ const projectDir = dirname(resolve(manifestFile));
672
+ const loaded = await loadFunctions(projectDir, manifest);
673
+ if (loaded?.errors.length) {
674
+ for (const error of loaded.errors) log(`✖ ${error}`);
675
+ return 1;
676
+ }
677
+ const { contracts, errors } = await readContracts({ projectDir, functions: loaded?.functions ?? [] });
678
+ if (errors.length) {
679
+ for (const error of errors) log(`✖ ${error}`);
680
+ return 1;
681
+ }
682
+ const { file, changed } = await writeTypes(projectDir, contracts);
683
+ const typed = [...contracts].filter(([, c]) => c).map(([name]) => name);
684
+ if (!file) {
685
+ log(hasZod(projectDir) || !loaded?.functions.length
686
+ ? `No function has a contract yet${changed ? ` (removed the old ${TYPES_FILE})` : ''}. Declare one with defineFunction({ input, output, handler }) from ${BRIDGE_PACKAGE}/server.`
687
+ : `No contracts: the app has no zod. Add it (\`npm install zod\`) and declare them with defineFunction({ input, output, handler }).`);
688
+ return 0;
689
+ }
690
+ log(`✔ ${relative(projectDir, file).split(sep).join('/')} ${changed ? 'written' : 'up to date'}: typed app.functions.call for ${typed.join(', ')}.`);
691
+ return 0;
692
+ }
693
+
653
694
  case 'test': {
654
695
  const projectDir = dirname(resolve(manifestFile));
655
696
  const settings = flags.record ? resolveSettings({ flagPlatform: flags.platform, flagEnv: flags.env }) : null;
@@ -838,6 +879,19 @@ async function checkFunctions(manifest, manifestFile) {
838
879
  return result;
839
880
  }
840
881
 
882
+ /** `hawk-app dev`'s start: hawk-functions.d.ts from the contracts as they are now (a line only when it changed). */
883
+ async function refreshTypes(projectDir, manifest, log) {
884
+ const loaded = await loadFunctions(projectDir, manifest);
885
+ if (!loaded?.functions.length || loaded.errors.length) return;
886
+ const { contracts, errors } = await readContracts({ projectDir, functions: loaded.functions });
887
+ if (errors.length) {
888
+ log(`! ${errors[0]}`);
889
+ return;
890
+ }
891
+ const { file, changed } = await writeTypes(projectDir, contracts);
892
+ if (changed && file) log(`✔ ${relative(projectDir, file).split(sep).join('/')} updated from the functions' contracts.`);
893
+ }
894
+
841
895
  /**
842
896
  * `validate --build [<folder>]`: builds the app as deploy would (with a stand-in base path, /<slug>/v0) and checks
843
897
  * that its HTML only loads URLs under it. The folder comes from the argument or the app:deploy script.
@@ -0,0 +1,212 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, relative, sep } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ // Function contracts: zod schemas of what a function takes and returns (`defineFunction({ input, output, handler })`
9
+ // from the bridge's /server). The CLI reads them with the app's own zod, as JSON Schema: deploy sends them with each
10
+ // function (the platform checks calls' input against them), and `hawk-app types` writes hawk-functions.d.ts so pages
11
+ // get typed `app.functions.call`. Same size limit as the platform.
12
+ export const MAX_SCHEMA_BYTES = 6 * 1024;
13
+ export const TYPES_FILE = 'hawk-functions.d.ts';
14
+
15
+ /** The generated entry: the function's contract as JSON Schema, converted by the app's zod (the one its schemas come from). */
16
+ function contractSource(entry) {
17
+ const userModule = entry.split(sep).join('/');
18
+ return [
19
+ `import * as fn from ${JSON.stringify(userModule)};`,
20
+ "import { toJSONSchema } from 'zod';",
21
+ 'const contract = fn.handler && fn.handler.contract;',
22
+ "const convert = (schema, io) => (schema ? toJSONSchema(schema, { io, unrepresentable: 'any' }) : null);",
23
+ "export default contract ? { input: convert(contract.input, 'input'), output: convert(contract.output, 'output') } : null;",
24
+ '',
25
+ ].join('\n');
26
+ }
27
+
28
+ /** Whether the app has zod (its contracts' schemas): without it, its functions have no contracts. */
29
+ export function hasZod(projectDir) {
30
+ try {
31
+ createRequire(join(projectDir, 'package.json')).resolve('zod');
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Each function's contract, `{ input, output }` JSON Schemas (each null when not declared), or null for a function
40
+ * without one. Loads each function once in this process (its module's top-level code runs, as in `hawk-app dev`).
41
+ * @param {{ projectDir: string, functions: { name: string, entry: string }[] }} options
42
+ * @returns {Promise<{ contracts: Map<string, { input: object | null, output: object | null } | null>, errors: string[] }>}
43
+ */
44
+ export async function readContracts({ projectDir, functions }) {
45
+ const contracts = new Map();
46
+ const errors = [];
47
+ if (!functions.length || !hasZod(projectDir)) {
48
+ for (const fn of functions) contracts.set(fn.name, null);
49
+ return { contracts, errors };
50
+ }
51
+ const esbuild = await import('esbuild');
52
+ const dir = await mkdtemp(join(tmpdir(), 'hawk-contracts-'));
53
+ try {
54
+ for (const fn of functions) {
55
+ const where = `functions/${fn.name}`;
56
+ const file = join(dir, `${fn.name}.mjs`);
57
+ try {
58
+ await esbuild.build({
59
+ stdin: { contents: contractSource(fn.entry), resolveDir: projectDir, sourcefile: `hawk-contract-${fn.name}.mjs`, loader: 'js' },
60
+ absWorkingDir: projectDir,
61
+ bundle: true,
62
+ platform: 'node',
63
+ target: 'node22',
64
+ format: 'esm',
65
+ outfile: file,
66
+ logLevel: 'silent',
67
+ banner: { js: "import { createRequire as __hawkCreateRequire } from 'node:module';\nconst require = __hawkCreateRequire(import.meta.url);" },
68
+ });
69
+ const contract = (await import(`${pathToFileURL(file).href}?t=${Date.now()}`)).default ?? null;
70
+ if (contract) {
71
+ for (const side of ['input', 'output']) {
72
+ const bytes = contract[side] ? Buffer.byteLength(JSON.stringify(contract[side])) : 0;
73
+ if (bytes > MAX_SCHEMA_BYTES) {
74
+ errors.push(`${where}: the contract's ${side} schema is ${Math.ceil(bytes / 1024)} KB as JSON Schema; at most ${MAX_SCHEMA_BYTES / 1024} KB. Describe the fields callers send and read, not every nested detail.`);
75
+ }
76
+ }
77
+ }
78
+ contracts.set(fn.name, contract && (contract.input || contract.output) ? contract : null);
79
+ } catch (error) {
80
+ const text = error?.errors?.map((e) => e.text).join('; ') || error?.message || String(error);
81
+ errors.push(`${where}: couldn't read its contract (defineFunction's input and output must be zod schemas): ${text}`);
82
+ }
83
+ }
84
+ } finally {
85
+ await rm(dir, { recursive: true, force: true });
86
+ }
87
+ return { contracts, errors };
88
+ }
89
+
90
+ /** Where hawk-functions.d.ts goes: src/ when the app has one (the templates' TypeScript includes it), else next to hawk-app.json. */
91
+ export function typesPath(projectDir) {
92
+ return existsSync(join(projectDir, 'src')) ? join(projectDir, 'src', TYPES_FILE) : join(projectDir, TYPES_FILE);
93
+ }
94
+
95
+ /**
96
+ * hawk-functions.d.ts: the functions with a contract, by name, as `HawkFunctions` of the bridge's client (typed
97
+ * `app.functions.call` and `start`). Null when no function has one.
98
+ */
99
+ export function typesSource(contracts) {
100
+ const typed = [...contracts].filter(([, c]) => c).sort(([a], [b]) => a.localeCompare(b));
101
+ if (!typed.length) return null;
102
+ const lines = [
103
+ '// Generated by `hawk-app types` from the functions\' contracts (defineFunction in functions/). Don\'t edit it:',
104
+ '// run `npx hawk-app types` again after changing a contract (`hawk-app dev` does it when it starts).',
105
+ "import '@bridgeline-digital/hawk-app-bridge/client';",
106
+ '',
107
+ "declare module '@bridgeline-digital/hawk-app-bridge/client' {",
108
+ ' interface HawkFunctions {',
109
+ ];
110
+ for (const [name, contract] of typed) {
111
+ lines.push(` ${JSON.stringify(name)}: {`);
112
+ lines.push(` input: ${contract.input ? toTypeScript(contract.input, contract.input, 3) : 'unknown'};`);
113
+ lines.push(` output: ${contract.output ? toTypeScript(contract.output, contract.output, 3) : 'unknown'};`);
114
+ lines.push(' };');
115
+ }
116
+ lines.push(' }', '}', '');
117
+ return lines.join('\n');
118
+ }
119
+
120
+ /**
121
+ * Writes hawk-functions.d.ts when the contracts give one (or removes a stale one when none do).
122
+ * @returns {Promise<{ file: string | null, changed: boolean }>}
123
+ */
124
+ export async function writeTypes(projectDir, contracts) {
125
+ const file = typesPath(projectDir);
126
+ const source = typesSource(contracts);
127
+ const current = existsSync(file) ? await readFile(file, 'utf8') : null;
128
+ if (source === null) {
129
+ if (current !== null && current.startsWith('// Generated by `hawk-app types`')) {
130
+ await rm(file);
131
+ return { file: null, changed: true };
132
+ }
133
+ return { file: null, changed: false };
134
+ }
135
+ if (current === source) return { file, changed: false };
136
+ await writeFile(file, source);
137
+ return { file, changed: true };
138
+ }
139
+
140
+ /** "src/hawk-functions.d.ts" when it's out of date with the contracts (or missing while some function has one), else null. */
141
+ export async function staleTypes(projectDir, contracts) {
142
+ const source = typesSource(contracts);
143
+ if (source === null) return null;
144
+ const file = typesPath(projectDir);
145
+ const current = existsSync(file) ? await readFile(file, 'utf8') : null;
146
+ return current === source ? null : relative(projectDir, file).split(sep).join('/');
147
+ }
148
+
149
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
150
+
151
+ /**
152
+ * A JSON Schema (as zod writes it) as a TypeScript type: objects, arrays, tuples, unions (anyOf, oneOf, type lists),
153
+ * intersections (allOf), enums and consts, records (additionalProperties), $refs into $defs; anything else is unknown.
154
+ * Descriptions become doc comments.
155
+ */
156
+ export function toTypeScript(schema, root = schema, depth = 0, seen = new Set()) {
157
+ if (schema === true || schema === undefined || schema === null || typeof schema !== 'object') return schema === false ? 'never' : 'unknown';
158
+ const pad = ' '.repeat(depth + 1);
159
+ const end = ' '.repeat(depth);
160
+ if (typeof schema.$ref === 'string') {
161
+ const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(schema.$ref);
162
+ const target = match ? root?.[match[1]]?.[decodeURIComponent(match[2])] : undefined;
163
+ // A recursive reference stays unknown rather than looping.
164
+ if (!target || seen.has(schema.$ref) || depth > 12) return 'unknown';
165
+ return toTypeScript(target, root, depth, new Set([...seen, schema.$ref]));
166
+ }
167
+ if ('const' in schema) return JSON.stringify(schema.const);
168
+ if (Array.isArray(schema.enum)) return schema.enum.map((v) => JSON.stringify(v)).join(' | ') || 'never';
169
+ const union = schema.anyOf ?? schema.oneOf;
170
+ if (Array.isArray(union)) return group(union.map((s) => toTypeScript(s, root, depth, seen)), ' | ');
171
+ if (Array.isArray(schema.allOf)) return group(schema.allOf.map((s) => toTypeScript(s, root, depth, seen)), ' & ');
172
+ if (Array.isArray(schema.type)) return group(schema.type.map((type) => toTypeScript({ ...schema, type }, root, depth, seen)), ' | ');
173
+ switch (schema.type) {
174
+ case 'string':
175
+ return 'string';
176
+ case 'number':
177
+ case 'integer':
178
+ return 'number';
179
+ case 'boolean':
180
+ return 'boolean';
181
+ case 'null':
182
+ return 'null';
183
+ case 'array': {
184
+ if (Array.isArray(schema.prefixItems)) {
185
+ const items = schema.prefixItems.map((s) => toTypeScript(s, root, depth, seen));
186
+ return `[${items.join(', ')}]`;
187
+ }
188
+ const item = toTypeScript(schema.items, root, depth, seen);
189
+ return /[|&]/.test(item) ? `Array<${item}>` : `${item}[]`;
190
+ }
191
+ case 'object':
192
+ case undefined: {
193
+ if (schema.type === undefined && !schema.properties && schema.additionalProperties === undefined) return 'unknown';
194
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
195
+ const members = Object.entries(schema.properties ?? {}).map(([key, value]) => {
196
+ const name = IDENTIFIER.test(key) ? key : JSON.stringify(key);
197
+ const doc = typeof value?.description === 'string' ? `${pad}/** ${value.description.replace(/\*\//g, '* /')} */\n` : '';
198
+ return `${doc}${pad}${name}${required.has(key) ? '' : '?'}: ${toTypeScript(value, root, depth + 1, seen)};`;
199
+ });
200
+ const extra = schema.additionalProperties;
201
+ if (extra && extra !== false) members.push(`${pad}[key: string]: ${extra === true ? 'unknown' : toTypeScript(extra, root, depth + 1, seen)};`);
202
+ return members.length ? `{\n${members.join('\n')}\n${end}}` : extra === false ? 'Record<string, never>' : 'Record<string, unknown>';
203
+ }
204
+ default:
205
+ return 'unknown';
206
+ }
207
+ }
208
+
209
+ function group(types, separator) {
210
+ const unique = [...new Set(types)];
211
+ return unique.length === 1 ? unique[0] : unique.map((t) => (/[|&]/.test(t) && !t.startsWith('{') ? `(${t})` : t)).join(separator);
212
+ }
package/src/create.mjs CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { planAgentFiles, writeAgentFiles } from './agents.mjs';
7
7
  import { credentialsPath } from './credentials.mjs';
8
8
  import { ENV_EXAMPLE, resolveSettings } from './env.mjs';
9
+ import { HELP_FILE, starterGuide } from './help.mjs';
9
10
  import { newManifest } from './manifest.mjs';
10
11
  import { DEFAULT_ICON_SVG } from './app-icon.mjs';
11
12
  import { applyChannel, CHANNELS, cliChannel, DEV_CHANNEL_NOTE, planChannel, writeCdnFiles } from './channel.mjs';
@@ -88,6 +89,7 @@ export async function createProject({ target, template = 'react', name, install
88
89
  await writeOut(join(dir, '.gitignore'), ['node_modules/', 'dist/', 'out/', '.next/', 'next-env.d.ts', '.env', '.env.local', '.env.*.local', '*.log', ''].join('\n'));
89
90
  await writeOut(join(dir, '.env.example'), ENV_EXAMPLE);
90
91
  await writeOut(join(dir, 'README.md'), readme({ slug, displayName, template, spec }));
92
+ await writeOut(join(dir, HELP_FILE), starterGuide(displayName));
91
93
  const agentFiles = await writeAgentFiles(dir, planAgentFiles(dir, { slug, name: displayName, template }));
92
94
 
93
95
  log(`✔ Created ${displayName} (${spec.description}) in ${relative(process.cwd(), dir) || '.'}`);
@@ -112,7 +114,7 @@ export async function createProject({ target, template = 'react', name, install
112
114
  log(' It isn\'t released yet: only Bridgeline staff see it, from the Apps page in HawkSearch (`npx hawk-app open`). Submit it');
113
115
  log(' for approval there; an approver releases it. `npx hawk-app --help` lists everything else.');
114
116
  log(' Docs: https://developers.hawksearchapps.com (npx hawk-app docs), with your Bridgeline account.');
115
- return { dir, slug, files: [...written, 'hawk-app.json', 'icon.svg', '.gitignore', '.env.example', 'README.md', ...agentFiles] };
117
+ return { dir, slug, files: [...written, 'hawk-app.json', 'icon.svg', '.gitignore', '.env.example', 'README.md', HELP_FILE, ...agentFiles] };
116
118
  }
117
119
 
118
120
  async function listTemplateFiles(root) {
@@ -182,5 +184,10 @@ with the settings in \`hawk-app.json\` (scopes, origins, views, functions…).
182
184
  - **Clients any time.** \`npx hawk-app clients add|exclude|remove <id>\` (or the Hawk Analytics portal) applies at once,
183
185
  without a deploy or approval, and is recorded in the app's history.
184
186
  - **Something off?** \`npx hawk-app doctor\` checks your login, the platform, the project and the bridge.
187
+
188
+ ## Help
189
+ \`HELP.md\` is the app's guide for its users: they ask "How do I…?" from the app's header in HawkSearch and the
190
+ platform's AI answers from it and the page they're on. It ships with every deploy; Help is off while it holds only
191
+ comments, as it starts.
185
192
  `;
186
193
  }
package/src/deploy.mjs CHANGED
@@ -7,6 +7,8 @@ import { checkAccessibility, describeAccessibility } from './accessibility.mjs';
7
7
  import { checkBuildUrls, describeBuildUrls } from './project-checks.mjs';
8
8
  import { askYesNo } from './slug-guard.mjs';
9
9
  import { describeSource } from './source.mjs';
10
+ import { readContracts, staleTypes } from './contracts.mjs';
11
+ import { HELP_FILE, HELP_SERVER_PATH } from './help.mjs';
10
12
  import { sourceMapIndex, splitSourceMaps } from './sourcemaps.mjs';
11
13
 
12
14
  const CONCURRENCY = 8;
@@ -34,8 +36,8 @@ export async function listFiles(dir) {
34
36
  * folder on the CDN), the files go straight to storage, then the platform verifies them and decides who sees the
35
37
  * version: an app not released yet shows it to Bridgeline staff at once; a released app keeps clients on their version until
36
38
  * it's approved in HawkSearch (unless the app is set to auto-approve).
37
- * `serverFiles` (the app's checked queries/ files, from the project root) go to the platform's private server bucket
38
- * with their own upload URLs, never to the CDN.
39
+ * `serverFiles` (the app's checked queries/ files and its HELP.md as help.md, from the project root) go to the
40
+ * platform's private server bucket with their own upload URLs, never to the CDN.
39
41
  * `functions` (checked `{ name, entry, config }` from loadFunctions) are bundled with esbuild, uploaded to the server
40
42
  * bucket (bundle.zip, source.zip, deps.json), deployed one by one as Lambda versions, and listed on complete.
41
43
  * `audit` runs `npm audit` of the production dependencies before a version is reserved and sends the result with
@@ -60,6 +62,14 @@ export async function deploy({ client, slug, dir, alias, manifest, source = null
60
62
  built.push({ ...result, config: fn.config });
61
63
  log(` ƒ ${fn.name} ${formatBytes(result.bundleBytes)} zipped, ${result.deps.length} package${result.deps.length === 1 ? '' : 's'}`);
62
64
  }
65
+ // Contracts (defineFunction's zod schemas) go with each function: the platform checks calls' input against them.
66
+ const { contracts, errors } = await readContracts({ projectDir, functions });
67
+ if (errors.length) throw new Error(`Fix the functions' contracts first:\n${errors.map((e) => ` ${e}`).join('\n')}`);
68
+ for (const fn of built) fn.contract = contracts.get(fn.name) ?? null;
69
+ const typed = built.filter((fn) => fn.contract).map((fn) => fn.name);
70
+ if (typed.length) log(` contracts: ${typed.join(', ')}`);
71
+ const stale = await staleTypes(projectDir, contracts);
72
+ if (stale) log(`! ${stale} doesn't match the functions' contracts: run \`npx hawk-app types\` so pages call them with the right types.`);
63
73
  }
64
74
  const functionFiles = built.flatMap((fn) => fn.files);
65
75
 
@@ -98,8 +108,10 @@ export async function deploy({ client, slug, dir, alias, manifest, source = null
98
108
  const accessibility = await runAccessibility(dir);
99
109
  log(` accessibility ${describeAccessibility(accessibility)}`);
100
110
  const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
101
- const queryCount = new Set(serverFiles.map((f) => f.path.replace(/.(sql|json)$/, ''))).size;
102
- const queriesNote = `${queryCount ? `, ${describeQueries(queryCount)}` : ''}${built.length ? `, ${describeFunctions(built.length)}` : ''}`;
111
+ const queryFiles = serverFiles.filter((f) => f.path.startsWith('queries/'));
112
+ const queryCount = new Set(queryFiles.map((f) => f.path.replace(/.(sql|json)$/, ''))).size;
113
+ const helpNote = serverFiles.some((f) => f.path === HELP_SERVER_PATH) ? `, ${HELP_FILE}` : '';
114
+ const queriesNote = `${queryCount ? `, ${describeQueries(queryCount)}` : ''}${built.length ? `, ${describeFunctions(built.length)}` : ''}${helpNote}`;
103
115
  const mapsNote = maps.length ? `, ${maps.length} source map${maps.length === 1 ? '' : 's'} (private)` : '';
104
116
  log(`Uploading v${version} of ${slug}: ${files.length} files, ${formatBytes(totalBytes)} from ${dir}${queriesNote}${mapsNote}`);
105
117
 
@@ -115,9 +127,11 @@ export async function deploy({ client, slug, dir, alias, manifest, source = null
115
127
  const serverByPath = new Map(privateFiles.map((f) => [f.path, f]));
116
128
  const functionByPath = new Map(functionFiles.map((f) => [f.path, f]));
117
129
  if (privateFiles.length && (started.serverUploads?.length ?? 0) !== privateFiles.length) {
118
- throw new Error(serverFiles.length
130
+ throw new Error(queryFiles.length
119
131
  ? 'The platform did not accept the queries/ files. It may not support app queries yet; update it or remove queries/.'
120
- : 'The platform did not accept the source maps. It may not support them yet; update it, or build without source maps.');
132
+ : serverFiles.length
133
+ ? `The platform did not accept ${HELP_FILE}. It may not support Help yet; update it, or move ${HELP_FILE} aside for this deploy.`
134
+ : 'The platform did not accept the source maps. It may not support them yet; update it, or build without source maps.');
121
135
  }
122
136
  if (functionFiles.length && (started.functionUploads?.length ?? 0) !== functionFiles.length) {
123
137
  throw new Error('The platform did not accept the functions. It may not support app functions yet; update it or remove "functions".');
@@ -142,7 +156,7 @@ export async function deploy({ client, slug, dir, alias, manifest, source = null
142
156
 
143
157
  // Each function becomes a Lambda version; the platform waits until it's ready.
144
158
  for (const fn of built) {
145
- const deployed = await client.deployFunction(slug, version, fn.name, fn.config);
159
+ const deployed = await client.deployFunction(slug, version, fn.name, fn.contract ? { ...fn.config, contract: fn.contract } : fn.config);
146
160
  log(` ✔ function ${fn.name} (${describeConfig(fn.config)})${deployed?.lambdaVersion ? ` Lambda version ${deployed.lambdaVersion}` : ''}`);
147
161
  }
148
162
 
package/src/help.mjs ADDED
@@ -0,0 +1,56 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ /** The app's guide, next to hawk-app.json: Help inside the app answers "How do I…?" from it. */
6
+ export const HELP_FILE = 'HELP.md';
7
+ /** Where it goes among the version's private server files (the platform reads it there). */
8
+ export const HELP_SERVER_PATH = 'help.md';
9
+ export const MAX_HELP_BYTES = 32 * 1024;
10
+
11
+ /** The guide as Help reads it: HTML comments (notes for the app's team, like the template's) are left out. */
12
+ export function guideText(text) {
13
+ return text.replace(/^/, '').replace(/<!--[\s\S]*?-->/g, '').trim();
14
+ }
15
+
16
+ /**
17
+ * The project's HELP.md as a server file of the version, checked: at most 32 KB. `file` is null without a guide yet (no
18
+ * file, or only comments, as `hawk-app create` writes it): the app then has no Help tab.
19
+ * @returns {Promise<{ file: { path: string, size: number, full: string } | null, errors: string[] } | null>}
20
+ */
21
+ export async function loadHelp(projectDir) {
22
+ const full = join(projectDir, HELP_FILE);
23
+ if (!existsSync(full)) return null;
24
+ const { size } = await stat(full);
25
+ if (size > MAX_HELP_BYTES) {
26
+ return { file: null, errors: [`${HELP_FILE} is ${Math.ceil(size / 1024)} KB: at most ${MAX_HELP_BYTES / 1024} KB. Keep it to what users ask about.`] };
27
+ }
28
+ if (!guideText(await readFile(full, 'utf8'))) return { file: null, errors: [] };
29
+ return { file: { path: HELP_SERVER_PATH, size, full }, errors: [] };
30
+ }
31
+
32
+ /** The guide `hawk-app create` starts: only comments, so Help stays off until the team writes it. */
33
+ export function starterGuide(displayName) {
34
+ return `<!--
35
+ ${HELP_FILE}: ${displayName}'s guide. Users ask "How do I…?" from the app's header in HawkSearch (the sparkles icon,
36
+ "Help"), and the platform's AI answers from this file and the page they're on. It ships with every deploy (privately,
37
+ not on the CDN), so each version answers from its own guide. Help is off while this file holds only comments.
38
+
39
+ Write for the people who use the app (merchandisers, not developers):
40
+ - what the app is for, in two or three sentences;
41
+ - each task, as numbered steps that name the buttons and fields as the app shows them;
42
+ - what the numbers and statuses mean, and common questions with their answers.
43
+ Plain Markdown, at most 32 KB. Comments like this one are left out of what the AI reads.
44
+ -->
45
+
46
+ <!--
47
+ # ${displayName}
48
+
49
+ What it's for.
50
+
51
+ ## Getting started
52
+
53
+ 1. …
54
+ -->
55
+ `;
56
+ }
package/src/manifest.mjs CHANGED
@@ -110,8 +110,11 @@ function validateViews(m) {
110
110
 
111
111
  // Same rules as the platform (AppActionRules).
112
112
  const ACTION_KEYS = ['label', 'view', 'targets'];
113
- const ACTION_TARGET_KEYS = ['path', 'selector', 'anchor', 'position', 'params'];
113
+ const ACTION_TARGET_KEYS = ['path', 'selector', 'anchor', 'position', 'params', 'when'];
114
114
  const ACTION_READER_KEYS = ['data', 'selector', 'attribute', 'pattern'];
115
+ const ACTION_CONDITIONS = ['equals', 'in', 'pattern'];
116
+ const MAX_CONDITION_VALUE = 100;
117
+ const MAX_CONDITION_VALUES = 20;
115
118
  export const ACTION_HOSTS = ['hawksearch'];
116
119
  const ACTION_POSITIONS = ['append', 'prepend', 'before', 'after'];
117
120
  const ACTION_ATTRIBUTE = /^[A-Za-z_:][A-Za-z0-9_:.-]{0,63}$/;
@@ -131,8 +134,9 @@ const isRegex = (value) => {
131
134
  /**
132
135
  * "actions": buttons HawkSearch's action injector adds to its own pages, where the app's targets say (a page path and a
133
136
  * CSS selector), that open one of the app's views in a dialog with values read from the page:
134
- * { "<name>": { "label", "view", "targets": { "hawksearch": [{ "path", "selector", "anchor"?, "position"?, "params": { "<param>": { "data"?, "selector"?, "attribute"?, "pattern"? } } }] } } }.
135
- * A reader's "data" names a data-<data> attribute that wins when it has a value; the rest is the fallback.
137
+ * { "<name>": { "label", "view", "targets": { "hawksearch": [{ "path", "selector", "anchor"?, "position"?, "params": { "<param>": { "data"?, "selector"?, "attribute"?, "pattern"? } }, "when"? }] } } }.
138
+ * A reader's "data" names a data-<data> attribute that wins when it has a value; the rest is the fallback. "when" shows
139
+ * the button only where the values read meet conditions: { "<param>": { "equals" | "in" | "pattern": … } }.
136
140
  */
137
141
  export function validateActions(m) {
138
142
  const errors = [];
@@ -222,6 +226,35 @@ function validateActionTarget(target, at, view, viewName) {
222
226
  if (reader.attribute !== undefined && !(typeof reader.attribute === 'string' && ACTION_ATTRIBUTE.test(reader.attribute))) errors.push(`${where}.attribute is an attribute name, e.g. "data-keyword".`);
223
227
  if (reader.pattern !== undefined && !isRegex(reader.pattern)) errors.push(`${where}.pattern is a regular expression (at most ${MAX_ACTION_EXPRESSION} characters); its first group is the value.`);
224
228
  }
229
+ if (target.when !== undefined) errors.push(...validateActionWhen(target.when, `${at}.when`, params));
230
+ return errors;
231
+ }
232
+
233
+ /**
234
+ * "when": the button shows only where the values the target reads meet every condition, by param (one of the target's
235
+ * params): { "equals": "…" }, { "in": ["…", …] } or { "pattern": "…" }, ignoring case. Same rules as the platform.
236
+ */
237
+ function validateActionWhen(when, at, params) {
238
+ if (!when || typeof when !== 'object' || Array.isArray(when) || !Object.keys(when).length) {
239
+ return [`${at} is { "<param>": { "equals": "…" } | { "in": ["…"] } | { "pattern": "…" } }: the button shows only where the values read meet them.`];
240
+ }
241
+ const errors = [];
242
+ for (const [param, condition] of Object.entries(when)) {
243
+ const where = `${at}.${param}`;
244
+ if (!Object.hasOwn(params, param)) errors.push(`${where}: "${param}" isn't one of this target's params, so it has no value to check.`);
245
+ const keys = condition && typeof condition === 'object' && !Array.isArray(condition) ? Object.keys(condition) : [];
246
+ if (keys.length !== 1 || !ACTION_CONDITIONS.includes(keys[0])) {
247
+ errors.push(`${where} is one condition: { "equals": "…" }, { "in": ["…", …] } or { "pattern": "…" }.`);
248
+ continue;
249
+ }
250
+ const value = condition[keys[0]];
251
+ const text = (v) => typeof v === 'string' && v.trim() && v.length <= MAX_CONDITION_VALUE;
252
+ if (keys[0] === 'equals' && !text(value)) errors.push(`${where}.equals is a value, at most ${MAX_CONDITION_VALUE} characters.`);
253
+ if (keys[0] === 'in' && !(Array.isArray(value) && value.length >= 1 && value.length <= MAX_CONDITION_VALUES && value.every(text))) {
254
+ errors.push(`${where}.in lists 1 to ${MAX_CONDITION_VALUES} values, each at most ${MAX_CONDITION_VALUE} characters.`);
255
+ }
256
+ if (keys[0] === 'pattern' && !isRegex(value)) errors.push(`${where}.pattern is a regular expression (at most ${MAX_ACTION_EXPRESSION} characters) the value must match.`);
257
+ }
225
258
  return errors;
226
259
  }
227
260