@jini-ai/cms 0.5.5 → 0.5.7

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.
Files changed (138) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/consent/express/index.d.ts +53 -0
  3. package/dist/consent/express/index.js +159 -0
  4. package/dist/consent/express/response.d.ts +21 -0
  5. package/dist/consent/express/response.js +89 -0
  6. package/dist/consent/html/client.d.ts +6 -0
  7. package/dist/consent/html/client.js +135 -0
  8. package/dist/consent/html/gate.d.ts +29 -0
  9. package/dist/consent/html/gate.js +104 -0
  10. package/dist/consent/html/index.d.ts +4 -0
  11. package/dist/consent/html/index.js +3 -0
  12. package/dist/consent/html/render.d.ts +27 -0
  13. package/dist/consent/html/render.js +62 -0
  14. package/dist/consent/html/static.d.ts +7 -0
  15. package/dist/consent/html/static.js +25 -0
  16. package/dist/consent/html/styles.d.ts +3 -0
  17. package/dist/consent/html/styles.js +22 -0
  18. package/dist/consent/index.d.ts +3 -0
  19. package/dist/consent/index.js +3 -0
  20. package/dist/consent/node/index.d.ts +47 -0
  21. package/dist/consent/node/index.js +169 -0
  22. package/dist/consent/policy.d.ts +47 -0
  23. package/dist/consent/policy.js +62 -0
  24. package/dist/consent/types.d.ts +40 -0
  25. package/dist/consent/types.js +5 -0
  26. package/dist/content-types/agent-tools.js +1 -1
  27. package/dist/content-types/tool-registrations.d.ts +2 -0
  28. package/dist/content-types/tool-registrations.js +1 -0
  29. package/dist/content-types/write-service.d.ts +2 -0
  30. package/dist/content-types/write-service.js +3 -0
  31. package/dist/forms/descriptor-rules.d.ts +9 -0
  32. package/dist/forms/descriptor-rules.js +112 -0
  33. package/dist/forms/email-format.d.ts +7 -0
  34. package/dist/forms/email-format.js +11 -0
  35. package/dist/forms/express/handler.js +4 -8
  36. package/dist/forms/field-registry.d.ts +193 -0
  37. package/dist/forms/field-registry.js +231 -0
  38. package/dist/forms/forms.d.ts +8 -5
  39. package/dist/forms/forms.js +29 -46
  40. package/dist/forms/html/html-authoring.js +63 -13
  41. package/dist/forms/html/html-submission-adapter.d.ts +5 -5
  42. package/dist/forms/html/html-submission-adapter.js +6 -13
  43. package/dist/forms/html-render.d.ts +10 -3
  44. package/dist/forms/html-render.js +116 -14
  45. package/dist/forms/index.d.ts +1 -0
  46. package/dist/forms/index.js +1 -0
  47. package/dist/forms/repo.memory.js +4 -4
  48. package/dist/forms/submission-attempts.d.ts +1 -1
  49. package/dist/forms/submission-attempts.js +13 -2
  50. package/dist/forms/submission-email-validation.js +2 -4
  51. package/dist/forms/submit-service.d.ts +3 -0
  52. package/dist/forms/submit-service.js +38 -5
  53. package/dist/forms/types.d.ts +50 -2
  54. package/dist/listing/batch.d.ts +53 -0
  55. package/dist/listing/batch.js +57 -0
  56. package/dist/listing/html.d.ts +72 -0
  57. package/dist/listing/html.js +109 -0
  58. package/dist/listing/index.d.ts +5 -0
  59. package/dist/listing/index.js +5 -0
  60. package/dist/listing/paths.d.ts +32 -0
  61. package/dist/listing/paths.js +37 -0
  62. package/dist/listing/see-more-script.d.ts +16 -0
  63. package/dist/listing/see-more-script.js +62 -0
  64. package/dist/media/content-type-sniffer.d.ts +1 -1
  65. package/dist/media/content-type-sniffer.js +15 -0
  66. package/dist/media/image-transformer.sharp.js +4 -1
  67. package/dist/media/media-service.d.ts +5 -3
  68. package/dist/media/media-service.js +10 -26
  69. package/dist/media/node.d.ts +1 -1
  70. package/dist/media/node.js +1 -1
  71. package/dist/media/slug.d.ts +8 -0
  72. package/dist/media/slug.js +32 -0
  73. package/dist/media/transform-registry.js +1 -1
  74. package/dist/media/transform-types.d.ts +2 -0
  75. package/dist/media/transform-types.js +3 -0
  76. package/dist/media/video-frames.ffmpeg.d.ts +15 -2
  77. package/dist/media/video-frames.ffmpeg.js +28 -13
  78. package/dist/navigation/dropdown.d.ts +41 -0
  79. package/dist/navigation/dropdown.js +85 -0
  80. package/dist/navigation/index.d.ts +2 -0
  81. package/dist/navigation/index.js +2 -0
  82. package/dist/redirects/capture.d.ts +2 -0
  83. package/dist/redirects/capture.js +4 -0
  84. package/dist/redirects/redirects.d.ts +8 -0
  85. package/dist/redirects/redirects.js +14 -0
  86. package/dist/routing/index.d.ts +5 -0
  87. package/dist/routing/index.js +2 -0
  88. package/dist/routing/ports.d.ts +51 -0
  89. package/dist/routing/ports.js +2 -0
  90. package/dist/routing/routing.d.ts +138 -0
  91. package/dist/routing/routing.js +443 -0
  92. package/dist/routing/types.d.ts +164 -0
  93. package/dist/routing/types.js +2 -0
  94. package/dist/seo/media.d.ts +23 -0
  95. package/dist/seo/media.js +30 -5
  96. package/dist/seo/page-head.js +8 -1
  97. package/dist/seo/seo.d.ts +2 -0
  98. package/dist/seo/seo.js +11 -6
  99. package/dist/seo/types.d.ts +8 -0
  100. package/dist/seo/xml-escape.d.ts +1 -3
  101. package/dist/seo/xml-escape.js +1 -6
  102. package/dist/widgets/concurrency.d.ts +27 -1
  103. package/dist/widgets/concurrency.js +12 -23
  104. package/dist/widgets/config-validation.js +6 -0
  105. package/dist/widgets/gallery-resolver.d.ts +23 -0
  106. package/dist/widgets/gallery-resolver.js +80 -0
  107. package/dist/widgets/html/gallery-render.d.ts +24 -0
  108. package/dist/widgets/html/gallery-render.js +65 -0
  109. package/dist/widgets/html/html-embeds.d.ts +4 -0
  110. package/dist/widgets/html/html-embeds.js +7 -1
  111. package/dist/widgets/html/index.d.ts +3 -0
  112. package/dist/widgets/html/index.js +3 -0
  113. package/dist/widgets/html/server-fragment.d.ts +9 -0
  114. package/dist/widgets/html/server-fragment.js +38 -0
  115. package/dist/widgets/markers/gallery.d.ts +50 -0
  116. package/dist/widgets/markers/gallery.js +164 -0
  117. package/dist/widgets/markers/html-attributes.d.ts +6 -1
  118. package/dist/widgets/markers/html-attributes.js +24 -9
  119. package/dist/widgets/markers/index.d.ts +2 -0
  120. package/dist/widgets/markers/index.js +2 -0
  121. package/dist/widgets/markers/marker.d.ts +4 -0
  122. package/dist/widgets/markers/marker.js +6 -0
  123. package/dist/widgets/markers/media-reference.d.ts +11 -0
  124. package/dist/widgets/markers/media-reference.js +11 -0
  125. package/dist/widgets/page-embed-types.d.ts +1 -1
  126. package/dist/widgets/page-embed-types.js +1 -1
  127. package/dist/widgets/registry.d.ts +144 -10
  128. package/dist/widgets/registry.js +40 -0
  129. package/dist/widgets/resolver-service.d.ts +10 -0
  130. package/dist/widgets/resolver-service.js +73 -29
  131. package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
  132. package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
  133. package/dist/widgets/resolvers/gallery.d.ts +12 -0
  134. package/dist/widgets/resolvers/gallery.js +33 -0
  135. package/dist/widgets/resolvers/index.d.ts +1 -0
  136. package/dist/widgets/resolvers/index.js +1 -0
  137. package/dist/widgets/types.d.ts +3 -1
  138. package/package.json +100 -7
@@ -1,3 +1,5 @@
1
+ import { FIELD_TYPE_REGISTRY, resolveFormField } from "./field-registry.js";
2
+ import { commonDescriptorErrors } from "./descriptor-rules.js";
1
3
  // Domain validation stays independent of feature manifests, and returns decisions without I/O
2
4
  // or exceptions so write and submission services can choose their own error presentation.
3
5
  // Published tool schemas and render-time defensive checks use these exported bounds/patterns
@@ -9,7 +11,7 @@ export const MAX_LABEL_LENGTH = 200;
9
11
  export const MIN_MAX_LENGTH = 1;
10
12
  export const MAX_MAX_LENGTH = 5000;
11
13
  export const FIELD_ID_PATTERN = /^[a-z][a-z0-9_]*$/;
12
- export const FIELD_TYPES = new Set(["text", "email", "textarea", "checkbox"]);
14
+ export const FIELD_TYPES = new Set(Object.keys(FIELD_TYPE_REGISTRY));
13
15
  export const MAX_CLASS_NAME_LENGTH = 300;
14
16
  export const MAX_ATTRIBUTES_PER_FIELD = 12;
15
17
  export const MAX_ATTRIBUTE_VALUE_LENGTH = 300;
@@ -25,7 +27,7 @@ export const HONEYPOT_KEY = "_hp";
25
27
  // Malformed IDs are never claimed: repeated malformed IDs must each report their shape error
26
28
  // rather than misleading the second field's author with a duplicate-ID diagnosis.
27
29
  function fieldIdErrors(descriptor, label, claimedIds) {
28
- if (!descriptor.id || !FIELD_ID_PATTERN.test(descriptor.id)) {
30
+ if (typeof descriptor.id !== "string" || !descriptor.id || !FIELD_ID_PATTERN.test(descriptor.id)) {
29
31
  return [{ field: label, reason: "id must match ^[a-z][a-z0-9_]*$" }];
30
32
  }
31
33
  if (claimedIds.has(descriptor.id)) {
@@ -41,6 +43,8 @@ function fieldTypeErrors(descriptor, label) {
41
43
  return [];
42
44
  }
43
45
  function labelLengthErrors(descriptor, label) {
46
+ if (typeof descriptor.label !== "string")
47
+ return [{ field: label, reason: "label must be a string" }];
44
48
  if (descriptor.label.length < MIN_LABEL_LENGTH || descriptor.label.length > MAX_LABEL_LENGTH) {
45
49
  return [{ field: label, reason: `label must be ${MIN_LABEL_LENGTH}-${MAX_LABEL_LENGTH} characters` }];
46
50
  }
@@ -117,7 +121,9 @@ function attributeErrors(descriptor, label) {
117
121
  * Empty helper results mean a satisfied rule. Bounds are 1-20 fields, labels 1-200 characters,
118
122
  * and maxLength 1-5000 (never on checkboxes), with unique lowercase field IDs.
119
123
  */
120
- export function validateFieldDescriptors({ fields }) {
124
+ export function validateFieldDescriptors({ fields }, _optional = {}) {
125
+ if (!Array.isArray(fields))
126
+ return { valid: false, fieldErrors: [{ field: "fields", reason: "must be an array" }] };
121
127
  const fieldErrors = [];
122
128
  if (fields.length < MIN_FIELDS) {
123
129
  fieldErrors.push({ field: "fields", reason: `at least ${MIN_FIELDS} field is required` });
@@ -127,85 +133,62 @@ export function validateFieldDescriptors({ fields }) {
127
133
  }
128
134
  const claimedIds = new Set();
129
135
  for (const descriptor of fields) {
136
+ if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) {
137
+ fieldErrors.push({ field: "fields", reason: "each field must be an object" });
138
+ continue;
139
+ }
130
140
  const label = descriptor.id || "(unknown)";
131
- fieldErrors.push(...fieldIdErrors(descriptor, label, claimedIds), ...fieldTypeErrors(descriptor, label), ...labelLengthErrors(descriptor, label), ...maxLengthErrors(descriptor, label), ...checkboxValueErrors(descriptor, label), ...classNameErrors(descriptor, label), ...attributeErrors(descriptor, label));
141
+ fieldErrors.push(...fieldIdErrors(descriptor, label, claimedIds), ...fieldTypeErrors(descriptor, label), ...labelLengthErrors(descriptor, label), ...maxLengthErrors(descriptor, label), ...checkboxValueErrors(descriptor, label), ...classNameErrors(descriptor, label), ...attributeErrors(descriptor, label), ...commonDescriptorErrors({ field: descriptor }));
132
142
  }
133
143
  if (fieldErrors.length > 0)
134
144
  return { valid: false, fieldErrors };
135
145
  return { valid: true };
136
146
  }
147
+ // Absence is distinct from acceptance: treating an omitted optional field as an accepted value
148
+ // would write undefined and violate the guarantee that stored answers contain only declared keys.
137
149
  // The honeypot is tolerated in the raw body, but is excluded from stored answers.
138
- function unregisteredKeyErrors(body, fieldsById) {
139
- const errors = [];
140
- for (const key of Object.keys(body)) {
141
- if (key === HONEYPOT_KEY)
142
- continue;
143
- if (!fieldsById.has(key)) {
144
- errors.push({ field: key, reason: "unregistered_key" });
145
- }
146
- }
147
- return errors;
148
- }
149
150
  // Whitespace is rejected only for required text; optional whitespace is an answer the definition
150
151
  // permits. Preserve the submitted string instead of silently trimming its stored value.
151
- function resolveTextValue(field, value) {
152
- if (typeof value !== "string")
153
- return { kind: "rejected", reason: "must be a string" };
154
- if (field.required && value.trim() === "")
155
- return { kind: "rejected", reason: "required" };
156
- if (field.maxLength != null && value.length > field.maxLength)
157
- return { kind: "rejected", reason: "too_long" };
158
- return { kind: "accepted", value };
159
- }
160
152
  // Native URL-encoded forms submit "on" for checked boxes without a value attribute; accepting
161
153
  // only JSON booleans would reject every checked checkbox when client JavaScript is disabled.
162
154
  // An unticked native checkbox omits its key; submission resolution treats that as absence.
163
- const HTML_CHECKBOX_ON_VALUE = "on";
164
- function resolveCheckboxValue(field, value) {
165
- if (field.checkboxValue !== undefined && typeof value === "string")
166
- return { kind: "accepted", value };
167
- if (typeof value === "boolean")
168
- return { kind: "accepted", value };
169
- if (value === HTML_CHECKBOX_ON_VALUE)
170
- return { kind: "accepted", value: true };
171
- return { kind: "rejected", reason: "must be a boolean" };
172
- }
173
155
  // JSON and native forms differ in how they represent missing values, so null/undefined both
174
- // mean absence. Boolean false is a real checkbox answer, including for a required checkbox.
175
- function resolveSubmittedValue(field, value) {
176
- if (value === undefined || value === null) {
177
- return field.required ? { kind: "rejected", reason: "required" } : { kind: "absent" };
178
- }
179
- if (field.type === "checkbox")
180
- return resolveCheckboxValue(field, value);
181
- return resolveTextValue(field, value);
156
+ // mean absence. Boolean false is a real checkbox answer, only for optional native checkboxes; required consent must be checked.
157
+ function unregisteredKeyErrors(body, fieldsById) {
158
+ return Object.keys(body).filter((key) => key !== HONEYPOT_KEY && !fieldsById.has(key)).map((field) => ({ field, reason: "unregistered_key" }));
182
159
  }
183
160
  /**
184
161
  * Only declared, accepted fields reach data; the reserved honeypot never does. Report unknown
185
162
  * body keys first, then declared-field errors in definition order, independent of body key order.
186
163
  * This keeps the declared-field portion of the error list stable across equivalent submissions.
187
164
  */
188
- export function validateSubmissionPayload(input) {
165
+ export function validateSubmissionPayload(input, optional = {}) {
189
166
  const { definition, body } = input;
167
+ if (!body || typeof body !== "object" || Array.isArray(body))
168
+ return { valid: false, fieldErrors: [{ field: "body", reason: "must be an object" }] };
190
169
  const fieldsById = new Map(definition.fields.map((f) => [f.id, f]));
191
170
  const fieldErrors = unregisteredKeyErrors(body, fieldsById);
192
171
  const data = {};
172
+ const files = {};
193
173
  for (const field of definition.fields) {
194
- const resolved = resolveSubmittedValue(field, body[field.id]);
174
+ const resolved = resolveFormField({ field, value: Object.hasOwn(body, field.id) ? body[field.id] : undefined }, optional);
195
175
  if (resolved.kind === "rejected") {
196
176
  fieldErrors.push({ field: field.id, reason: resolved.reason });
197
177
  }
198
178
  else if (resolved.kind === "accepted") {
199
179
  data[field.id] = resolved.value;
200
180
  }
181
+ else if (resolved.kind === "upload") {
182
+ files[field.id] = resolved.file;
183
+ }
201
184
  }
202
185
  if (fieldErrors.length > 0)
203
186
  return { valid: false, fieldErrors };
204
- return { valid: true, data };
187
+ return Object.keys(files).length ? { valid: true, data, files } : { valid: true, data };
205
188
  }
206
189
  // Only a non-empty trimmed string trips the trap; absent, non-string or whitespace-only values
207
190
  // do not reject an otherwise valid submission.
208
- export function isHoneypotTripped(input) {
191
+ export function isHoneypotTripped(input, _optional = {}) {
209
192
  if (typeof input.hp !== "string")
210
193
  return false;
211
194
  return input.hp.trim().length > 0;
@@ -3,6 +3,7 @@
3
3
  * Prototype belongs in @jini-ai/cms/forms later; its published release lacks HTML authoring. */
4
4
  import { parseFragment, serialize } from "parse5";
5
5
  import { FormFieldValidationError } from "../errors.js";
6
+ import { FIELD_TYPE_REGISTRY } from "../field-registry.js";
6
7
  export { decodeFormFields, encodeFormFields } from "../fields-json.js";
7
8
  function invalid(reason) {
8
9
  throw new FormFieldValidationError({ message: reason, fieldErrors: [{ field: "html", reason }] });
@@ -37,6 +38,18 @@ function labelText(node) {
37
38
  }
38
39
  return text.trim();
39
40
  }
41
+ /** Read only the nearest fieldset's direct legend; blank/missing text keeps control-label fallback.
42
+ * No I/O or mutation; O(d + c + t) time for ancestor depth, fieldset children and legend text,
43
+ * with labelText's O(t) traversal space. */
44
+ function groupLabel({ element }, _optional = {}) {
45
+ for (let parent = element.parentNode; parent && "tagName" in parent; parent = parent.parentNode) {
46
+ if (parent.tagName !== "fieldset")
47
+ continue;
48
+ const legend = parent.childNodes.find((child) => "tagName" in child && child.tagName === "legend");
49
+ return legend ? labelText(legend) || undefined : undefined;
50
+ }
51
+ return undefined;
52
+ }
40
53
  /** Keep the author's required-attribute spelling while retaining parser normalization that
41
54
  * discards dangling outer form closers. Both attribute ranges come from parse5, not a regex
42
55
  * that could mistake a script string for a control. No attribute value is synthesized here. */
@@ -97,14 +110,23 @@ export function deriveHtmlForm({ html }, _optional = {}) {
97
110
  element.attrs = element.attrs.filter((attribute) => attribute.name !== "name");
98
111
  continue;
99
112
  }
100
- if (element.tagName === "input" && ["file", "password"].includes(inputType))
101
- invalid(`Input type '${inputType}' is not supported`);
102
- if (element.tagName === "select" && attr(element, "multiple") !== undefined)
103
- invalid("Multiple selections are not supported");
104
- if (fields.has(name)) {
105
- if (inputType === "radio" && fields.get(name)?.type === "text") {
106
- const group = fields.get(name);
107
- group.required = group.required || attr(element, "required") !== undefined;
113
+ if (element.tagName === "input" && inputType === "password")
114
+ invalid("Password fields are not supported");
115
+ const existing = fields.get(name);
116
+ if (existing) {
117
+ // Radio/checkbox controls share a successful-control name by design; collect their allowed
118
+ // values rather than admitting arbitrary strings as the old HTML compatibility view did.
119
+ if (inputType === "radio" && existing.type === "radio" || inputType === "checkbox" && ["checkbox", "checkbox-group"].includes(existing.type)) {
120
+ const value = attr(element, "value") ?? "on";
121
+ if (existing.type === "checkbox") {
122
+ existing.type = "checkbox-group";
123
+ existing.options = [{ value: existing.checkboxValue ?? "on", label: existing.label }];
124
+ // Seed option one before replacing its standalone label with the group's legend.
125
+ existing.label = groupLabel({ element }) ?? existing.label;
126
+ delete existing.checkboxValue;
127
+ }
128
+ existing.options.push({ value, label: element.parentNode && "tagName" in element.parentNode && element.parentNode.tagName === "label" ? labelText(element.parentNode) || value : value });
129
+ existing.required = existing.required || attr(element, "required") !== undefined;
108
130
  continue;
109
131
  }
110
132
  invalid(`Duplicate field name '${name}'`);
@@ -117,14 +139,42 @@ export function deriveHtmlForm({ html }, _optional = {}) {
117
139
  ? element.parentNode : undefined;
118
140
  const label = labels.find((item) => id !== undefined && attr(item, "for") === id) ?? parentLabel;
119
141
  const maxLength = attr(element, "maxlength");
120
- fields.set(name, {
142
+ const type = element.tagName === "select" ? (attr(element, "multiple") !== undefined ? "multi-select" : "select")
143
+ : element.tagName === "textarea" ? "textarea"
144
+ : inputType === "checkbox" && attr(element, "role") === "switch" ? "toggle"
145
+ : Object.entries(FIELD_TYPE_REGISTRY).find(([, kind]) => kind.control === "input" && kind.htmlType === inputType)?.[0] ?? (inputType === "radio" ? "radio" : "text");
146
+ const choices = element.tagName === "select" ? elements(element.childNodes).filter((child) => child.tagName === "option").map((option) => ({ value: attr(option, "value") ?? labelText(option), label: labelText(option) })).filter((option) => option.value !== "")
147
+ : inputType === "radio" ? [{ value: attr(element, "value") ?? "on", label: label ? labelText(label) || name : attr(element, "value") ?? "on" }] : undefined;
148
+ const numeric = type === "number" || type === "range";
149
+ const descriptor = {
121
150
  id: name,
122
- label: label ? labelText(label) || name : attr(element, "aria-label") || name,
123
- type: element.tagName === "textarea" ? "textarea" : inputType === "email" ? "email" : inputType === "checkbox" ? "checkbox" : "text",
151
+ label: (type === "radio" ? groupLabel({ element }) : undefined) ?? (label ? labelText(label) || name : attr(element, "aria-label") || name),
152
+ type,
124
153
  required: attr(element, "required") !== undefined,
125
- ...(checkboxValue !== undefined ? { checkboxValue } : {}),
154
+ ...(checkboxValue !== undefined && type === "checkbox" ? { checkboxValue } : {}),
126
155
  ...(maxLength !== undefined ? { maxLength: Number(maxLength) } : {}),
127
- });
156
+ ...(choices ? { options: choices } : {}),
157
+ };
158
+ for (const key of ["placeholder", "pattern"]) {
159
+ const value = attr(element, key);
160
+ if (value !== undefined)
161
+ descriptor[key] = value;
162
+ }
163
+ for (const key of ["min", "max"]) {
164
+ const value = attr(element, key);
165
+ if (value !== undefined)
166
+ descriptor[key] = numeric ? Number(value) : value;
167
+ }
168
+ const minLength = attr(element, "minlength");
169
+ if (minLength !== undefined)
170
+ descriptor.minLength = Number(minLength);
171
+ const step = attr(element, "step");
172
+ if (step !== undefined)
173
+ descriptor.step = Number(step);
174
+ const accept = attr(element, "accept");
175
+ if (accept !== undefined && type === "file")
176
+ descriptor.accept = accept.split(",").map((item) => item.trim());
177
+ fields.set(name, descriptor);
128
178
  }
129
179
  // Re-serialization also discards ignored/dangling </form> tokens: they must never close our
130
180
  // server-owned wrapper early. Raw-text script contents are preserved by the HTML serializer.
@@ -1,9 +1,9 @@
1
1
  import type { FormDefinitionRecord } from "../index.js";
2
- /** Compatibility for the installed Jini release, which accepts boolean checkbox answers only.
3
- * Jini's next release natively reads checkboxValue; until then this CMS HTML adapter presents
4
- * authored checkbox strings as text to the same validator. It changes no durable descriptor.
5
- * Empty authored values are successful controls; missing required controls must still fail. */
6
- export declare function htmlSubmissionDefinition({ definition, body }: {
2
+ /** Earlier published releases accepted boolean checkbox answers only, so their HTML view
3
+ * presented authored strings as text. The field registry now natively validates checkboxValue,
4
+ * choice options and file types. Keep this public adapter as identity for host compatibility:
5
+ * changing a choice to text here would bypass the server's declared-value allowlist. */
6
+ export declare function htmlSubmissionDefinition({ definition }: {
7
7
  definition: FormDefinitionRecord;
8
8
  body: Record<string, unknown>;
9
9
  }, _optional?: {}): FormDefinitionRecord;
@@ -1,15 +1,8 @@
1
- /** Compatibility for the installed Jini release, which accepts boolean checkbox answers only.
2
- * Jini's next release natively reads checkboxValue; until then this CMS HTML adapter presents
3
- * authored checkbox strings as text to the same validator. It changes no durable descriptor.
4
- * Empty authored values are successful controls; missing required controls must still fail. */
5
- export function htmlSubmissionDefinition({ definition, body }, _optional = {}) {
6
- if (definition.mode !== "html")
7
- return definition;
8
- return { ...definition, fields: definition.fields.map((field) => {
9
- const checkbox = field;
10
- if (field.type !== "checkbox" || checkbox.checkboxValue === undefined || typeof body[field.id] !== "string")
11
- return field;
12
- return { ...field, type: "text", required: false };
13
- }) };
1
+ /** Earlier published releases accepted boolean checkbox answers only, so their HTML view
2
+ * presented authored strings as text. The field registry now natively validates checkboxValue,
3
+ * choice options and file types. Keep this public adapter as identity for host compatibility:
4
+ * changing a choice to text here would bypass the server's declared-value allowlist. */
5
+ export function htmlSubmissionDefinition({ definition }, _optional = {}) {
6
+ return definition;
14
7
  }
15
8
  //# sourceMappingURL=html-submission-adapter.js.map
@@ -12,12 +12,19 @@ export interface FormHtmlHooks {
12
12
  export declare function escapeFormHtml({ value }: {
13
13
  value: string;
14
14
  }, _optional?: {}): string;
15
+ /** Renderer hooks retain the CMS's established styling/error anchors without defining a theme. */
16
+ export interface FormBodyOptions {
17
+ submitLabel?: string;
18
+ idPrefix?: string;
19
+ fieldClass?: string;
20
+ errorClass?: string;
21
+ }
22
+ /** Shared body for authored and builder forms; every field's HTML semantics come from the registry.
23
+ * @complexity O(f + o) for fields and their bounded option lists, no I/O. */
15
24
  export declare function renderHtmlFormBody({ fields, hooks }: {
16
25
  fields: readonly FieldDescriptor[];
17
26
  hooks: Pick<FormHtmlHooks, "field">;
18
- }, { submitLabel }?: {
19
- submitLabel?: string;
20
- }): string;
27
+ }, options?: FormBodyOptions): string;
21
28
  /** `className` is the host's styling hook (the host passes its form CSS class so the active theme's form rules
22
29
  * apply); author CSS stays free to override it. Whitespace-separated, de-duplicated, never replaced. */
23
30
  export declare function renderHtmlForm({ slug, fields, action, hooks }: {
@@ -1,18 +1,86 @@
1
+ import { FIELD_TYPE_REGISTRY, isSafeFieldPattern } from "./field-registry.js";
2
+ import { ATTRIBUTE_NAME_PATTERN } from "./forms.js";
1
3
  export function escapeFormHtml({ value }, _optional = {}) {
2
4
  return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
3
5
  }
4
- export function renderHtmlFormBody({ fields, hooks }, { submitLabel = "Send" } = {}) {
5
- return fields.map((field) => {
6
- const name = escapeFormHtml({ value: field.id });
7
- const label = escapeFormHtml({ value: field.label });
8
- const required = field.required ? " required" : "";
9
- const maxLength = field.maxLength != null ? ` maxlength="${field.maxLength}"` : "";
10
- const attributes = `name="${name}" ${hooks.field}="${name}" toolparamtitle="${label}" toolparamdescription="${label}"${required}${maxLength}`;
11
- const control = field.type === "textarea"
12
- ? `<textarea ${attributes}></textarea>`
13
- : `<input type="${field.type}" ${attributes}>`;
14
- return `<label>${label}${control}</label>`;
15
- }).join("\n") + `\n<button type="submit">${escapeFormHtml({ value: submitLabel })}</button>`;
6
+ const attr = (name, value) => ` ${name}="${escapeFormHtml({ value: String(value) })}"`;
7
+ /** Extra attributes cannot override structural or server-backed constraints. Values are escaped at this sink. */
8
+ function extraAttributes(field, claimed) {
9
+ return Object.entries(field.attributes ?? {}).filter(([name, value]) => ATTRIBUTE_NAME_PATTERN.test(name) && !claimed.has(name) && typeof value === "string" && (name !== "pattern" || isSafeFieldPattern({ pattern: value }))).map(([name, value]) => attr(name, value)).join("");
10
+ }
11
+ /** One registry control rendered with label/legend, help/error associations and escaped defaults. */
12
+ function renderField(field, hooks, options) {
13
+ if (!field || typeof field.id !== "string" || typeof field.label !== "string" || !Object.hasOwn(FIELD_TYPE_REGISTRY, field.type))
14
+ return "";
15
+ const kind = FIELD_TYPE_REGISTRY[field.type];
16
+ const id = `${options.idPrefix ?? "form"}-${field.id}`;
17
+ const helpId = `${id}-help`;
18
+ const errorId = `${id}-error`;
19
+ const label = escapeFormHtml({ value: field.label });
20
+ const describedBy = [field.helpText ? helpId : "", errorId, field.attributes?.["aria-describedby"] ?? ""].filter(Boolean).join(" ");
21
+ const required = field.required ? " required" : "";
22
+ const base = attr("name", field.name ?? field.id) + attr(hooks.field, field.id) + attr("toolparamtitle", field.label) + attr("toolparamdescription", field.helpText ?? field.label) + attr("aria-describedby", describedBy);
23
+ const constraints = [
24
+ ["placeholder", field.placeholder], ["minlength", field.minLength], ["maxlength", field.maxLength], ["pattern", field.pattern],
25
+ ["min", field.min], ["max", field.max], ["step", field.step],
26
+ ];
27
+ const claimed = new Set(["aria-describedby"]);
28
+ let extra = field.className ? attr("class", field.className) : "";
29
+ for (const [name, value] of constraints) {
30
+ if (value == null)
31
+ continue;
32
+ if (name === "maxlength" && (!Number.isInteger(value) || Number(value) <= 0))
33
+ continue;
34
+ if (name === "minlength" && (!Number.isInteger(value) || Number(value) < 0))
35
+ continue;
36
+ if (name === "pattern" && !isSafeFieldPattern({ pattern: String(value) }))
37
+ continue;
38
+ extra += attr(name, value);
39
+ claimed.add(name);
40
+ }
41
+ extra += extraAttributes(field, claimed);
42
+ const help = field.helpText ? `<div${attr("id", helpId)} class="widget-form-field-help">${escapeFormHtml({ value: field.helpText })}</div>` : "";
43
+ const error = `<div${attr("class", options.errorClass ?? "widget-form-field-error")}${attr("data-field", field.id)}${attr("id", errorId)} hidden></div>`;
44
+ const defaultValues = Array.isArray(field.default) ? field.default : [field.default];
45
+ let control;
46
+ if (kind.control === "group") {
47
+ const inputs = (field.options ?? []).map((option, index) => {
48
+ const optionId = `${id}-${index}`;
49
+ const checked = defaultValues.includes(option.value) ? " checked" : "";
50
+ // Native checkbox-group required on each box means ALL, so server enforces one-or-more;
51
+ // aria-required communicates the group's requirement without changing that invariant.
52
+ const optionRequired = kind.multiple ? "" : required;
53
+ return `<label${attr("for", optionId)}><input${attr("id", optionId)}${attr("type", kind.htmlType)}${base}${attr("value", option.value)}${checked}${optionRequired}${extra}>${escapeFormHtml({ value: option.label })}</label>`;
54
+ }).join("");
55
+ return `<fieldset${attr("id", id)}${attr("class", options.fieldClass ?? "widget-form-field")}${attr("aria-describedby", describedBy)}${field.required ? ' aria-required="true"' : ""}><legend>${label}${field.required ? " *" : ""}</legend>${inputs}${help}${error}</fieldset>`;
56
+ }
57
+ if (kind.control === "select") {
58
+ const placeholder = kind.multiple ? "" : `<option value="">${escapeFormHtml({ value: field.placeholder ?? "Choose an option" })}</option>`;
59
+ const choices = (field.options ?? []).map((option) => `<option${attr("value", option.value)}${defaultValues.includes(option.value) ? " selected" : ""}>${escapeFormHtml({ value: option.label })}</option>`).join("");
60
+ control = `<select${attr("id", id)}${base}${kind.multiple ? " multiple" : ""}${required}${extra}>${placeholder}${choices}</select>`;
61
+ }
62
+ else if (kind.control === "textarea") {
63
+ control = `<textarea${attr("id", id)}${base}${required}${extra}>${escapeFormHtml({ value: String(field.default ?? "") })}</textarea>`;
64
+ }
65
+ else {
66
+ let value = field.default !== undefined ? attr("value", String(field.default)) : "";
67
+ if (kind.htmlType === "checkbox")
68
+ value = (field.checkboxValue !== undefined ? attr("value", field.checkboxValue) : "") + (field.default === true || field.default === field.checkboxValue && field.default !== undefined ? " checked" : "");
69
+ if (field.type === "file")
70
+ value = field.accept ? attr("accept", field.accept.join(",")) : "";
71
+ control = `<input${attr("type", kind.htmlType)}${attr("id", id)}${base}${field.type === "toggle" ? ' role="switch"' : ""}${required}${value}${extra}>`;
72
+ }
73
+ if (field.type === "hidden")
74
+ return control;
75
+ // The checkbox modifier lets the baseline put the box before its label on one line (owner report
76
+ // 2026-10-05: stacked and stretched like a text input, its glyph drew centred under the label).
77
+ const fieldClass = (options.fieldClass ?? "widget-form-field") + (kind.htmlType === "checkbox" ? " widget-form-field-checkbox" : "");
78
+ return `<div${attr("class", fieldClass)}><label${attr("for", id)}>${label}${field.required ? " *" : ""}</label>${control}${help}${error}</div>`;
79
+ }
80
+ /** Shared body for authored and builder forms; every field's HTML semantics come from the registry.
81
+ * @complexity O(f + o) for fields and their bounded option lists, no I/O. */
82
+ export function renderHtmlFormBody({ fields, hooks }, options = {}) {
83
+ return fields.map((field) => renderField(field, hooks, options)).join("\n") + `\n<button type="submit">${escapeFormHtml({ value: options.submitLabel ?? "Send" })}</button>`;
16
84
  }
17
85
  /** `className` is the host's styling hook (the host passes its form CSS class so the active theme's form rules
18
86
  * apply); author CSS stays free to override it. Whitespace-separated, de-duplicated, never replaced. */
@@ -22,10 +90,44 @@ export function renderHtmlForm({ slug, fields, action, hooks }, { html, successM
22
90
  const classes = [...new Set((className ?? "").split(/\s+/).filter(Boolean))].join(" ");
23
91
  const classAttr = classes ? `class="${escapeFormHtml({ value: classes })}" ` : "";
24
92
  return `<div ${hooks.success} ${slugHook} role="status" hidden>${escapeFormHtml({ value: successMessage })}</div>` +
25
- `<form ${classAttr}${hooks.form}="${escapedSlug}" ${slugHook} toolname="form_${escapeFormHtml({ value: slug.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 123) })}" tooldescription="${escapedSlug}" method="post" action="${escapeFormHtml({ value: action })}">` +
93
+ `<form ${classAttr}${hooks.form}="${escapedSlug}" ${slugHook} toolname="form_${escapeFormHtml({ value: slug.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 123) })}" tooldescription="${escapedSlug}" method="post"${fields.some((field) => field.type === "file") ? ' enctype="multipart/form-data"' : ""} action="${escapeFormHtml({ value: action })}">` +
26
94
  `<div ${hooks.error} ${slugHook} role="alert" hidden></div>` +
27
- (html ?? renderHtmlFormBody({ fields, hooks })) +
95
+ (html ?? renderHtmlFormBody({ fields, hooks }, { idPrefix: `form-${slug}` })) +
28
96
  // Hidden container requires neither a theme class nor inline CSS; bots still see the input.
29
97
  `<div hidden aria-hidden="true"><input type="text" name="_hp" tabindex="-1" autocomplete="off"></div></form>`;
30
98
  }
99
+ /* Migration rationale retained from the host renderer:
100
+
101
+ * Renders one field descriptor's `class`/extra-attribute string, both attached to the SAME
102
+ * rendered input element the field's own `id`/`name`/`required`/`type` already get. Re-checks
103
+ * `ATTRIBUTE_NAME_PATTERN` here rather than trusting that every stored field passed through
104
+ * `forms.ts`'s `validateFieldDescriptors` (this is the public render path, REQ-37/ADR-047) —
105
+ * defense in depth, since an attribute NAME is not something `escapeHtml` can make safe the way it
106
+ * can a value (`onclick` is structurally dangerous regardless of how its own text is escaped).
107
+ * `className` carries no such risk once escaped, so it is not re-validated, only escaped.
108
+
109
+ One `<textarea>`/checkbox/text-or-email `<input>` for a contact-form field descriptor — pulled out
110
+ * of {@link renderField}'s old three-way nested ternary so each shape is a flat, separately
111
+ * readable branch rather than one expression whose nesting depth drove the field-renderer's cognitive
112
+ * complexity. `requiredAttr` is computed once and reused across all three shapes, same value the old
113
+ * nested ternary recomputed per-branch. `describedByAttr` wires the field to its own (initially empty
114
+ * and hidden) error slot for assistive tech — see {@link renderField}'s own doc for why
115
+ * that slot exists even though nothing fills it in on the very first render.
116
+ The field's own `maxLength` as a `maxlength` attribute, so the browser stops input at the same
117
+ * limit `forms.ts`'s `validateSubmissionPayload` enforces (`too_long`) instead of letting a visitor
118
+ * type past it and lose the submission to a server rejection. Only a positive integer renders —
119
+ * the value comes from stored JSON, so anything else is dropped rather than interpolated.
120
+ * @complexity O(1).
121
+ One contact-form field descriptor -> its `<div class="widget-form-field">` markup — extracted from
122
+ * {@link renderWidgetContactForm}'s old inline `.map()` callback so the field's own render logic is a
123
+ * named, independently measured function rather than an anonymous closure.
124
+ *
125
+ * Carries its own `<div class="widget-form-field-error" data-field="ID" hidden></div>` slot, hidden
126
+ * and empty on every ordinary render — `renderWidgetContactForm` has no request in scope (this is a
127
+ * pure prop-to-HTML function, no I/O), so it cannot know at render time whether THIS submission
128
+ * failed validation on THIS field. The slot exists purely as a stable, pre-built anchor
129
+ * {@link injectFormSubmissionResultIntoHtml} fills in and un-hides after the fact, on the
130
+ * Post/Redirect/Get reload that follows a failed submission (see that function's own doc for the
131
+ * full mechanism).
132
+ */
31
133
  //# sourceMappingURL=html-render.js.map
@@ -14,4 +14,5 @@ export * from "./submission-email-validation.js";
14
14
  export * from "./delete-submission.js";
15
15
  export * from "./duplicate-slug.js";
16
16
  export * from "./repo.memory.js";
17
+ export * from "./field-registry.js";
17
18
  //# sourceMappingURL=index.d.ts.map
@@ -14,4 +14,5 @@ export * from "./submission-email-validation.js";
14
14
  export * from "./delete-submission.js";
15
15
  export * from "./duplicate-slug.js";
16
16
  export * from "./repo.memory.js";
17
+ export * from "./field-registry.js";
17
18
  //# sourceMappingURL=index.js.map
@@ -4,7 +4,7 @@ import { FormSlugConflictError } from "./errors.js";
4
4
  function cloneStored(row) {
5
5
  return {
6
6
  ...row,
7
- fields: row.fields.map((f) => ({ ...f })),
7
+ fields: structuredClone(row.fields),
8
8
  notify: { ...row.notify, recipients: [...row.notify.recipients] },
9
9
  };
10
10
  }
@@ -95,12 +95,12 @@ export class InMemoryFormDefinitionRepo {
95
95
  }
96
96
  }
97
97
  function cloneSubmission(row) {
98
- return { ...row, data: { ...row.data } };
98
+ return { ...row, data: structuredClone(row.data) };
99
99
  }
100
100
  /** The port's view of a stored row: the Trash columns stay inside this adapter. */
101
101
  function toSubmissionRecord(row) {
102
102
  const { deletedAt: _deletedAt, version: _version, ...record } = row;
103
- return { ...record, data: { ...record.data } };
103
+ return { ...record, data: structuredClone(record.data) };
104
104
  }
105
105
  export class InMemoryFormSubmissionRepo {
106
106
  rows = new Map();
@@ -113,7 +113,7 @@ export class InMemoryFormSubmissionRepo {
113
113
  return toSubmissionRecord(row);
114
114
  }
115
115
  async create(record) {
116
- this.rows.set(record.id, { ...record, data: { ...record.data }, deletedAt: null, version: 1 });
116
+ this.rows.set(record.id, { ...record, data: structuredClone(record.data), deletedAt: null, version: 1 });
117
117
  }
118
118
  /** Rolls this repo's rows back when `work` throws, as the SQL adapter's transaction does. Calls
119
119
  * take turns (as the SQLite kernel's do) so one rollback cannot erase another call's row; a
@@ -49,7 +49,7 @@ export interface SubmissionAttempts {
49
49
  * The key one attempt is remembered under: a SHA-256 of workspace, slug, source IP, attempt token
50
50
  * (null when the request carried none) and the body. Body keys are sorted so their order cannot
51
51
  * change the key. Held only in memory, only for the window.
52
- * @complexity O(b log b) for b body keys.
52
+ * @complexity O(b log b + n) for b body keys and n uploaded bytes, hashed in 8 KiB chunks.
53
53
  */
54
54
  export declare function submissionAttemptKey({ input, attemptToken, hash }: {
55
55
  input: SubmitFormInput;
@@ -10,11 +10,22 @@ export const DUPLICATE_ATTEMPT = Symbol("duplicate form submission attempt");
10
10
  * The key one attempt is remembered under: a SHA-256 of workspace, slug, source IP, attempt token
11
11
  * (null when the request carried none) and the body. Body keys are sorted so their order cannot
12
12
  * change the key. Held only in memory, only for the window.
13
- * @complexity O(b log b) for b body keys.
13
+ * @complexity O(b log b + n) for b body keys and n uploaded bytes, hashed in 8 KiB chunks.
14
14
  */
15
15
  export function submissionAttemptKey({ input, attemptToken, hash }, _optional = {}) {
16
16
  // Object keys are unique, so the comparator never sees a tie.
17
- const body = Object.entries(input.body).sort(([a], [b]) => (a < b ? -1 : 1));
17
+ const body = Object.entries(input.body).map(([key, value]) => {
18
+ if (!value || typeof value !== "object" || !("bytes" in value) || !(value.bytes instanceof Uint8Array))
19
+ return [key, value];
20
+ // Hash bounded chunks rather than JSON-encoding millions of Uint8Array index properties.
21
+ // Chunk order and byte length remain bound to the digest; no uploaded bytes are remembered.
22
+ const digests = [];
23
+ for (let offset = 0; offset < value.bytes.byteLength; offset += 8192) {
24
+ digests.push(hash(String.fromCharCode(...value.bytes.subarray(offset, offset + 8192))));
25
+ }
26
+ const { bytes, ...metadata } = value;
27
+ return [key, { ...metadata, byteLength: bytes.byteLength, bytesHash: hash(JSON.stringify(digests)) }];
28
+ }).sort(([a], [b]) => (a < b ? -1 : 1));
18
29
  return hash(JSON.stringify([input.workspaceId, input.slug, input.sourceIp, attemptToken ?? null, body]));
19
30
  }
20
31
  /**
@@ -1,8 +1,6 @@
1
1
  import { FormSubmissionValidationError } from "./errors.js";
2
2
  import { isHoneypotTripped, validateSubmissionPayload } from "./forms.js";
3
- // Match native type=email syntax (WHATWG HTML email state), including single-label domains.
4
- // Do not trim or rewrite answers: Jini's payload contract preserves submitted strings.
5
- const EMAIL_PATTERN = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
3
+ import { isValidFormEmail } from "./email-format.js";
6
4
  /** Compatibility guard until Jini validates email fields itself. Lookup remains the first step;
7
5
  * disabled forms and honeypots retain the package's response before any validation or writes. */
8
6
  export function assertSubmissionEmails({ definition, body }, _optional = {}) {
@@ -17,7 +15,7 @@ export function assertSubmissionEmails({ definition, body }, _optional = {}) {
17
15
  if (existing.some((error) => error.field === field.id))
18
16
  return false;
19
17
  // JS's $ can match before a final newline; comparing the full match refuses that bypass.
20
- return EMAIL_PATTERN.exec(value)?.[0] !== value;
18
+ return !isValidFormEmail({ value });
21
19
  }).map((field) => field.id));
22
20
  if (invalid.size === 0)
23
21
  return;
@@ -27,6 +27,7 @@ import type { Clock, IdGenerator, UUID } from "@jini-ai/core/primitives";
27
27
  import type { FormDefinitionRepoPort, FormSubmissionRepoPort } from "./ports.js";
28
28
  import type { FormDefinitionViewPort } from "./ports.js";
29
29
  import { type SubmissionAttempts } from "./submission-attempts.js";
30
+ import type { FormFileStoragePort } from "./types.js";
30
31
  export interface SubmitFormDeps {
31
32
  definitionRepo: FormDefinitionRepoPort;
32
33
  submissionRepo: FormSubmissionRepoPort;
@@ -36,6 +37,8 @@ export interface SubmitFormDeps {
36
37
  clock: Clock;
37
38
  idGen: IdGenerator;
38
39
  rateLimiter: RateLimiterPort;
40
+ /** Anonymous uploads go through the host media owner only after validation/rate/dedupe. */
41
+ files?: FormFileStoragePort;
39
42
  /** Omitted: the two writes run back to back with no rollback between them. */
40
43
  transaction?: SubmissionTransactionPort;
41
44
  }