@seamlet/astro 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -164,7 +164,7 @@ A reference to a record removed since is kept, with the issue `missing-record`:
164
164
 
165
165
  ## What the page hides
166
166
 
167
- A field is edited where it is seen, so the editor opens what hides it when a person chooses it in the list of fields. A native `<details>` needs nothing: a click on its summary opens it in the editor too. A part the site hides and shows by itself (its own accordion, a step of a quiz, a dialog, a tab) gets the attribute `data-cms-reveal` (`REVEAL_ATTRIBUTE`). When a field inside it is chosen, the bridge sets `data-cms-revealed` on it and sends it the event `cms:reveal`, which bubbles; the site shows the part with CSS or with its own script:
167
+ A field is edited where it is seen, so the editor opens what hides it when a person chooses it in the list of fields. A native `<details>` needs nothing: a click on its summary opens it in the editor too. Neither do tabs, filters and toggles made of radio buttons and checkboxes and shown with CSS: a click on one or its label flips it in the editor, and the page keeps it flipped after every edit. A part the site hides and shows by itself (its own accordion, a step of a quiz, a dialog, a tab) gets the attribute `data-cms-reveal` (`REVEAL_ATTRIBUTE`). When a field inside it is chosen, the bridge sets `data-cms-revealed` on it and sends it the event `cms:reveal`, which bubbles; the site shows the part with CSS or with its own script:
168
168
 
169
169
  ```css
170
170
  [data-cms-reveal][data-cms-revealed] {
@@ -178,7 +178,7 @@ What was opened stays open while the page is rendered anew after every edit.
178
178
 
179
179
  - `/preview` and `/preview/<page>`: the preview the CMS's editor frames. `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.
180
180
  - The bridge on the preview: it tells the editor where the marked elements are and what a person does on the page, and types texts in place.
181
- - `/media/<site>/<file>`: the photos uploaded in the CMS, taken from it.
181
+ - `/media/<site>/<file>`: the photos uploaded in the CMS, taken from it on request; a static build copies every uploaded photo its built pages refer to next to them, so a static host serves them too.
182
182
 
183
183
  A site never renders content as raw HTML (`set:html`): the editor acts on what its preview says.
184
184
 
package/dist/bridge.js CHANGED
@@ -62,6 +62,7 @@ function asElement(value) {
62
62
  // src/bridge/page-reveals.ts
63
63
  var DETAILS_SELECTOR = "details";
64
64
  var REVEAL_SELECTOR = `[${REVEAL_ATTRIBUTE}]`;
65
+ var SWITCH_SELECTOR = 'input[type="radio"], input[type="checkbox"]';
65
66
  function revealAround(element) {
66
67
  for (let part = element; part !== null; ) {
67
68
  if (isDetails(part)) part.open = true;
@@ -78,14 +79,28 @@ function toggleDetailsAt(target, isOnField) {
78
79
  }
79
80
  if (!isOnField) details.open = false;
80
81
  }
82
+ function flipSwitchAt(target, isOnField) {
83
+ const control = switchAround(target);
84
+ if (control === void 0) return;
85
+ const isRadio = control.type === "radio";
86
+ const isFlipped = isRadio ? !control.checked : !isOnField;
87
+ if (!isFlipped) return;
88
+ control.checked = isRadio || !control.checked;
89
+ announceSwitch(control);
90
+ }
81
91
  function revealedPartsOf(document2) {
82
92
  const details = [...document2.querySelectorAll(DETAILS_SELECTOR)];
83
93
  const parts = [...document2.querySelectorAll(REVEAL_SELECTOR)];
94
+ const switches = [...document2.querySelectorAll(SWITCH_SELECTOR)];
84
95
  return {
85
96
  openDetails: indexesWhere(details, (part) => isDetails(part) && part.open),
86
97
  revealed: indexesWhere(
87
98
  parts,
88
99
  (part) => part.hasAttribute(REVEALED_ATTRIBUTE)
100
+ ),
101
+ flippedSwitches: indexesWhere(
102
+ switches,
103
+ (control) => isSwitch(control) && control.checked !== control.defaultChecked
89
104
  )
90
105
  };
91
106
  }
@@ -100,6 +115,26 @@ function restoreRevealedParts(document2, revealed) {
100
115
  const part = parts[index];
101
116
  if (part !== void 0) reveal(part);
102
117
  }
118
+ restoreSwitches(document2, revealed.flippedSwitches);
119
+ }
120
+ function restoreSwitches(document2, flipped) {
121
+ const switches = [...document2.querySelectorAll(SWITCH_SELECTOR)];
122
+ for (const index of flipped) {
123
+ const control = switches[index];
124
+ if (control === void 0 || !isSwitch(control)) continue;
125
+ control.checked = !control.defaultChecked;
126
+ announceSwitch(control);
127
+ }
128
+ }
129
+ function announceSwitch(control) {
130
+ control.dispatchEvent(new Event("input", { bubbles: true }));
131
+ control.dispatchEvent(new Event("change", { bubbles: true }));
132
+ }
133
+ function switchAround(target) {
134
+ if (!isElement(target)) return void 0;
135
+ const label = target.closest("label");
136
+ const control = isLabel(label) ? label.control : target;
137
+ return control !== null && isSwitch(control) ? control : void 0;
103
138
  }
104
139
  function reveal(part) {
105
140
  if (part.hasAttribute(REVEALED_ATTRIBUTE)) return;
@@ -120,6 +155,12 @@ function indexesWhere(elements, isPicked) {
120
155
  function isDetails(element) {
121
156
  return element.localName === "details";
122
157
  }
158
+ function isLabel(element) {
159
+ return element?.localName === "label";
160
+ }
161
+ function isSwitch(element) {
162
+ return element.matches(SWITCH_SELECTOR);
163
+ }
123
164
  function isElement(value) {
124
165
  return value !== null && "nodeType" in value && value.nodeType === Node.ELEMENT_NODE;
125
166
  }
@@ -147,7 +188,9 @@ function listenToPage(document2, listeners) {
147
188
  return;
148
189
  }
149
190
  const marked = markedElementAround(event.target);
150
- toggleDetailsAt(event.target, marked !== void 0);
191
+ const isOnField = marked !== void 0;
192
+ toggleDetailsAt(event.target, isOnField);
193
+ flipSwitchAt(event.target, isOnField);
151
194
  listeners.onClick(
152
195
  marked === void 0 ? void 0 : { ...marked, point: { x: event.clientX, y: event.clientY } }
153
196
  );
@@ -194,6 +237,8 @@ function listenToPage(document2, listeners) {
194
237
  ["scroll", listeners.onLayoutChange],
195
238
  // An opened or closed `<details>` moves what is below it; toggle does not bubble.
196
239
  ["toggle", listeners.onLayoutChange],
240
+ // A flipped switch shows and hides parts of the page.
241
+ ["change", listeners.onLayoutChange],
197
242
  // Images load after the page is shown and move what is below them; load does not bubble.
198
243
  ["load", listeners.onLayoutChange]
199
244
  ];
package/dist/index.js CHANGED
@@ -1,5 +1,53 @@
1
+ import {
2
+ isUploadedPhoto
3
+ } from "./chunk-P2OQOZBC.js";
4
+
1
5
  // src/index.ts
2
6
  import { fileURLToPath } from "url";
7
+
8
+ // src/uploaded-photos.ts
9
+ import { mkdir, readdir, readFile, writeFile } from "fs/promises";
10
+ import { dirname, join } from "path";
11
+ var PHOTO_ADDRESS = /\/media\/([a-z0-9-]+)\/([A-Za-z0-9_-]+\.webp)/gu;
12
+ var HTML_EXTENSION = ".html";
13
+ function uploadedPhotoAddresses(html) {
14
+ const addresses = [...html.matchAll(PHOTO_ADDRESS)].flatMap(
15
+ ([address, , fileName]) => isUploadedPhoto(fileName) ? [address] : []
16
+ );
17
+ return [...new Set(addresses)];
18
+ }
19
+ async function copyUploadedPhotos(options) {
20
+ const pages = await htmlFilesIn(options.directory);
21
+ const texts = await Promise.all(pages.map((page) => readFile(page, "utf8")));
22
+ const addresses = new Set(texts.flatMap(uploadedPhotoAddresses));
23
+ await Promise.all(
24
+ [...addresses].map(
25
+ (address) => copyPhoto(address, options.directory, options.cmsOrigin)
26
+ )
27
+ );
28
+ return addresses.size;
29
+ }
30
+ async function copyPhoto(address, directory, cmsOrigin) {
31
+ const url = new URL(address, cmsOrigin);
32
+ const response = await fetch(url);
33
+ if (!response.ok) {
34
+ throw new Error(
35
+ `Copying the photo ${url.href} into the build failed with status ${String(response.status)}`
36
+ );
37
+ }
38
+ const target = join(directory, address);
39
+ await mkdir(dirname(target), { recursive: true });
40
+ await writeFile(target, new Uint8Array(await response.arrayBuffer()));
41
+ }
42
+ async function htmlFilesIn(directory) {
43
+ const entries = await readdir(directory, {
44
+ recursive: true,
45
+ withFileTypes: true
46
+ });
47
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(HTML_EXTENSION)).map((entry) => join(entry.parentPath, entry.name));
48
+ }
49
+
50
+ // src/index.ts
3
51
  var CONFIG_MODULE = "virtual:seamlet/config";
4
52
  var SITE_MODULE = "virtual:seamlet/site";
5
53
  var RESOLVED_CONFIG_MODULE = `\0${CONFIG_MODULE}`;
@@ -41,6 +89,16 @@ function seamlet(options) {
41
89
  entrypoint: "@seamlet/astro/routes/media",
42
90
  prerender: false
43
91
  });
92
+ },
93
+ // A static host has no route to take the uploaded photos from the CMS on request: the photos
94
+ // the built pages refer to are copied next to them. Pages rendered on request take them
95
+ // through the route above.
96
+ "astro:build:done": async ({ dir, logger }) => {
97
+ const copied = await copyUploadedPhotos({
98
+ directory: fileURLToPath(dir),
99
+ cmsOrigin
100
+ });
101
+ if (copied > 0) logger.info(`copied ${String(copied)} uploaded photos`);
44
102
  }
45
103
  }
46
104
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamlet/astro",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Connects an Astro site to the Seamlet CMS: the schema of its content, the preview the editor frames, and its published content.",
5
5
  "license": "MIT",
6
6
  "type": "module",