@seamlet/astro 1.2.1 → 1.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ - Requests: `requestForm({ id, title, fields })` with `requestText`, `requestLongText`, `requestPhone`, `requestEmail` and `requestChoice` declares a form visitors send the business requests with; `defineSeamletSite` takes them as `forms`, and the site's description tells the CMS about them. `RequestForm.astro` renders one, with a checkbox of consent of its own; it is posted to the CMS without a script, and sent in place with it.
6
+ - `readRequestEntries(body)` reads a request's body into its words, and `readRequestValues(form, entries)` checks a request the same way in the browser and in the CMS, with issues by field (`required`, `too-long`, `invalid-phone`, `invalid-email`, `unknown-option`) and their texts (`REQUEST_ISSUE_TEXTS`).
7
+ - The events `seamlet:request-send` (cancelable), `seamlet:request-sent` and `seamlet:request-failed` on the form let the site's code send a request its own way or follow it.
8
+
9
+ A site with forms needs a CMS that reads descriptions of 1.3 (cms.seamlet.ru from its release that takes requests). Code with an exhaustive `switch` over `SiteDescriptionProblem` gets its new members to handle.
10
+
3
11
  ## 1.2.1
4
12
 
5
13
  - `site.staticPaths(content)` is what Astro's `GetStaticPaths` takes: a static site's `[...page]` route may write `satisfies GetStaticPaths` and read its props with `InferGetStaticPropsType`. `StaticPage` and `RenderedPage` became type aliases, and `staticPaths` returns a mutable array.
package/README.md CHANGED
@@ -362,6 +362,71 @@ A field is edited where it is seen, so the editor opens what hides it when a per
362
362
 
363
363
  What was opened stays open while the page is rendered anew after every edit: a part is known again by its `id`, or else by the field marked on it, in it or around it.
364
364
 
365
+ ## Requests
366
+
367
+ A visitor sends the business a request from the site: «Обратный звонок», «Рассчитать стоимость», «Бронь столика». The site declares its forms, gives them to `defineSeamletSite` and renders them; the CMS keeps every request a year, writes to the people who edit the site and passes it on where the site's developer says.
368
+
369
+ ```ts
370
+ // src/forms.ts
371
+ import {
372
+ requestChoice,
373
+ requestForm,
374
+ requestLongText,
375
+ requestPhone,
376
+ requestText,
377
+ } from "@seamlet/astro/schema";
378
+
379
+ export const estimateForm = requestForm({
380
+ id: "estimate",
381
+ title: "Рассчитать стоимость",
382
+ fields: {
383
+ name: requestText({ label: "Имя", required: true }),
384
+ phone: requestPhone({ label: "Телефон", required: true }),
385
+ size: requestChoice({ label: "Размер", options: ["6×4", "6×6"] }),
386
+ comment: requestLongText({ label: "Комментарий", maxLength: 1000 }),
387
+ },
388
+ });
389
+ ```
390
+
391
+ ```ts
392
+ // src/seamlet.ts
393
+ export default defineSeamletSite({
394
+ schema,
395
+ initialContent,
396
+ pages,
397
+ forms: [estimateForm],
398
+ });
399
+ ```
400
+
401
+ ```astro
402
+ ---
403
+ import RequestForm from "@seamlet/astro/components/RequestForm.astro";
404
+ import { estimateForm } from "../forms";
405
+ ---
406
+
407
+ <RequestForm form={estimateForm} submitLabel="Рассчитать" />
408
+ ```
409
+
410
+ The fields are `requestText` (a line), `requestLongText` (several lines), `requestPhone`, `requestEmail` and `requestChoice` (one of `options`); each may be `required`, and a text has a `maxLength` (200 for a line, 2000 for several by default). A field's name is a name such as `phone` or `preferredTime`. The form's `title` names its requests in the CMS and in the letters.
411
+
412
+ `RequestForm.astro` renders plain elements with classes (`seamlet-form`, `seamlet-form-field`, `seamlet-form-issue`, `seamlet-form-consent`, `seamlet-form-policy`, `seamlet-form-submit`, `seamlet-form-sent`, `seamlet-form-failed`), which the site styles from around it: `.contacts :global(.seamlet-form-field)`. Its props are the `form`, the words of the button (`submitLabel`), what the visitor reads once the request is sent or could not be (`sentText`, `failedText`), the addresses of the texts of consent and of the policy (`consentHref`, `policyHref`, the CMS's pages of them by default) and `instance`, which tells apart two forms of the same id on one page.
413
+
414
+ The visitor consents with a checkbox of its own, never checked beforehand, to the texts the business confirmed in the CMS: until then the CMS takes no requests of the site, and the form says it could not send. Without a script the form is posted to the CMS, which answers with a page of its own; with the package's script (on every page, loaded only where a form is) the visitor stays on the page. The script checks what was typed with the code the CMS checks it with (`readRequestValues`), says what to fix under each field and sends the request.
415
+
416
+ The site's own code hears every step from events on the form element, which bubble:
417
+
418
+ - `seamlet:request-send` (`REQUEST_SEND_EVENT`), before the request is sent, with `{ form, entries }`. Cancelable: after `event.preventDefault()` the package sends nothing, and the site sends the request its own way.
419
+ - `seamlet:request-sent` (`REQUEST_SENT_EVENT`), once the CMS has kept it, with `{ form, entries, id }`: a goal of the site's analytics, its own message.
420
+ - `seamlet:request-failed` (`REQUEST_FAILED_EVENT`), with `{ form, reason, issues }`; `reason` is `invalid`, `closed` (the business takes no requests yet), `too-many` or `unavailable`.
421
+
422
+ ```ts
423
+ document.addEventListener(REQUEST_SENT_EVENT, (event) => {
424
+ ym(COUNTER, "reachGoal", event.detail.form);
425
+ });
426
+ ```
427
+
428
+ A site whose `Content-Security-Policy` sets `connect-src` or `form-action` lets the CMS's origin in both. A request the policy stops on its way is posted by the browser as a plain form.
429
+
365
430
  ## What the integration adds
366
431
 
367
432
  - `/preview` and `/preview/<page>`: the preview the CMS's editor frames, the pages of records too (at their address or at the record's id, a page the site does not show too). `GET` shows the published content; `POST` with the content as JSON shows that content without saving it, which is how the editor shows every edit at once. Only the CMS may frame it (`Content-Security-Policy: frame-ancestors`), and it is never indexed or cached; its refusals (an address of no page, a record a draft does not have, a draft that is not JSON or is over 1 MB) say the same.
@@ -0,0 +1,192 @@
1
+ ---
2
+ // A form of the site's requests as plain elements, which the site styles from around it
3
+ // (`.contacts :global(.seamlet-form-field)`). Without a script it is a form posted to the CMS, and
4
+ // the CMS answers with a page of its own; the package's script sends it in place. The checkbox of
5
+ // consent stands apart from the fields and is never checked beforehand, as the law asks: the
6
+ // visitor consents to the texts of the business, which the CMS shows at their addresses.
7
+ import { cmsOrigin, siteId } from "virtual:seamlet/config";
8
+ import {
9
+ legalPagePathOf,
10
+ maxLengthOf,
11
+ REQUEST_CONSENT_KEY,
12
+ REQUEST_FORM_KEY,
13
+ REQUEST_PAGE_KEY,
14
+ REQUEST_TRAP_KEY,
15
+ requestsPathOf,
16
+ type RequestField,
17
+ type RequestForm,
18
+ } from "@seamlet/astro/schema";
19
+
20
+ interface Props {
21
+ readonly form: RequestForm;
22
+ /** The words of the button. */
23
+ readonly submitLabel?: string;
24
+ /** What the visitor reads once the business has the request. */
25
+ readonly sentText?: string;
26
+ /** What the visitor reads when the request could not be sent. */
27
+ readonly failedText?: string;
28
+ /** Where the text of consent is: the CMS's page of it unless the site has its own. */
29
+ readonly consentHref?: string;
30
+ /** Where the business's policy is: the CMS's page of it unless the site has its own. */
31
+ readonly policyHref?: string;
32
+ /** Tells two forms of the same id on one page apart, in the ids of their elements. */
33
+ readonly instance?: string;
34
+ readonly class?: string;
35
+ }
36
+
37
+ const {
38
+ form,
39
+ submitLabel = "Отправить",
40
+ sentText = "Спасибо! Заявка отправлена, мы скоро свяжемся с вами.",
41
+ failedText = "Не получилось отправить заявку. Попробуйте ещё раз чуть позже.",
42
+ consentHref = new URL(legalPagePathOf(siteId, "consent"), cmsOrigin).href,
43
+ policyHref = new URL(legalPagePathOf(siteId, "privacy"), cmsOrigin).href,
44
+ instance = form.id,
45
+ class: className,
46
+ } = Astro.props;
47
+
48
+ const action = new URL(requestsPathOf(siteId), cmsOrigin).href;
49
+ const fields = Object.entries(form.fields);
50
+ const idOf = (name: string) => `seamlet-${instance}-${name}`;
51
+ const issueIdOf = (name: string) => `${idOf(name)}-issue`;
52
+ /** The page the form stands on, so the business knows where the request came from. */
53
+ const page = Astro.url.pathname;
54
+ /** A field typed on one line of an `<input>`. */
55
+ type LineField = Extract<RequestField, { kind: "text" | "phone" | "email" }>;
56
+ const INPUT_TYPES: Readonly<Record<LineField["kind"], string>> = {
57
+ text: "text",
58
+ phone: "tel",
59
+ email: "email",
60
+ };
61
+ /** What a phone's keyboard and a browser's memory of addresses offer for a field. */
62
+ const AUTOCOMPLETE: Partial<Record<LineField["kind"], string>> = {
63
+ phone: "tel",
64
+ email: "email",
65
+ };
66
+ const isLineField = (field: RequestField): field is LineField =>
67
+ field.kind === "text" || field.kind === "phone" || field.kind === "email";
68
+ ---
69
+
70
+ <form
71
+ class:list={["seamlet-form", className]}
72
+ method="post"
73
+ action={action}
74
+ data-seamlet-form={form.id}
75
+ data-seamlet-request-form={JSON.stringify(form)}
76
+ >
77
+ <input type="hidden" name={REQUEST_FORM_KEY} value={form.id} />
78
+ <input type="hidden" name={REQUEST_PAGE_KEY} value={page} />
79
+ {fields.map(([name, field]) => (
80
+ <div class="seamlet-form-field" data-field={name}>
81
+ <label for={idOf(name)}>
82
+ {field.label}
83
+ {field.required === true && <span aria-hidden="true"> *</span>}
84
+ </label>
85
+ {field.kind === "longText" && (
86
+ <textarea
87
+ id={idOf(name)}
88
+ name={name}
89
+ rows="4"
90
+ required={field.required === true}
91
+ maxlength={maxLengthOf(field)}
92
+ aria-describedby={issueIdOf(name)}
93
+ />
94
+ )}
95
+ {field.kind === "choice" && (
96
+ <select
97
+ id={idOf(name)}
98
+ name={name}
99
+ required={field.required === true}
100
+ aria-describedby={issueIdOf(name)}
101
+ >
102
+ <option value="">Выберите</option>
103
+ {field.options.map((option) => (
104
+ <option value={option}>{option}</option>
105
+ ))}
106
+ </select>
107
+ )}
108
+ {isLineField(field) && (
109
+ <input
110
+ id={idOf(name)}
111
+ name={name}
112
+ type={INPUT_TYPES[field.kind]}
113
+ required={field.required === true}
114
+ maxlength={field.kind === "text" ? maxLengthOf(field) : undefined}
115
+ autocomplete={AUTOCOMPLETE[field.kind]}
116
+ aria-describedby={issueIdOf(name)}
117
+ />
118
+ )}
119
+ <p
120
+ class="seamlet-form-issue"
121
+ id={issueIdOf(name)}
122
+ data-seamlet-issue={name}
123
+ hidden
124
+ />
125
+ </div>
126
+ ))}
127
+ <div class="seamlet-form-trap" aria-hidden="true">
128
+ <label>
129
+ Это поле заполнять не нужно
130
+ <input
131
+ type="text"
132
+ name={REQUEST_TRAP_KEY}
133
+ tabindex="-1"
134
+ autocomplete="off"
135
+ />
136
+ </label>
137
+ </div>
138
+ <div class="seamlet-form-consent">
139
+ <input
140
+ id={idOf(REQUEST_CONSENT_KEY)}
141
+ type="checkbox"
142
+ name={REQUEST_CONSENT_KEY}
143
+ value="on"
144
+ required
145
+ aria-describedby={issueIdOf(REQUEST_CONSENT_KEY)}
146
+ />
147
+ <label for={idOf(REQUEST_CONSENT_KEY)}>
148
+ Даю{" "}
149
+ <a href={consentHref} target="_blank" rel="noopener">
150
+ согласие на обработку персональных данных
151
+ </a>
152
+ </label>
153
+ <p
154
+ class="seamlet-form-issue"
155
+ id={issueIdOf(REQUEST_CONSENT_KEY)}
156
+ data-seamlet-issue={REQUEST_CONSENT_KEY}
157
+ hidden
158
+ />
159
+ </div>
160
+ <p class="seamlet-form-policy">
161
+ <a href={policyHref} target="_blank" rel="noopener">
162
+ Политика обработки персональных данных
163
+ </a>
164
+ </p>
165
+ <button class="seamlet-form-submit" type="submit">
166
+ {submitLabel}
167
+ </button>
168
+ <p
169
+ class="seamlet-form-sent"
170
+ role="status"
171
+ tabindex="-1"
172
+ data-seamlet-sent
173
+ data-seamlet-text={sentText}
174
+ />
175
+ <p
176
+ class="seamlet-form-failed"
177
+ role="alert"
178
+ data-seamlet-failed
179
+ data-seamlet-text={failedText}
180
+ />
181
+ </form>
182
+
183
+ <style>
184
+ /* Out of sight and out of reach of the keyboard, yet a field a program fills. */
185
+ .seamlet-form-trap {
186
+ position: absolute;
187
+ left: -10000px;
188
+ width: 1px;
189
+ height: 1px;
190
+ overflow: hidden;
191
+ }
192
+ </style>
package/dist/bridge.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  shortcutOf,
19
19
  toggleList,
20
20
  toggleMark
21
- } from "./chunk-Y4R4YV5O.js";
21
+ } from "./chunk-2GZZHXP5.js";
22
22
  import {
23
23
  FIELD_PATH_ATTRIBUTE,
24
24
  REVEALED_ATTRIBUTE,
@@ -28,7 +28,7 @@ import {
28
28
  readRichText,
29
29
  resolveFieldPath,
30
30
  safeHref
31
- } from "./chunk-UL2C2IB2.js";
31
+ } from "./chunk-ENMUO442.js";
32
32
 
33
33
  // src/bridge/typed-fields.ts
34
34
  function typedKindAt(schema, path) {
@@ -6,7 +6,7 @@ import {
6
6
  readRichText,
7
7
  resolveFieldPath,
8
8
  safeHref
9
- } from "./chunk-UL2C2IB2.js";
9
+ } from "./chunk-ENMUO442.js";
10
10
 
11
11
  // src/bridge/editor-shortcuts.ts
12
12
  function shortcutOf(event) {
@@ -0,0 +1,6 @@
1
+ // src/request-form-markers.ts
2
+ var REQUEST_FORM_SELECTOR = "form[data-seamlet-form]";
3
+
4
+ export {
5
+ REQUEST_FORM_SELECTOR
6
+ };
@@ -1325,6 +1325,106 @@ function originOf(value) {
1325
1325
  return WEB_PROTOCOLS.has(url2.protocol) ? url2.origin : void 0;
1326
1326
  }
1327
1327
 
1328
+ // src/schema/request-forms.ts
1329
+ var DEFAULT_TEXT_LENGTH = 200;
1330
+ var DEFAULT_LONG_TEXT_LENGTH = 2e3;
1331
+ var MAX_REQUEST_FIELD_LENGTH = 5e3;
1332
+ var MAX_OPTIONS = 50;
1333
+ var MAX_FORM_ID_LENGTH = 40;
1334
+ var FORM_ID = /^[a-z][a-z0-9-]*$/u;
1335
+ var FIELD_NAME = /^[a-z][a-zA-Z0-9]*$/u;
1336
+ var LINE_BREAK = /[\r\n]/u;
1337
+ var REQUEST_FIELD_KINDS = [
1338
+ "text",
1339
+ "longText",
1340
+ "phone",
1341
+ "email",
1342
+ "choice"
1343
+ ];
1344
+ function requestForm(form) {
1345
+ return form;
1346
+ }
1347
+ function requestText(options) {
1348
+ return { kind: "text", ...options };
1349
+ }
1350
+ function requestLongText(options) {
1351
+ return { kind: "longText", ...options };
1352
+ }
1353
+ function requestPhone(options) {
1354
+ return { kind: "phone", ...options };
1355
+ }
1356
+ function requestEmail(options) {
1357
+ return { kind: "email", ...options };
1358
+ }
1359
+ function requestChoice(options) {
1360
+ return { kind: "choice", ...options };
1361
+ }
1362
+ function readRequestForm(input) {
1363
+ if (!isJsonObject(input)) return invalidForm("not-a-form");
1364
+ const { id, title, fields } = input;
1365
+ if (!isFormId(id)) return invalidForm("invalid-id");
1366
+ if (!isText(title)) return invalidForm("invalid-title");
1367
+ if (!isJsonObject(fields)) return invalidForm("no-fields");
1368
+ const entries = Object.entries(fields);
1369
+ if (entries.length === 0) return invalidForm("no-fields");
1370
+ const hasInvalidName = entries.some(([name]) => !FIELD_NAME.test(name));
1371
+ if (hasInvalidName) return invalidForm("invalid-field-name");
1372
+ const readFields2 = entries.map(
1373
+ ([name, field]) => [name, readRequestField(field)]
1374
+ );
1375
+ const isEveryFieldRead = readFields2.every(([, field]) => field !== void 0);
1376
+ if (!isEveryFieldRead) return invalidForm("invalid-field");
1377
+ const form = { id, title, fields: Object.fromEntries(readFields2) };
1378
+ return { status: "read", form };
1379
+ }
1380
+ function isFormId(value) {
1381
+ return typeof value === "string" && value.length <= MAX_FORM_ID_LENGTH && FORM_ID.test(value);
1382
+ }
1383
+ function maxLengthOf(field) {
1384
+ const fallback = field.kind === "text" ? DEFAULT_TEXT_LENGTH : DEFAULT_LONG_TEXT_LENGTH;
1385
+ return field.maxLength ?? fallback;
1386
+ }
1387
+ function readRequestField(input) {
1388
+ if (!isJsonObject(input)) return void 0;
1389
+ const { kind, label, required } = input;
1390
+ if (!isRequestFieldKind(kind) || !isText(label)) return void 0;
1391
+ if (required !== void 0 && typeof required !== "boolean") {
1392
+ return void 0;
1393
+ }
1394
+ const common = { label, ...required === void 0 ? {} : { required } };
1395
+ switch (kind) {
1396
+ case "text":
1397
+ case "longText":
1398
+ return withMaxLength(kind, common, input["maxLength"]);
1399
+ case "phone":
1400
+ case "email":
1401
+ return { kind, ...common };
1402
+ case "choice":
1403
+ return withOptions2(common, input["options"]);
1404
+ }
1405
+ }
1406
+ function withMaxLength(kind, common, maxLength) {
1407
+ if (maxLength === void 0) return { kind, ...common };
1408
+ const isSoundLength = Number.isInteger(maxLength) && typeof maxLength === "number" && maxLength > 0 && maxLength <= MAX_REQUEST_FIELD_LENGTH;
1409
+ return isSoundLength ? { kind, ...common, maxLength } : void 0;
1410
+ }
1411
+ function withOptions2(common, options) {
1412
+ if (!Array.isArray(options)) return void 0;
1413
+ const values = options;
1414
+ const texts = values.filter(isOptionText);
1415
+ const isSoundList = texts.length === values.length && texts.length > 0 && texts.length <= MAX_OPTIONS && new Set(texts).size === texts.length;
1416
+ return isSoundList ? { kind: "choice", ...common, options: texts } : void 0;
1417
+ }
1418
+ function isRequestFieldKind(value) {
1419
+ return REQUEST_FIELD_KINDS.some((kind) => kind === value);
1420
+ }
1421
+ function invalidForm(reason) {
1422
+ return { status: "invalid", reason };
1423
+ }
1424
+ function isOptionText(value) {
1425
+ return isText(value) && value === value.trim() && !LINE_BREAK.test(value);
1426
+ }
1427
+
1328
1428
  // src/schema/record-pages.ts
1329
1429
  var RECORD_PLACEHOLDER = "[record]";
1330
1430
  var RECORD_PAGE_PATH = new RegExp(
@@ -1407,16 +1507,18 @@ function checkSiteDescription(input) {
1407
1507
  const initial = parseContent(schema.fields, input["initialContent"]);
1408
1508
  const pages = checkPages(schema.fields, input["pages"]);
1409
1509
  const recordPages2 = checkRecordPages(schema.fields, input["recordPages"]);
1510
+ const forms = checkForms(input["forms"]);
1410
1511
  const problems = [
1411
1512
  ...nameProblems,
1412
1513
  ...initialContentProblems(initial.issues),
1413
1514
  ...pages.problems,
1414
1515
  ...recordPages2.problems,
1415
1516
  ...placementProblems(pages.pages ?? [], recordPages2.pages ?? []),
1416
- ...addressProblems(schema.fields, initial.content, recordPages2.pages ?? [])
1517
+ ...addressProblems(schema.fields, initial.content, recordPages2.pages ?? []),
1518
+ ...forms.problems
1417
1519
  ];
1418
1520
  const isNamed = isSiteId(id) && isText(title);
1419
- const isWhole = pages.pages !== void 0 && recordPages2.pages !== void 0;
1521
+ const isWhole = pages.pages !== void 0 && recordPages2.pages !== void 0 && forms.forms !== void 0;
1420
1522
  if (!isNamed || !isWhole || problems.length > 0) {
1421
1523
  return invalid(problems);
1422
1524
  }
@@ -1426,7 +1528,8 @@ function checkSiteDescription(input) {
1426
1528
  schema: schema.fields,
1427
1529
  initialContent: initial.content,
1428
1530
  pages: pages.pages,
1429
- ...recordPages2.pages.length === 0 ? {} : { recordPages: recordPages2.pages }
1531
+ ...recordPages2.pages.length === 0 ? {} : { recordPages: recordPages2.pages },
1532
+ ...forms.forms.length === 0 ? {} : { forms: forms.forms }
1430
1533
  };
1431
1534
  return { status: "read", description };
1432
1535
  }
@@ -1520,6 +1623,27 @@ function readRecordPage(schema, input) {
1520
1623
  const isSearchTexts = typeof searchTexts === "string" && isSearchTextsGroup(field.item, searchTexts);
1521
1624
  return isSearchTexts ? { path, collection: collection2, searchTexts } : "invalid-search-texts";
1522
1625
  }
1626
+ function checkForms(input) {
1627
+ if (input === void 0) return { forms: [], problems: [] };
1628
+ if (!Array.isArray(input)) {
1629
+ return { forms: void 0, problems: [{ code: "invalid-forms" }] };
1630
+ }
1631
+ const entries = input;
1632
+ const readings = entries.map((entry) => readRequestForm(entry));
1633
+ const formProblems = readings.flatMap(
1634
+ (reading, index) => reading.status === "invalid" ? [{ code: "invalid-form", index, reason: reading.reason }] : []
1635
+ );
1636
+ const forms = readings.flatMap(
1637
+ (reading) => reading.status === "read" ? [reading.form] : []
1638
+ );
1639
+ const ids = forms.map((form) => form.id);
1640
+ const repeated = ids.filter((id, index) => ids.indexOf(id) !== index);
1641
+ const repeatProblems = [...new Set(repeated)].map(
1642
+ (id) => ({ code: "repeated-form-id", id })
1643
+ );
1644
+ const problems = [...formProblems, ...repeatProblems];
1645
+ return { forms: problems.length === 0 ? forms : void 0, problems };
1646
+ }
1523
1647
  function placementProblems(pages, recordPages2) {
1524
1648
  const misplaced = pages.filter(
1525
1649
  (page) => recordPageAt(recordPages2, page.path) !== void 0
@@ -1555,6 +1679,110 @@ function invalid(problems) {
1555
1679
  return { status: "invalid", problems };
1556
1680
  }
1557
1681
 
1682
+ // src/schema/request-values.ts
1683
+ var REQUEST_FORM_KEY = "seamlet-form";
1684
+ var REQUEST_PAGE_KEY = "seamlet-page";
1685
+ var REQUEST_CONSENT_KEY = "seamlet-consent";
1686
+ var REQUEST_TRAP_KEY = "seamlet-check";
1687
+ var CHECKED_VALUES = /* @__PURE__ */ new Set(["on", "true"]);
1688
+ var MAX_PAGE_LENGTH = 300;
1689
+ var PAGE_PATH2 = /^\/(?![/\\])[^\s\\]*$/u;
1690
+ var PHONE_SEPARATORS = /[\s().-]/gu;
1691
+ var PHONE_DIGITS = /^\+?\d{5,15}$/u;
1692
+ var EMAIL_ADDRESS2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
1693
+ var MAX_EMAIL_LENGTH = 254;
1694
+ var LINE_BREAKS = /\r\n?/gu;
1695
+ var ANY_LINE_BREAK = /[\r\n]+/gu;
1696
+ var REQUEST_ISSUE_TEXTS = {
1697
+ required: "\u0417\u0430\u043F\u043E\u043B\u043D\u0438\u0442\u0435 \u044D\u0442\u043E \u043F\u043E\u043B\u0435",
1698
+ "too-long": "\u0417\u0434\u0435\u0441\u044C \u0441\u043B\u0438\u0448\u043A\u043E\u043C \u0434\u043B\u0438\u043D\u043D\u044B\u0439 \u0442\u0435\u043A\u0441\u0442",
1699
+ "invalid-phone": "\u041F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 \u043D\u043E\u043C\u0435\u0440 \u0442\u0435\u043B\u0435\u0444\u043E\u043D\u0430",
1700
+ "invalid-email": "\u041F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u043F\u043E\u0447\u0442\u044B",
1701
+ "unknown-option": "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u043E\u0434\u0438\u043D \u0438\u0437 \u0432\u0430\u0440\u0438\u0430\u043D\u0442\u043E\u0432"
1702
+ };
1703
+ var CONSENT_REQUIRED_TEXT = "\u041E\u0442\u043C\u0435\u0442\u044C\u0442\u0435 \u0441\u043E\u0433\u043B\u0430\u0441\u0438\u0435 \u043D\u0430 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u043A\u0443 \u043F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u044C\u043D\u044B\u0445 \u0434\u0430\u043D\u043D\u044B\u0445";
1704
+ function readRequestValues(form, entries) {
1705
+ const readings = Object.entries(form.fields).map(
1706
+ ([name, field]) => readValue(name, field, entryOf(entries, name))
1707
+ );
1708
+ const issues = readings.filter(isIssue);
1709
+ if (issues.length > 0) return { status: "invalid", issues };
1710
+ const values = readings.filter(isFilledValue);
1711
+ return { status: "read", values };
1712
+ }
1713
+ function readRequestEntries(input) {
1714
+ if (!isJsonObject(input)) return {};
1715
+ const pairs = Object.entries(input).filter(
1716
+ (pair) => typeof pair[1] === "string"
1717
+ );
1718
+ return Object.fromEntries(pairs);
1719
+ }
1720
+ function isConsentGiven(entries) {
1721
+ return CHECKED_VALUES.has(entryOf(entries, REQUEST_CONSENT_KEY));
1722
+ }
1723
+ function isTrapFilled(entries) {
1724
+ return entryOf(entries, REQUEST_TRAP_KEY).trim() !== "";
1725
+ }
1726
+ function requestPageOf(entries) {
1727
+ const page = entryOf(entries, REQUEST_PAGE_KEY);
1728
+ const isPage = page.length <= MAX_PAGE_LENGTH && PAGE_PATH2.test(page);
1729
+ return isPage ? page : "/";
1730
+ }
1731
+ function isPhoneNumber(typed) {
1732
+ return PHONE_DIGITS.test(typed.replaceAll(PHONE_SEPARATORS, ""));
1733
+ }
1734
+ function isEmailAddress(typed) {
1735
+ return typed.length <= MAX_EMAIL_LENGTH && EMAIL_ADDRESS2.test(typed);
1736
+ }
1737
+ function entryOf(entries, name) {
1738
+ const entry = Object.hasOwn(entries, name) ? entries[name] : "";
1739
+ return typeof entry === "string" ? entry : "";
1740
+ }
1741
+ function readValue(name, field, typed) {
1742
+ const words = wordsOf(field, typed);
1743
+ if (words === "") {
1744
+ return field.required === true ? { field: name, code: "required" } : { field: name, label: field.label, value: "" };
1745
+ }
1746
+ const code = issueOf(field, words);
1747
+ return code === void 0 ? { field: name, label: field.label, value: words } : { field: name, code };
1748
+ }
1749
+ function wordsOf(field, typed) {
1750
+ const trimmed = typed.trim();
1751
+ return field.kind === "longText" ? trimmed.replaceAll(LINE_BREAKS, "\n") : trimmed.replaceAll(ANY_LINE_BREAK, " ");
1752
+ }
1753
+ function issueOf(field, words) {
1754
+ switch (field.kind) {
1755
+ case "text":
1756
+ case "longText":
1757
+ return countCharacters(words) > maxLengthOf(field) ? "too-long" : void 0;
1758
+ case "phone":
1759
+ return isPhoneNumber(words) ? void 0 : "invalid-phone";
1760
+ case "email":
1761
+ return isEmailAddress(words) ? void 0 : "invalid-email";
1762
+ case "choice":
1763
+ return field.options.includes(words) ? void 0 : "unknown-option";
1764
+ }
1765
+ }
1766
+ function isIssue(reading) {
1767
+ return "code" in reading;
1768
+ }
1769
+ function isFilledValue(reading) {
1770
+ return "value" in reading && reading.value !== "";
1771
+ }
1772
+
1773
+ // src/schema/request-addresses.ts
1774
+ function requestsPathOf(siteId) {
1775
+ return `/api/sites/${siteId}/requests`;
1776
+ }
1777
+ function legalPagePathOf(siteId, document) {
1778
+ return `/legal/${siteId}/${document}`;
1779
+ }
1780
+
1781
+ // src/schema/request-events.ts
1782
+ var REQUEST_SEND_EVENT = "seamlet:request-send";
1783
+ var REQUEST_SENT_EVENT = "seamlet:request-sent";
1784
+ var REQUEST_FAILED_EVENT = "seamlet:request-failed";
1785
+
1558
1786
  export {
1559
1787
  defineSchema,
1560
1788
  text,
@@ -1618,6 +1846,18 @@ export {
1618
1846
  SITE_CONFIG_FILE,
1619
1847
  isSiteId,
1620
1848
  readSiteConfig,
1849
+ DEFAULT_TEXT_LENGTH,
1850
+ DEFAULT_LONG_TEXT_LENGTH,
1851
+ MAX_REQUEST_FIELD_LENGTH,
1852
+ requestForm,
1853
+ requestText,
1854
+ requestLongText,
1855
+ requestPhone,
1856
+ requestEmail,
1857
+ requestChoice,
1858
+ readRequestForm,
1859
+ isFormId,
1860
+ maxLengthOf,
1621
1861
  RECORD_PLACEHOLDER,
1622
1862
  PAGE_PATH_SEPARATOR,
1623
1863
  recordPages,
@@ -1630,5 +1870,23 @@ export {
1630
1870
  HOME_PAGE_PATH,
1631
1871
  readSiteDescription,
1632
1872
  checkSiteDescription,
1633
- isSearchTextsGroup
1873
+ isSearchTextsGroup,
1874
+ REQUEST_FORM_KEY,
1875
+ REQUEST_PAGE_KEY,
1876
+ REQUEST_CONSENT_KEY,
1877
+ REQUEST_TRAP_KEY,
1878
+ REQUEST_ISSUE_TEXTS,
1879
+ CONSENT_REQUIRED_TEXT,
1880
+ readRequestValues,
1881
+ readRequestEntries,
1882
+ isConsentGiven,
1883
+ isTrapFilled,
1884
+ requestPageOf,
1885
+ isPhoneNumber,
1886
+ isEmailAddress,
1887
+ requestsPathOf,
1888
+ legalPagePathOf,
1889
+ REQUEST_SEND_EVENT,
1890
+ REQUEST_SENT_EVENT,
1891
+ REQUEST_FAILED_EVENT
1634
1892
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  pagePathOf
3
- } from "./chunk-HFPZVDST.js";
3
+ } from "./chunk-VQKAWFCQ.js";
4
4
  import {
5
5
  FIELD_PATH_SEPARATOR,
6
6
  collectionAt,
@@ -9,7 +9,7 @@ import {
9
9
  newRecord,
10
10
  recordAt,
11
11
  recordPageAt
12
- } from "./chunk-UL2C2IB2.js";
12
+ } from "./chunk-ENMUO442.js";
13
13
 
14
14
  // src/preview-requests.ts
15
15
  var MAX_DRAFT_BYTES = 1024 * 1024;
@@ -6,7 +6,7 @@ import {
6
6
  recordAt,
7
7
  recordPageAt,
8
8
  recordPagePath
9
- } from "./chunk-UL2C2IB2.js";
9
+ } from "./chunk-ENMUO442.js";
10
10
 
11
11
  // src/site-pages.ts
12
12
  function pagePathOf(rest) {
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  SITE_CONFIG_FILE,
7
7
  isSiteId,
8
8
  readSiteConfig
9
- } from "./chunk-UL2C2IB2.js";
9
+ } from "./chunk-ENMUO442.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import process from "process";