@seamlet/astro 0.12.1 → 1.1.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 ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0
4
+
5
+ - Pages of records: `recordPages(schema, { collection, path: "/menu/[record]" })` gives every record of a collection a page, rendered by one component with `content`, `record` and `index`; `defineSeamletSite` takes them as `recordPages`, the site's description tells the CMS about them, and the preview shows them, a record the editor has just added too.
6
+ - `slug({ label })`: the address of a record's page, checked as an address (`invalid-slug`) and as one of a kind in its collection (`taken-slug`); `slugOf` spells it from Russian words. A record without a slug has its page at its id.
7
+ - `site.pageOf(rest, content)` and `site.staticPaths(content)` render every page of a site from one `[...page]` route, the pages of records too. `site.pageAt` renders the site's own pages only, and is deprecated.
8
+ - The preview answers an address of no page at once, without asking the CMS or reading the draft.
9
+
10
+ A site with a `slug` or pages of records needs a CMS that reads descriptions of 1.1 (cms.seamlet.ru from its release 1.1.0): an older one refuses the kind `slug`. Code with an exhaustive `switch` over `Field`, `ContentIssue`, `SchemaProblemReason` or `SiteDescriptionProblem` gets their new members to handle.
11
+
12
+ ## 1.0.0
13
+
14
+ The first stable version. From here the package follows semver: what a site writes against it keeps building until the next major version.
15
+
16
+ What is stable:
17
+
18
+ - `@seamlet/astro` — the integration `seamlet()`, with the site's id, title and CMS in `seamlet.json` (`CMS_ORIGIN` takes the place of `cms`).
19
+ - `@seamlet/astro/site` — `defineSeamletSite({ schema, initialContent, pages, photos })` with `pageAt`, `loadPublishedContent`, `ownPhotoAt` and `description`.
20
+ - `@seamlet/astro/schema` — the field kinds and their builders, `ContentOf`, `defaultContent`, `parseContent`, `editable`, `resolveFieldPath`, formatted text (`richTextOf`, `plainTextOf`, `linesOf`), `safeHref`, `imagePosition`, the presets, record ids, `readSiteConfig`, `readSiteDescription`, `checkSiteDescription` and `checkFields`.
21
+ - `@seamlet/astro/components/RichText.astro` and the routes the integration adds: the preview (`/preview`), the site's description (`/seamlet/site.json`), the photos taken from the CMS (`/media/<site>/<file>`) and the site's own photos for the editor (`/seamlet-photos/<address>`).
22
+ - `@seamlet/astro/protocol` — the messages between the bridge and the CMS's editor at `PROTOCOL_VERSION` 2. A change the other side cannot read raises it; the editor speaks with the bridges of earlier versions it lists.
23
+ - `npx @seamlet/astro init`.
24
+
25
+ Since 0.12.1 nothing changed in the code.
26
+
27
+ ## 0.12.1
28
+
29
+ - A field that became plain text keeps the words of a formatted text saved in it.
30
+
31
+ ## 0.12.0
32
+
33
+ - `safeHref`: an address no link may have never reaches markup; `RichText` and the bridge use it.
34
+ - A site the CMS would refuse stops its build with every reason (`checkSiteDescription`, `checkFields`); `readFields` refuses names with a dot or of digits, options of the wrong kind, references to a missing collection and an `itemTitle` that is no text or link.
35
+ - Requests to the CMS are bounded in time and number; `/media` answers 502 when the CMS does not.
36
+ - A draft rendered while a text is typed or the page pressed is shown once that is over.
37
+ - `init` starts a site with a title of any length and writes its modules all or none.
package/README.md CHANGED
@@ -67,7 +67,7 @@ npx astro add @seamlet/astro node # the integration, and an ad
67
67
  <h1 {...edit("title")}>{content.title}</h1>
68
68
  ```
69
69
 
70
- 4. Describe the site for the CMS in `src/seamlet.ts`: its schema, the content it starts with, and its pages, each with the name the editor gives it and the component that renders it (it takes the content as its `content` prop). The home page `/` comes first; `searchTexts` names where the content holds a page's title and description for search engines, a group of a `title` and a `description` text (`seoFields`).
70
+ 4. Describe the site for the CMS in `src/seamlet.ts`: its schema, the content it starts with, and its pages, each with the name the editor gives it and the component that renders it (it takes the content as its `content` prop), and the pages of records if it has them (see «Pages of records»). The home page `/` comes first; `searchTexts` names where the content holds a page's title and description for search engines, a group of a `title` and a `description` text (`seoFields`).
71
71
 
72
72
  ```ts
73
73
  // src/seamlet.ts
@@ -98,6 +98,23 @@ npx astro add @seamlet/astro node # the integration, and an ad
98
98
  });
99
99
  ```
100
100
 
101
+ A site renders all its pages, the pages of records too (below), from one route `src/pages/[...page].astro`: `site.staticPaths(content)` lists every page for a static build, and `site.pageOf(rest, content)` finds the one asked for on request; each gives the component and the props to render it with.
102
+
103
+ ```astro
104
+ ---
105
+ // src/pages/[...page].astro, built as static pages
106
+ import site from "../seamlet";
107
+
108
+ export async function getStaticPaths() {
109
+ return site.staticPaths(await site.loadPublishedContent());
110
+ }
111
+
112
+ const { component: Page, props } = Astro.props;
113
+ ---
114
+
115
+ <Page {...props} />
116
+ ```
117
+
101
118
  The site's pages take their published content with `site.loadPublishedContent()`: at build time for a static site, on request otherwise. Until the first publication, and while the CMS does not know the site yet (a site connected from its repository is built before that), it is the site's initial content; any other refusal of the CMS stops the build. A module that needs the content without importing every page takes it with `loadPublishedContent(schema)` of `@seamlet/astro/site`.
102
119
 
103
120
  ## Fields
@@ -109,6 +126,7 @@ The site's pages take their published content with `site.loadPublishedContent()`
109
126
  | `number({ label, min?, max? })` | a number | `null` |
110
127
  | `date({ label })` | `YYYY-MM-DD` | `null` |
111
128
  | `time({ label })` | `HH:MM`, 00:00 to 23:59 | `null` |
129
+ | `slug({ label })` | the address of a record's page, `latte` in `/menu/latte` (below) | `""` |
112
130
  | `image({ label })` | `{ src, alt }` | `null` |
113
131
  | `link({ label })` | `{ text, href }`: a page of the site, another site, a phone or an email | `null` |
114
132
  | `url({ label })` | a web address without a text: a booking widget, a map | `null` |
@@ -239,6 +257,70 @@ const schema = defineSchema({
239
257
 
240
258
  A reference to a record removed since is kept, with the issue `missing-record`: the content is not saved until the person picks another one or clears it.
241
259
 
260
+ ## Pages of records
261
+
262
+ A collection can have a page for each of its records: a product, a dish, an article. The site writes one component, and the owner who adds a record in the CMS gets its page, which the CMS's editor opens and edits like any other page (a CMS that reads descriptions of version 1.1, as cms.seamlet.ru does from its release 1.1.0). `recordPages` names the collection and the address of its pages, `[record]` standing for the record:
263
+
264
+ ```ts
265
+ // src/schema.ts
266
+ import { recordPages, slug } from "@seamlet/astro/schema";
267
+
268
+ export const schema = defineSchema({
269
+ menu: collection({
270
+ label: "Меню",
271
+ itemTitle: "name",
272
+ item: {
273
+ name: text({ label: "Название" }),
274
+ price: number({ label: "Цена, ₽", min: 0 }),
275
+ slug: slug({ label: "Адрес страницы" }),
276
+ },
277
+ }),
278
+ });
279
+
280
+ export const drinkPages = recordPages(schema, {
281
+ collection: "menu",
282
+ path: "/menu/[record]",
283
+ });
284
+ ```
285
+
286
+ ```ts
287
+ // src/seamlet.ts
288
+ export default defineSeamletSite({
289
+ schema,
290
+ initialContent,
291
+ pages: {
292
+ "/": { title: "Главная", component: HomePage },
293
+ "/menu": { title: "Меню", component: MenuPage },
294
+ },
295
+ recordPages: [{ pages: drinkPages, component: DrinkPage }],
296
+ });
297
+ ```
298
+
299
+ The component takes the content, the record and its place in its collection as `content`, `record` and `index`, so its markers point at the record's fields:
300
+
301
+ ```astro
302
+ ---
303
+ // src/components/DrinkPage.astro
304
+ import { editable } from "@seamlet/astro/schema";
305
+ import type { Content } from "../schema";
306
+
307
+ interface Props {
308
+ content: Content;
309
+ record: Content["menu"][number];
310
+ index: number;
311
+ }
312
+
313
+ const { record, index } = Astro.props;
314
+ const edit = editable<Content>();
315
+ ---
316
+
317
+ <h1 {...edit("menu", index, "name")}>{record.name}</h1>
318
+ ```
319
+
320
+ `drinkPages.pathOf(drink)` gives the address of a record's page for a link to it: `/menu/latte`. The address is the record's `slug` when it has one, a field the owner fills in (lower-case Latin letters, digits and hyphens; the CMS spells it from the record's name), and its id otherwise, so a record has its page from the moment it is added, and keeps it when it is renamed or moved. A slug another record of the collection has, as its slug or as its id, is the issue `taken-slug`; a slug with other characters, `invalid-slug`. A collection is `catalog.products` through groups, its pages have one segment more than a page of the site (`/catalog/[record]`, never `/[record]`), a record has one page, and no page of the site may stand among the pages of records (`/menu/specials` beside `/menu/[record]`). `searchTexts` names a group of the record with a `title` and a `description` text for its page.
321
+
322
+ Every record of the initial content needs an address of its own, a slug or an id, since its page is built before the CMS saves anything. The preview shows a page of a record the owner has just added, so the editor opens it before it is saved or published: under the pages of records, an address that could be an id and names no record shows a blank record, which the editor's draft replaces at once.
323
+
242
324
  ## What the page hides
243
325
 
244
326
  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:
@@ -253,7 +335,7 @@ What was opened stays open while the page is rendered anew after every edit: a p
253
335
 
254
336
  ## What the integration adds
255
337
 
256
- - `/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; its refusals (a page the site does not have, a draft that is not JSON or is over 1 MB) say the same.
338
+ - `/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). `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.
257
339
  - 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, formatted ones too.
258
340
  - `/seamlet/site.json`: what the site tells the CMS about itself (`SITE_DESCRIPTION_PATH`): its id and name from `seamlet.json`, its schema, the content it starts with and its pages. The CMS reads it with `readSiteDescription` whenever the site is opened in its editor.
259
341
  - `/seamlet-photos/<address>`: the site's own photo at an address its content gives it, found by `photos` of `defineSeamletSite`, for the CMS's editor.
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as Fields } from './parse-content-Eg8yRq_t.js';
2
- import { P as PageMessage, E as EditorMessage } from './messages-CvZhLRSE.js';
1
+ import { F as Fields } from './parse-content-IOJxgKLB.js';
2
+ import { P as PageMessage, E as EditorMessage } from './messages-Bqgs_mic.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-SSECNMAD.js";
21
+ } from "./chunk-PLNPM2D3.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-ZS32CW5F.js";
31
+ } from "./chunk-I3HOJE3S.js";
32
32
 
33
33
  // src/bridge/typed-fields.ts
34
34
  function typedKindAt(schema, path) {
@@ -42,6 +42,7 @@ function typedKindAt(schema, path) {
42
42
  case "number":
43
43
  case "date":
44
44
  case "time":
45
+ case "slug":
45
46
  case "image":
46
47
  case "link":
47
48
  case "url":
@@ -0,0 +1,67 @@
1
+ import {
2
+ HOME_PAGE_PATH,
3
+ PAGE_PATH_SEPARATOR,
4
+ collectionAt,
5
+ recordAddressOf,
6
+ recordAt,
7
+ recordPageAt,
8
+ recordPagePath
9
+ } from "./chunk-I3HOJE3S.js";
10
+
11
+ // src/site-pages.ts
12
+ function pagePathOf(rest) {
13
+ return rest === void 0 ? HOME_PAGE_PATH : `${PAGE_PATH_SEPARATOR}${rest}`;
14
+ }
15
+ function pageParameterOf(path) {
16
+ return path === HOME_PAGE_PATH ? void 0 : path.slice(PAGE_PATH_SEPARATOR.length);
17
+ }
18
+ function renderedPageAt(site, path, content) {
19
+ const page = Object.hasOwn(site.pages, path) ? site.pages[path] : void 0;
20
+ if (page !== void 0) {
21
+ return { component: page.component, props: { content } };
22
+ }
23
+ return renderedRecordPageAt(site, path, content);
24
+ }
25
+ function staticPagesOf(site, content) {
26
+ const ownPages = Object.entries(site.pages).map(
27
+ ([path, page]) => staticPage(path, { component: page.component, props: { content } })
28
+ );
29
+ const recordPages = site.recordPages.flatMap(
30
+ (recordPage) => staticRecordPagesOf(site.schema, recordPage, content)
31
+ );
32
+ return [...ownPages, ...recordPages];
33
+ }
34
+ function renderedRecordPageAt(site, path, content) {
35
+ const described = site.recordPages.map((recordPage) => ({
36
+ ...recordPage.pages,
37
+ component: recordPage.component
38
+ }));
39
+ const found = recordPageAt(described, path);
40
+ if (found === void 0) return void 0;
41
+ const records = collectionAt(site.schema, content, found.page.collection);
42
+ if (records === void 0) return void 0;
43
+ const placed = recordAt(records.collection, records.records, found.address);
44
+ if (placed === void 0) return void 0;
45
+ const props = { content, record: placed.record, index: placed.index };
46
+ return { component: found.page.component, props };
47
+ }
48
+ function staticRecordPagesOf(schema, recordPage, content) {
49
+ const found = collectionAt(schema, content, recordPage.pages.collection);
50
+ if (found === void 0) return [];
51
+ return found.records.flatMap((record, index) => {
52
+ const address = recordAddressOf(found.collection, record);
53
+ if (address === void 0) return [];
54
+ const path = recordPagePath(recordPage.pages.path, address);
55
+ const props = { content, record, index };
56
+ return [staticPage(path, { component: recordPage.component, props })];
57
+ });
58
+ }
59
+ function staticPage(path, page) {
60
+ return { params: { page: pageParameterOf(path) }, props: page };
61
+ }
62
+
63
+ export {
64
+ pagePathOf,
65
+ renderedPageAt,
66
+ staticPagesOf
67
+ };