@seamlet/astro 1.2.1 → 1.4.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0
4
+
5
+ - Documents: `file({ label })` holds a PDF the owner uploads in the CMS, `{ src, name }` (`FileValue`): a price list, a menu, a contract. An empty name of a file asks for its file (`missing-file-src`). A static build copies the uploaded documents its pages link to, as it copies the photos, and the route of uploads serves them on request; `isUploadedFile` names both.
6
+
7
+ A site with a file field needs a CMS that reads 1.4 (cms.seamlet.ru from its release that takes documents). Code with an exhaustive `switch` over `FieldKind` or over the issues of `parseContent` gets the new member to handle.
8
+
9
+ ## 1.3.0
10
+
11
+ - 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.
12
+ - `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`).
13
+ - 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.
14
+
15
+ 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.
16
+
3
17
  ## 1.2.1
4
18
 
5
19
  - `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
@@ -128,6 +128,7 @@ The site's pages take their published content with `site.loadPublishedContent()`
128
128
  | `time({ label })` | `HH:MM`, 00:00 to 23:59 | `null` |
129
129
  | `slug({ label })` | the address of a record's page, `latte` in `/menu/latte` (below) | `""` |
130
130
  | `image({ label })` | `{ src, alt }` | `null` |
131
+ | `file({ label })` | `{ src, name }`: a PDF uploaded in the CMS, a price list or a menu | `null` |
131
132
  | `link({ label })` | `{ text, href }`: a page of the site, another site, a phone or an email | `null` |
132
133
  | `url({ label })` | a web address without a text: a booking widget, a map | `null` |
133
134
  | `toggle({ label })` | `true` or `false` | `false` |
@@ -138,6 +139,16 @@ The site's pages take their published content with `site.loadPublishedContent()`
138
139
  | `group({ label, fields })` | named fields shown together | each field empty |
139
140
  | `collection({ label, item, itemTitle? })` | a list of records of the same fields | `[]` |
140
141
 
142
+ A file is a document the owner uploads in the CMS as a PDF. The site links to it, and a static build copies it next to its pages as it copies the uploaded photos:
143
+
144
+ ```astro
145
+ {content.priceList && (
146
+ <a href={safeHref(content.priceList.src)} download>
147
+ {content.priceList.name}
148
+ </a>
149
+ )}
150
+ ```
151
+
141
152
  Options are `{ value, label }`: the site reads `value`, the editor shows `label`, so a label can change without touching the site. The content has their exact type:
142
153
 
143
154
  ```ts
@@ -362,6 +373,71 @@ A field is edited where it is seen, so the editor opens what hides it when a per
362
373
 
363
374
  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
375
 
376
+ ## Requests
377
+
378
+ 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.
379
+
380
+ ```ts
381
+ // src/forms.ts
382
+ import {
383
+ requestChoice,
384
+ requestForm,
385
+ requestLongText,
386
+ requestPhone,
387
+ requestText,
388
+ } from "@seamlet/astro/schema";
389
+
390
+ export const estimateForm = requestForm({
391
+ id: "estimate",
392
+ title: "Рассчитать стоимость",
393
+ fields: {
394
+ name: requestText({ label: "Имя", required: true }),
395
+ phone: requestPhone({ label: "Телефон", required: true }),
396
+ size: requestChoice({ label: "Размер", options: ["6×4", "6×6"] }),
397
+ comment: requestLongText({ label: "Комментарий", maxLength: 1000 }),
398
+ },
399
+ });
400
+ ```
401
+
402
+ ```ts
403
+ // src/seamlet.ts
404
+ export default defineSeamletSite({
405
+ schema,
406
+ initialContent,
407
+ pages,
408
+ forms: [estimateForm],
409
+ });
410
+ ```
411
+
412
+ ```astro
413
+ ---
414
+ import RequestForm from "@seamlet/astro/components/RequestForm.astro";
415
+ import { estimateForm } from "../forms";
416
+ ---
417
+
418
+ <RequestForm form={estimateForm} submitLabel="Рассчитать" />
419
+ ```
420
+
421
+ 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.
422
+
423
+ `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.
424
+
425
+ 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.
426
+
427
+ The site's own code hears every step from events on the form element, which bubble:
428
+
429
+ - `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.
430
+ - `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.
431
+ - `seamlet:request-failed` (`REQUEST_FAILED_EVENT`), with `{ form, reason, issues }`; `reason` is `invalid`, `closed` (the business takes no requests yet), `too-many` or `unavailable`.
432
+
433
+ ```ts
434
+ document.addEventListener(REQUEST_SENT_EVENT, (event) => {
435
+ ym(COUNTER, "reachGoal", event.detail.form);
436
+ });
437
+ ```
438
+
439
+ 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.
440
+
365
441
  ## What the integration adds
366
442
 
367
443
  - `/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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as Fields } from './parse-content-Bnn6Wa-l.js';
2
- import { P as PageMessage, E as EditorMessage } from './messages-C2OjqFmy.js';
1
+ import { F as Fields } from './parse-content-CT2cUVL-.js';
2
+ import { P as PageMessage, E as EditorMessage } from './messages-Dwjthhrk.js';
3
3
 
4
4
  interface BridgeOptions {
5
5
  /** The origin of the CMS whose editor may frame the page, such as `https://cms.example.com`. */
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-BCJUH3ZD.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-TMN7KG5M.js";
32
32
 
33
33
  // src/bridge/typed-fields.ts
34
34
  function typedKindAt(schema, path) {
@@ -44,6 +44,7 @@ function typedKindAt(schema, path) {
44
44
  case "time":
45
45
  case "slug":
46
46
  case "image":
47
+ case "file":
47
48
  case "link":
48
49
  case "url":
49
50
  case "toggle":
@@ -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
+ };
@@ -6,7 +6,7 @@ import {
6
6
  readRichText,
7
7
  resolveFieldPath,
8
8
  safeHref
9
- } from "./chunk-UL2C2IB2.js";
9
+ } from "./chunk-TMN7KG5M.js";
10
10
 
11
11
  // src/bridge/editor-shortcuts.ts
12
12
  function shortcutOf(event) {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  pagePathOf
3
- } from "./chunk-HFPZVDST.js";
3
+ } from "./chunk-ULOKDC6D.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-TMN7KG5M.js";
13
13
 
14
14
  // src/preview-requests.ts
15
15
  var MAX_DRAFT_BYTES = 1024 * 1024;
@@ -17,6 +17,7 @@ var BAD_REQUEST = 400;
17
17
  var PAYLOAD_TOO_LARGE = 413;
18
18
  var UNSUPPORTED_MEDIA_TYPE = 415;
19
19
  var UPLOADED_PHOTO = /^[A-Za-z0-9_-]+\.webp$/u;
20
+ var UPLOADED_FILE = /^[A-Za-z0-9_-]+\.(?:webp|pdf)$/u;
20
21
  function previewHeadersOf(cmsOrigin) {
21
22
  return {
22
23
  "Content-Security-Policy": `frame-ancestors ${cmsOrigin}`,
@@ -57,6 +58,9 @@ function previewContentAt(description, content, rest) {
57
58
  function isUploadedPhoto(fileName) {
58
59
  return fileName !== void 0 && UPLOADED_PHOTO.test(fileName);
59
60
  }
61
+ function isUploadedFile(fileName) {
62
+ return fileName !== void 0 && UPLOADED_FILE.test(fileName);
63
+ }
60
64
  async function readLimited(request) {
61
65
  if (request.body === null) return "";
62
66
  const reader = request.body.getReader();
@@ -100,5 +104,6 @@ export {
100
104
  readDraft,
101
105
  isPageAddress,
102
106
  previewContentAt,
103
- isUploadedPhoto
107
+ isUploadedPhoto,
108
+ isUploadedFile
104
109
  };