@phis/contracts 0.4.0 → 0.5.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/dist/forms.js ADDED
@@ -0,0 +1,508 @@
1
+ /**
2
+ * The Form descriptor: what a form's fields are, how they are laid out on its grid, what it says when
3
+ * it succeeds or is refused -- and the one parser that decides whether a stored value is one.
4
+ *
5
+ * A descriptor is written by a Module's preset or by the Form Builder, stored by phis-server and drawn
6
+ * by the site UI. All three read it here, so a descriptor the server accepts is one the renderer can
7
+ * draw, and the other way round. What stays in the UI is how the grid becomes CSS.
8
+ */
9
+ import { collectPhiRuntimeValueConditions, readPhiRuntimeConditionExpression, } from "./conditions.js";
10
+ import { parsePhiControlOptionsProviderConfig, } from "./controls.js";
11
+ import { isPhiSpacingToken, resolvePhiResponsiveValue, } from "./layout.js";
12
+ import { isPhiRecord } from "./record.js";
13
+ export const PHI_FORM_DESCRIPTOR_SCHEMA_VERSION = 1;
14
+ /** The number of tracks every form grid is divided into. */
15
+ export const PHI_FORM_GRID_TRACKS = 24;
16
+ /** What a submit says where the descriptor names nothing: the form's own word for it, else "Submit". */
17
+ export const PHI_FORM_DEFAULT_SUBMIT_LABEL = {
18
+ kind: "label",
19
+ key: "actions.submitLabel",
20
+ fallback: "Submit",
21
+ };
22
+ /** The last grid line, one past the last track, because `end` is exclusive. */
23
+ export const PHI_FORM_GRID_LAST_LINE = PHI_FORM_GRID_TRACKS + 1;
24
+ function readRequiredString(value, path) {
25
+ const normalized = typeof value === "string" ? value.trim() : "";
26
+ if (!normalized) {
27
+ throw new Error(`${path} must be a non-empty string.`);
28
+ }
29
+ return normalized;
30
+ }
31
+ function readOptionalString(value, path) {
32
+ if (value == null) {
33
+ return undefined;
34
+ }
35
+ return readRequiredString(value, path);
36
+ }
37
+ function readProviderKey(value, path) {
38
+ const key = readRequiredString(value, path);
39
+ if (!key.includes("/")) {
40
+ throw new Error(`${path} must use a namespaced package/key form.`);
41
+ }
42
+ return key;
43
+ }
44
+ function readTextDescriptor(value, path) {
45
+ if (!isPhiRecord(value)) {
46
+ throw new Error(`${path} must be a Form text descriptor.`);
47
+ }
48
+ if (value.kind === "literal") {
49
+ return { kind: "literal", value: typeof value.value === "string" ? value.value : "" };
50
+ }
51
+ if (value.kind === "label" || value.kind === "config") {
52
+ return {
53
+ kind: value.kind,
54
+ key: readRequiredString(value.key, `${path}.key`),
55
+ fallback: typeof value.fallback === "string" ? value.fallback : "",
56
+ };
57
+ }
58
+ throw new Error(`${path}.kind must be "literal", "label" or "config".`);
59
+ }
60
+ function readGridRange(value, path) {
61
+ if (value == null) {
62
+ return undefined;
63
+ }
64
+ if (!isPhiRecord(value)) {
65
+ throw new Error(`${path} must be a grid range object.`);
66
+ }
67
+ return assertPhiFormGridRange({
68
+ start: typeof value.start === "number" ? value.start : Number.NaN,
69
+ end: typeof value.end === "number" ? value.end : Number.NaN,
70
+ }, path);
71
+ }
72
+ function readResponsiveGridRange(value, path) {
73
+ if (value == null) {
74
+ return undefined;
75
+ }
76
+ if (!isPhiRecord(value)) {
77
+ throw new Error(`${path} must be a responsive grid range object.`);
78
+ }
79
+ return {
80
+ compact: readGridRange(value.compact, `${path}.compact`),
81
+ medium: readGridRange(value.medium, `${path}.medium`),
82
+ wide: readGridRange(value.wide, `${path}.wide`),
83
+ };
84
+ }
85
+ function readFormConditionExpression(value, path) {
86
+ if (value == null)
87
+ return undefined;
88
+ const expression = readPhiRuntimeConditionExpression(value);
89
+ if (!expression)
90
+ throw new Error(`${path} must be a valid runtime condition expression.`);
91
+ for (const condition of collectPhiRuntimeValueConditions(expression)) {
92
+ if (condition.source !== "form" && condition.source !== "controller") {
93
+ throw new Error(`${path} may use only form or controller condition sources.`);
94
+ }
95
+ }
96
+ return expression;
97
+ }
98
+ function readResponsiveGap(value, path) {
99
+ if (value == null)
100
+ return undefined;
101
+ if (!isPhiRecord(value))
102
+ throw new Error(`${path} must be a responsive spacing object.`);
103
+ const readToken = (entry, entryPath) => {
104
+ if (entry == null)
105
+ return undefined;
106
+ if (!isPhiSpacingToken(entry))
107
+ throw new Error(`${entryPath} must be a Phi spacing token.`);
108
+ return entry;
109
+ };
110
+ return {
111
+ compact: readToken(value.compact, `${path}.compact`),
112
+ medium: readToken(value.medium, `${path}.medium`),
113
+ wide: readToken(value.wide, `${path}.wide`),
114
+ };
115
+ }
116
+ function readValidationRule(value, path) {
117
+ if (!isPhiRecord(value)) {
118
+ throw new Error(`${path} must be a validation rule object.`);
119
+ }
120
+ return {
121
+ providerKey: readProviderKey(value.providerKey, `${path}.providerKey`),
122
+ message: value.message == null ? undefined : readTextDescriptor(value.message, `${path}.message`),
123
+ config: value.config == null
124
+ ? undefined
125
+ : isPhiRecord(value.config)
126
+ ? value.config
127
+ : (() => { throw new Error(`${path}.config must be an object.`); })(),
128
+ };
129
+ }
130
+ function readOption(value, path) {
131
+ if (!isPhiRecord(value)) {
132
+ throw new Error(`${path} must be an option object.`);
133
+ }
134
+ return {
135
+ value: readRequiredString(value.value, `${path}.value`),
136
+ label: readTextDescriptor(value.label, `${path}.label`),
137
+ description: value.description == null
138
+ ? undefined
139
+ : readTextDescriptor(value.description, `${path}.description`),
140
+ disabled: typeof value.disabled === "boolean" ? value.disabled : undefined,
141
+ icon: readOptionalString(value.icon, `${path}.icon`),
142
+ };
143
+ }
144
+ function assertUniqueKeys(items, path) {
145
+ const seen = new Set();
146
+ for (const item of items) {
147
+ if (seen.has(item.key)) {
148
+ throw new Error(`${path} contains duplicate key "${item.key}".`);
149
+ }
150
+ seen.add(item.key);
151
+ }
152
+ }
153
+ /**
154
+ * One part of a field's placement, which, once written, names a range for at least one mode.
155
+ *
156
+ * An empty part would read as "stated" and still say nothing, and a stated part decides that the field
157
+ * has no column for the other one -- that is too much to hang on an object with nothing in it.
158
+ */
159
+ function readPlacementPart(value, path) {
160
+ const range = readResponsiveGridRange(value, path);
161
+ if (range && !range.compact && !range.medium && !range.wide) {
162
+ throw new Error(`${path} must state a range for at least one of compact, medium, or wide.`);
163
+ }
164
+ return range;
165
+ }
166
+ /**
167
+ * A field's own placement: both parts, one part, or none.
168
+ *
169
+ * One part is a statement of its own and not half of two: a field that names only its control has no
170
+ * label column and draws its label above the control inside that range, and one that names only its
171
+ * label has no control column. Nothing is taken from the layout for the part left out -- a control moved
172
+ * to 7-19 beside a layout label at 1-9 would overlap it and stack without anyone having asked for it.
173
+ */
174
+ function readFieldPlacement(value, path) {
175
+ if (value == null) {
176
+ return undefined;
177
+ }
178
+ if (!isPhiRecord(value)) {
179
+ throw new Error(`${path} must be an object.`);
180
+ }
181
+ const label = readPlacementPart(value.label, `${path}.label`);
182
+ const control = readPlacementPart(value.control, `${path}.control`);
183
+ if (!label && !control) {
184
+ return undefined;
185
+ }
186
+ return { ...(label ? { label } : {}), ...(control ? { control } : {}) };
187
+ }
188
+ function readField(value, path) {
189
+ if (!isPhiRecord(value)) {
190
+ throw new Error(`${path} must be a field object.`);
191
+ }
192
+ const options = Array.isArray(value.options)
193
+ ? value.options.map((option, index) => readOption(option, `${path}.options[${index}]`))
194
+ : undefined;
195
+ if (options) {
196
+ const optionKeys = options.map((option) => ({ key: option.value }));
197
+ assertUniqueKeys(optionKeys, `${path}.options`);
198
+ }
199
+ const optionsProvider = value.optionsProvider == null
200
+ ? undefined
201
+ : parsePhiControlOptionsProviderConfig(value.optionsProvider);
202
+ if (value.optionsProvider != null && !optionsProvider) {
203
+ throw new Error(`${path}.optionsProvider is invalid.`);
204
+ }
205
+ if (value.config != null && !isPhiRecord(value.config)) {
206
+ throw new Error(`${path}.config must be an object.`);
207
+ }
208
+ const placement = readFieldPlacement(value.placement, `${path}.placement`);
209
+ return {
210
+ key: readRequiredString(value.key, `${path}.key`),
211
+ fieldProviderKey: readProviderKey(value.fieldProviderKey, `${path}.fieldProviderKey`),
212
+ label: value.label == null ? undefined : readTextDescriptor(value.label, `${path}.label`),
213
+ controlLabel: value.controlLabel == null
214
+ ? undefined
215
+ : readTextDescriptor(value.controlLabel, `${path}.controlLabel`),
216
+ description: value.description == null
217
+ ? undefined
218
+ : readTextDescriptor(value.description, `${path}.description`),
219
+ placeholder: value.placeholder == null
220
+ ? undefined
221
+ : readTextDescriptor(value.placeholder, `${path}.placeholder`),
222
+ autoComplete: readOptionalString(value.autoComplete, `${path}.autoComplete`),
223
+ initialValue: value.initialValue,
224
+ options,
225
+ optionsProvider,
226
+ validation: Array.isArray(value.validation)
227
+ ? value.validation.map((rule, index) => readValidationRule(rule, `${path}.validation[${index}]`))
228
+ : undefined,
229
+ visibleWhen: readFormConditionExpression(value.visibleWhen, `${path}.visibleWhen`),
230
+ disabledWhen: readFormConditionExpression(value.disabledWhen, `${path}.disabledWhen`),
231
+ placement,
232
+ config: value.config,
233
+ };
234
+ }
235
+ function readSubmit(value) {
236
+ if (value == null)
237
+ return undefined;
238
+ if (!isPhiRecord(value))
239
+ throw new Error("submit must be an object.");
240
+ const align = value.align;
241
+ if (align != null && align !== "start" && align !== "center" && align !== "end") {
242
+ throw new Error("submit.align must be start, center or end.");
243
+ }
244
+ const label = value.label == null ? undefined : readTextDescriptor(value.label, "submit.label");
245
+ const control = readPlacementPart(value.control, "submit.control");
246
+ return {
247
+ ...(label ? { label } : {}),
248
+ ...(align ? { align } : {}),
249
+ ...(control ? { control } : {}),
250
+ };
251
+ }
252
+ export function parsePhiFormDescriptor(value) {
253
+ if (!isPhiRecord(value)) {
254
+ throw new Error("Form descriptor must be an object.");
255
+ }
256
+ if (value.schemaVersion !== PHI_FORM_DESCRIPTOR_SCHEMA_VERSION) {
257
+ throw new Error(`Form descriptor schemaVersion must be ${PHI_FORM_DESCRIPTOR_SCHEMA_VERSION}.`);
258
+ }
259
+ if (!Array.isArray(value.fields)) {
260
+ throw new Error("Form descriptor fields must be an array.");
261
+ }
262
+ if ("actions" in value) {
263
+ /*
264
+ * A form has one submit and it is described under `submit`; whether it is drawn is the Form
265
+ * Widget's placement. A list of actions would be a second vocabulary for the same thing.
266
+ */
267
+ throw new Error("Form descriptor actions are forbidden; describe the submit under submit.");
268
+ }
269
+ if ("presentation" in value) {
270
+ throw new Error("Form descriptor presentation is forbidden; use the owning Layout or Overlay.");
271
+ }
272
+ const fields = value.fields.map((field, index) => readField(field, `fields[${index}]`));
273
+ assertUniqueKeys(fields, "fields");
274
+ const labelSetKey = value.labelSetKey == null
275
+ ? undefined
276
+ : readProviderKey(value.labelSetKey, "labelSetKey");
277
+ const layout = value.layout == null
278
+ ? undefined
279
+ : isPhiRecord(value.layout)
280
+ ? {
281
+ gap: readResponsiveGap(value.layout.gap, "layout.gap"),
282
+ columnGap: readResponsiveGap(value.layout.columnGap, "layout.columnGap"),
283
+ labelAlign: value.layout.labelAlign == null
284
+ ? undefined
285
+ : value.layout.labelAlign === "start" || value.layout.labelAlign === "end"
286
+ ? value.layout.labelAlign
287
+ : (() => { throw new Error("layout.labelAlign must be start or end."); })(),
288
+ label: readResponsiveGridRange(value.layout.label, "layout.label"),
289
+ control: readResponsiveGridRange(value.layout.control, "layout.control"),
290
+ }
291
+ : (() => { throw new Error("layout must be an object."); })();
292
+ if (layout) {
293
+ resolvePhiFormLayout(layout);
294
+ }
295
+ const success = value.success == null
296
+ ? undefined
297
+ : isPhiRecord(value.success)
298
+ ? {
299
+ title: readTextDescriptor(value.success.title, "success.title"),
300
+ text: value.success.text == null
301
+ ? undefined
302
+ : readTextDescriptor(value.success.text, "success.text"),
303
+ reset: value.success.reset == null ? undefined : value.success.reset === true,
304
+ complete: value.success.complete == null ? undefined : value.success.complete === true,
305
+ }
306
+ : (() => { throw new Error("success must be an object."); })();
307
+ const errors = value.errors == null
308
+ ? undefined
309
+ : isPhiRecord(value.errors)
310
+ ? Object.fromEntries(Object.entries(value.errors).map(([code, text]) => [
311
+ code,
312
+ readTextDescriptor(text, `errors.${code}`),
313
+ ]))
314
+ : (() => { throw new Error("errors must be an object."); })();
315
+ return {
316
+ schemaVersion: PHI_FORM_DESCRIPTOR_SCHEMA_VERSION,
317
+ key: readRequiredString(value.key, "key"),
318
+ labelSetKey,
319
+ fields,
320
+ layout,
321
+ success,
322
+ errors,
323
+ persistDraft: value.persistDraft == null ? undefined : value.persistDraft === true,
324
+ guard: value.guard == null ? undefined : value.guard === true,
325
+ submit: readSubmit(value.submit),
326
+ };
327
+ }
328
+ /**
329
+ * What a form looks like when it says nothing: labels beside their controls at a third of the width,
330
+ * and stacked once the form is measured narrow, which is the one place a label beside a short input
331
+ * leaves the input no room. Line 9 of 24 is the third; the control takes everything after it.
332
+ *
333
+ * A third is the house column, the one the Login states for itself in `PHI_LOGIN_FORM_LAYOUT_CONFIG`:
334
+ * eight tracks hold a two-word label without wrapping it, and sixteen still read as the wider half.
335
+ */
336
+ export const PHI_FORM_DEFAULT_LAYOUT = {
337
+ gap: {
338
+ compact: "sm",
339
+ medium: "base",
340
+ wide: "base",
341
+ },
342
+ labelAlign: "start",
343
+ label: {
344
+ compact: { start: 1, end: PHI_FORM_GRID_LAST_LINE },
345
+ medium: { start: 1, end: 9 },
346
+ wide: { start: 1, end: 9 },
347
+ },
348
+ control: {
349
+ compact: { start: 1, end: PHI_FORM_GRID_LAST_LINE },
350
+ medium: { start: 9, end: PHI_FORM_GRID_LAST_LINE },
351
+ wide: { start: 9, end: PHI_FORM_GRID_LAST_LINE },
352
+ },
353
+ };
354
+ /**
355
+ * The ranges a form reaches for again and again, named once.
356
+ *
357
+ * A field written as the same range for its label and its control is a stacked field, because the two
358
+ * cannot share a row; the half-width pairs are how a two-column form is said now that `columns` is
359
+ * gone. All three collapse to the full width when the form is measured narrow, which is the only
360
+ * width at which two columns of anything are worse than one.
361
+ */
362
+ export const PHI_FORM_ROW_FULL = {
363
+ compact: { start: 1, end: PHI_FORM_GRID_LAST_LINE },
364
+ medium: { start: 1, end: PHI_FORM_GRID_LAST_LINE },
365
+ wide: { start: 1, end: PHI_FORM_GRID_LAST_LINE },
366
+ };
367
+ export const PHI_FORM_ROW_END_HALF = {
368
+ compact: { start: 1, end: PHI_FORM_GRID_LAST_LINE },
369
+ medium: { start: 13, end: PHI_FORM_GRID_LAST_LINE },
370
+ wide: { start: 13, end: PHI_FORM_GRID_LAST_LINE },
371
+ };
372
+ /** Labels above their controls at every width: one range for both parts of every field. */
373
+ export const PHI_FORM_STACKED_LAYOUT = {
374
+ label: PHI_FORM_ROW_FULL,
375
+ control: PHI_FORM_ROW_FULL,
376
+ };
377
+ export const PHI_FORM_STACKED_END_HALF = {
378
+ label: PHI_FORM_ROW_END_HALF,
379
+ control: PHI_FORM_ROW_END_HALF,
380
+ };
381
+ /** One field of a two-column form whose labels stand beside their controls. */
382
+ export const PHI_FORM_SIDE_START_HALF = {
383
+ label: {
384
+ compact: { start: 1, end: 7 },
385
+ medium: { start: 1, end: 5 },
386
+ wide: { start: 1, end: 5 },
387
+ },
388
+ control: {
389
+ compact: { start: 7, end: PHI_FORM_GRID_LAST_LINE },
390
+ medium: { start: 5, end: 13 },
391
+ wide: { start: 5, end: 13 },
392
+ },
393
+ };
394
+ export const PHI_FORM_SIDE_END_HALF = {
395
+ label: {
396
+ compact: { start: 1, end: 7 },
397
+ medium: { start: 13, end: 17 },
398
+ wide: { start: 13, end: 17 },
399
+ },
400
+ control: {
401
+ compact: { start: 7, end: PHI_FORM_GRID_LAST_LINE },
402
+ medium: { start: 17, end: PHI_FORM_GRID_LAST_LINE },
403
+ wide: { start: 17, end: PHI_FORM_GRID_LAST_LINE },
404
+ },
405
+ };
406
+ /**
407
+ * Two columns filled the way a grid fills them, for a form long enough that saying it per field would
408
+ * be a list of alternations nobody can read or keep correct.
409
+ *
410
+ * A field that brings its own placement keeps it and, where it takes the whole width, starts the next
411
+ * field on a fresh row -- which is what the grid did when the column count decided this and not the
412
+ * field. Authoring sugar over the contract, never a second contract: what it produces is ordinary
413
+ * per-field placement.
414
+ */
415
+ export function phiFormFlowHalfColumns(fields) {
416
+ let atRowStart = true;
417
+ return fields.map((field) => {
418
+ const stated = [
419
+ statedPhiFormGridRange(field.placement?.label, "medium"),
420
+ statedPhiFormGridRange(field.placement?.control, "medium"),
421
+ ].filter((range) => range != null);
422
+ if (stated.length > 0) {
423
+ const claimsWholeRow = Math.min(...stated.map((range) => range.start)) === 1 &&
424
+ Math.max(...stated.map((range) => range.end)) === PHI_FORM_GRID_LAST_LINE;
425
+ atRowStart = claimsWholeRow ? true : !atRowStart;
426
+ return field;
427
+ }
428
+ const placement = atRowStart ? PHI_FORM_SIDE_START_HALF : PHI_FORM_SIDE_END_HALF;
429
+ atRowStart = !atRowStart;
430
+ return { ...field, placement };
431
+ });
432
+ }
433
+ /** A field that takes the whole row in a form that is otherwise two columns. */
434
+ export const PHI_FORM_STACKED_FULL = {
435
+ label: PHI_FORM_ROW_FULL,
436
+ control: PHI_FORM_ROW_FULL,
437
+ };
438
+ export const PHI_FORM_RESPONSIVE_MODES = ["compact", "medium", "wide"];
439
+ export function assertPhiFormGridRange(range, path) {
440
+ const { start, end } = range;
441
+ if (!Number.isInteger(start) || start < 1 || start > PHI_FORM_GRID_TRACKS) {
442
+ throw new Error(`${path}.start must be an integer from 1 to ${PHI_FORM_GRID_TRACKS}.`);
443
+ }
444
+ if (!Number.isInteger(end) || end < 2 || end > PHI_FORM_GRID_LAST_LINE) {
445
+ throw new Error(`${path}.end must be an integer from 2 to ${PHI_FORM_GRID_LAST_LINE}.`);
446
+ }
447
+ if (end <= start) {
448
+ throw new Error(`${path}.end must be greater than ${path}.start.`);
449
+ }
450
+ return { start, end };
451
+ }
452
+ export function createPhiFormLiteralText(value) {
453
+ return { kind: "literal", value };
454
+ }
455
+ export function createPhiFormLabelText(key, fallback) {
456
+ return { kind: "label", key, fallback };
457
+ }
458
+ export function resolvePhiFormText(text, labels, formConfig) {
459
+ if (text.kind === "literal") {
460
+ return text.value;
461
+ }
462
+ if (text.kind === "config") {
463
+ const value = formConfig?.[text.key];
464
+ return typeof value === "string" && value.trim() ? value : text.fallback;
465
+ }
466
+ return labels?.[text.key] ?? text.fallback;
467
+ }
468
+ export function assertPhiFormLabelSetKey(value) {
469
+ const normalized = value.trim();
470
+ if (!normalized || !normalized.includes("/")) {
471
+ throw new Error(`Form label-set key "${value}" must use a namespaced package/key form.`);
472
+ }
473
+ }
474
+ export function resolvePhiFormResponsiveGridRange(value, fallback, path) {
475
+ const resolved = resolvePhiResponsiveValue(value, fallback);
476
+ return {
477
+ compact: assertPhiFormGridRange(resolved.compact, `${path}.compact`),
478
+ medium: assertPhiFormGridRange(resolved.medium, `${path}.medium`),
479
+ wide: assertPhiFormGridRange(resolved.wide, `${path}.wide`),
480
+ };
481
+ }
482
+ export function resolvePhiFormLayout(layout) {
483
+ const gap = resolvePhiResponsiveValue(layout?.gap, PHI_FORM_DEFAULT_LAYOUT.gap);
484
+ return {
485
+ gap,
486
+ /*
487
+ * The row gap answers for the column gap where nobody stated one, so the default is the number the
488
+ * form already carries rather than a second constant that could drift away from it.
489
+ */
490
+ columnGap: resolvePhiResponsiveValue(layout?.columnGap, gap),
491
+ labelAlign: layout?.labelAlign ?? PHI_FORM_DEFAULT_LAYOUT.labelAlign,
492
+ label: resolvePhiFormResponsiveGridRange(layout?.label, PHI_FORM_DEFAULT_LAYOUT.label, "layout.label"),
493
+ control: resolvePhiFormResponsiveGridRange(layout?.control, PHI_FORM_DEFAULT_LAYOUT.control, "layout.control"),
494
+ };
495
+ }
496
+ /**
497
+ * The range one part of a placement states for one mode, cascading only from the smaller modes of that
498
+ * same part -- never from the layout, which is the other part's business to decide.
499
+ */
500
+ export function statedPhiFormGridRange(value, mode) {
501
+ if (!value)
502
+ return undefined;
503
+ if (mode === "compact")
504
+ return value.compact;
505
+ if (mode === "medium")
506
+ return value.medium ?? value.compact;
507
+ return value.wide ?? value.medium ?? value.compact;
508
+ }
package/dist/http.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The request headers phis and the site UI must spell the same way.
3
3
  *
4
- * These five names are a wire protocol between processes that deploy independently. phis reads them in
4
+ * These names are a wire protocol between processes that deploy independently. phis reads them in
5
5
  * its guards and route handlers; the site UI writes them in its proxies, gateways and browser widgets.
6
6
  * A disagreement is not a type error and not a failing test -- it is a 403 from `guardApiV1` or a 400
7
7
  * from `requireSiteFromExplicitKeyRequest`, at runtime, in whichever direction the mismatch happens to
@@ -31,3 +31,12 @@ export declare const PHIS_AREA_HEADER: "x-phis-area";
31
31
  */
32
32
  export declare const PHIS_REQUEST_PATH_HEADER: "x-phis-request-path";
33
33
  export declare const PHIS_REQUEST_SEARCH_HEADER: "x-phis-request-search";
34
+ /**
35
+ * Marks a request the site UI's form relay forwards for a visitor.
36
+ *
37
+ * The relay calls phis with the internal token, and the internal token is exempt from the per-address
38
+ * limit because it measures a Site runtime rather than a person. This header tells phis that behind this
39
+ * one request stands a single visitor, so `guardApiV1` counts their submissions -- by session, or by
40
+ * address where there is none. Anybody may send it; all it can do is put the sender under a limit.
41
+ */
42
+ export declare const PHIS_FORM_RELAY_HEADER: "x-phis-form-relay";
package/dist/http.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The request headers phis and the site UI must spell the same way.
3
3
  *
4
- * These five names are a wire protocol between processes that deploy independently. phis reads them in
4
+ * These names are a wire protocol between processes that deploy independently. phis reads them in
5
5
  * its guards and route handlers; the site UI writes them in its proxies, gateways and browser widgets.
6
6
  * A disagreement is not a type error and not a failing test -- it is a 403 from `guardApiV1` or a 400
7
7
  * from `requireSiteFromExplicitKeyRequest`, at runtime, in whichever direction the mismatch happens to
@@ -31,3 +31,12 @@ export const PHIS_AREA_HEADER = "x-phis-area";
31
31
  */
32
32
  export const PHIS_REQUEST_PATH_HEADER = "x-phis-request-path";
33
33
  export const PHIS_REQUEST_SEARCH_HEADER = "x-phis-request-search";
34
+ /**
35
+ * Marks a request the site UI's form relay forwards for a visitor.
36
+ *
37
+ * The relay calls phis with the internal token, and the internal token is exempt from the per-address
38
+ * limit because it measures a Site runtime rather than a person. This header tells phis that behind this
39
+ * one request stands a single visitor, so `guardApiV1` counts their submissions -- by session, or by
40
+ * address where there is none. Anybody may send it; all it can do is put the sender under a limit.
41
+ */
42
+ export const PHIS_FORM_RELAY_HEADER = "x-phis-form-relay";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The layout vocabulary stored descriptors are written in: a value per measured width, and the named
3
+ * spacing steps.
4
+ *
5
+ * Here rather than in the site UI because a Form descriptor is stored by phis-server and states its
6
+ * gaps and ranges in these terms, so the server has to read them the way the renderer does.
7
+ */
8
+ export type PhiResponsiveValue<TValue> = {
9
+ compact?: TValue;
10
+ medium?: TValue;
11
+ wide?: TValue;
12
+ };
13
+ export type PhiResolvedResponsiveValue<TValue> = {
14
+ compact: TValue;
15
+ medium: TValue;
16
+ wide: TValue;
17
+ };
18
+ /** A stated value cascades upwards to the wider modes; the fallback answers only where nothing is. */
19
+ export declare function resolvePhiResponsiveValue<TValue>(value: PhiResponsiveValue<TValue> | undefined, fallback: PhiResolvedResponsiveValue<TValue>): PhiResolvedResponsiveValue<TValue>;
20
+ export declare const PHI_SPACING_TOKENS: readonly ["none", "xxs", "xs", "sm", "base", "md", "lg", "xl", "xxl"];
21
+ export type PhiSpacingToken = (typeof PHI_SPACING_TOKENS)[number];
22
+ export declare function isPhiSpacingToken(value: unknown): value is PhiSpacingToken;
package/dist/layout.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The layout vocabulary stored descriptors are written in: a value per measured width, and the named
3
+ * spacing steps.
4
+ *
5
+ * Here rather than in the site UI because a Form descriptor is stored by phis-server and states its
6
+ * gaps and ranges in these terms, so the server has to read them the way the renderer does.
7
+ */
8
+ /** A stated value cascades upwards to the wider modes; the fallback answers only where nothing is. */
9
+ export function resolvePhiResponsiveValue(value, fallback) {
10
+ if (!value)
11
+ return fallback;
12
+ const compact = value.compact ?? fallback.compact;
13
+ const medium = value.medium ?? (value.compact === undefined ? fallback.medium : compact);
14
+ const wide = value.wide ?? (value.medium === undefined && value.compact === undefined ? fallback.wide : medium);
15
+ return { compact, medium, wide };
16
+ }
17
+ export const PHI_SPACING_TOKENS = [
18
+ "none",
19
+ "xxs",
20
+ "xs",
21
+ "sm",
22
+ "base",
23
+ "md",
24
+ "lg",
25
+ "xl",
26
+ "xxl",
27
+ ];
28
+ export function isPhiSpacingToken(value) {
29
+ return typeof value === "string" && PHI_SPACING_TOKENS.includes(value);
30
+ }
@@ -0,0 +1,8 @@
1
+ export declare function isPhiRecord(value: unknown): value is Record<string, unknown>;
2
+ /**
3
+ * The value a dotted path names inside a record: `"author.name"` reads `input.author.name`.
4
+ *
5
+ * Empty segments are skipped, so `""` names the input itself, and only records are walked -- an array
6
+ * or a primitive on the way ends the walk with `undefined` rather than being indexed into.
7
+ */
8
+ export declare function readPhiDotPath(input: unknown, path: string): unknown;
package/dist/record.js ADDED
@@ -0,0 +1,24 @@
1
+ /*
2
+ * Shared by the parsers of this package and not exported from it: each consumer keeps its own, and
3
+ * the one question they all answer is settled the same way -- an array is not a keyed record.
4
+ */
5
+ export function isPhiRecord(value) {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+ /**
9
+ * The value a dotted path names inside a record: `"author.name"` reads `input.author.name`.
10
+ *
11
+ * Empty segments are skipped, so `""` names the input itself, and only records are walked -- an array
12
+ * or a primitive on the way ends the walk with `undefined` rather than being indexed into.
13
+ */
14
+ export function readPhiDotPath(input, path) {
15
+ let current = input;
16
+ for (const segment of path.split(".")) {
17
+ if (!segment)
18
+ continue;
19
+ if (!isPhiRecord(current))
20
+ return undefined;
21
+ current = current[segment];
22
+ }
23
+ return current;
24
+ }