@seamlet/astro 0.8.0 → 0.9.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/README.md CHANGED
@@ -46,21 +46,28 @@ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https
46
46
  <h1 {...edit("title")}>{content.title}</h1>
47
47
  ```
48
48
 
49
- 3. Name the site's address in the CMS and its pages: components that take the content as their `content` prop.
49
+ 3. Describe the site for the CMS: its address and name there, 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`).
50
50
 
51
51
  ```ts
52
52
  // src/seamlet.ts
53
53
  import { defineSeamletSite } from "@seamlet/astro/site";
54
54
  import HomePage from "./components/HomePage.astro";
55
+ import { initialContent } from "./initial-content";
55
56
  import { schema } from "./schema";
56
57
 
57
58
  export default defineSeamletSite({
58
59
  id: "zerno",
60
+ title: "Кофейня «Зерно»",
59
61
  schema,
60
- pages: { "/": HomePage },
62
+ initialContent,
63
+ pages: {
64
+ "/": { title: "Главная", component: HomePage, searchTexts: "seo.home" },
65
+ },
61
66
  });
62
67
  ```
63
68
 
69
+ The CMS takes all of it from the site itself, so connecting a site, or changing its schema, needs no change of the CMS.
70
+
64
71
  4. Add the integration:
65
72
 
66
73
  ```js
@@ -74,7 +81,7 @@ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https
74
81
  });
75
82
  ```
76
83
 
77
- The site's pages take their published content with `site.loadPublishedContent()`: at build time for a static site, on request otherwise.
84
+ The site's pages take their published content with `site.loadPublishedContent()`: at build time for a static site, on request otherwise. A module that needs the content without importing every page takes it with `loadPublishedContent(id, schema)` of `@seamlet/astro/site`.
78
85
 
79
86
  ## Fields
80
87
 
@@ -136,8 +143,10 @@ const MENU_FILES = import.meta.glob<{ default: ImageMetadata }>(
136
143
 
137
144
  export default defineSeamletSite({
138
145
  id: "zerno",
146
+ title: "Кофейня «Зерно»",
139
147
  schema,
140
- pages: { "/": HomePage },
148
+ initialContent,
149
+ pages: { "/": { title: "Главная", component: HomePage } },
141
150
  photos: (src) => MENU_FILES[src.replace("/menu/", "./assets/menu/")]?.default,
142
151
  });
143
152
  ```
@@ -219,9 +228,10 @@ What was opened stays open while the page is rendered anew after every edit.
219
228
 
220
229
  - `/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.
221
230
  - 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.
231
+ - `/seamlet/site.json`: what the site tells the CMS about itself (`SITE_DESCRIPTION_PATH`): its name, its schema, the content it starts with and its pages. The CMS reads it with `readSiteDescription` whenever the site is opened in its editor.
222
232
  - `/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.
223
233
  - `/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.
224
234
 
225
235
  A site never renders content as raw HTML (`set:html`): the editor acts on what its preview says.
226
236
 
227
- The preview's address goes on the site's settings page in the CMS: `https://<site>/preview`.
237
+ To connect the site, an administrator of the CMS gives it the preview's address, `https://<site>/preview`; the CMS takes the site's description from its origin.
package/dist/bridge.js CHANGED
@@ -18,15 +18,42 @@ import {
18
18
  shortcutOf,
19
19
  toggleList,
20
20
  toggleMark
21
- } from "./chunk-27XIZEVS.js";
21
+ } from "./chunk-UBBVOSON.js";
22
22
  import {
23
23
  FIELD_PATH_ATTRIBUTE,
24
24
  REVEALED_ATTRIBUTE,
25
25
  REVEAL_ATTRIBUTE,
26
26
  REVEAL_EVENT,
27
27
  asOneParagraph,
28
- readRichText
29
- } from "./chunk-ZUJEM3P7.js";
28
+ readRichText,
29
+ resolveFieldPath
30
+ } from "./chunk-NFUGV7JJ.js";
31
+
32
+ // src/bridge/typed-fields.ts
33
+ function typedKindAt(schema, path) {
34
+ const target = resolveFieldPath(schema, path);
35
+ if (target?.kind !== "field") return void 0;
36
+ switch (target.field.kind) {
37
+ case "text":
38
+ return "plain";
39
+ case "richText":
40
+ return "rich";
41
+ case "number":
42
+ case "date":
43
+ case "time":
44
+ case "image":
45
+ case "link":
46
+ case "url":
47
+ case "toggle":
48
+ case "choice":
49
+ case "choices":
50
+ case "reference":
51
+ case "references":
52
+ case "group":
53
+ case "collection":
54
+ return void 0;
55
+ }
56
+ }
30
57
 
31
58
  // src/bridge/page-markers.ts
32
59
  var MARKER_SELECTOR = `[${FIELD_PATH_ATTRIBUTE}]`;
@@ -524,7 +551,7 @@ function caretRangeAt(document2, point) {
524
551
  }
525
552
 
526
553
  // src/bridge/rich-text-editing.ts
527
- var EDITABLE = "true";
554
+ var EDITABLE_AS_RICH_TEXT = "true";
528
555
  var PASTED_LINE_BREAKS = /\s*\n\s*/gu;
529
556
  var SPACE = " ";
530
557
  function canEditRichTextInPlace(element, value, isInline) {
@@ -636,7 +663,7 @@ function startRichTextEditing(element, options) {
636
663
  options.onSelect(void 0);
637
664
  options.onEnd(outcome);
638
665
  };
639
- element.setAttribute("contenteditable", EDITABLE);
666
+ element.setAttribute("contenteditable", EDITABLE_AS_RICH_TEXT);
640
667
  element.addEventListener("input", reportInput);
641
668
  element.addEventListener("keydown", pressKey);
642
669
  element.addEventListener("paste", pastePlainText);
@@ -708,9 +735,35 @@ function formatShortcutOf(event) {
708
735
  }
709
736
 
710
737
  // src/bridge/page-typing.ts
738
+ var PREPARED_TYPING_TIMEOUT_MS = 3e3;
739
+ var EDITABLE_VALUES = {
740
+ plain: EDITABLE_AS_PLAIN_TEXT,
741
+ rich: EDITABLE_AS_RICH_TEXT
742
+ };
711
743
  function createPageTyping(options) {
712
744
  let stopEditing;
713
745
  let richEditing;
746
+ let prepared;
747
+ const takePrepared = (isTyped) => {
748
+ if (prepared === void 0) return;
749
+ clearTimeout(prepared.timer);
750
+ if (!isTyped && stopEditing === void 0) {
751
+ prepared.element.blur();
752
+ prepared.element.removeAttribute("contenteditable");
753
+ }
754
+ prepared = void 0;
755
+ };
756
+ const prepare = (element, kind) => {
757
+ const isTypedAlready = element.hasAttribute("contenteditable");
758
+ if (isTypedAlready) return;
759
+ takePrepared(false);
760
+ element.setAttribute("contenteditable", EDITABLE_VALUES[kind]);
761
+ element.focus({ preventScroll: true });
762
+ const timer = setTimeout(() => {
763
+ takePrepared(false);
764
+ }, PREPARED_TYPING_TIMEOUT_MS);
765
+ prepared = { element, timer };
766
+ };
714
767
  const stop = () => {
715
768
  stopEditing?.();
716
769
  };
@@ -725,9 +778,11 @@ function createPageTyping(options) {
725
778
  const element = findMarkedElement(options.document, path);
726
779
  const canType = element !== void 0 && canEditTextInPlace(element, request.text);
727
780
  if (!canType) {
781
+ takePrepared(false);
728
782
  options.send({ type: "typing-refused", path });
729
783
  return;
730
784
  }
785
+ takePrepared(true);
731
786
  options.send({ type: "typing-started", path });
732
787
  stopEditing = startInlineTextEditing(element, {
733
788
  isSingleLine: request.isSingleLine,
@@ -745,9 +800,11 @@ function createPageTyping(options) {
745
800
  const element = findMarkedElement(options.document, path);
746
801
  const canType = element !== void 0 && canEditRichTextInPlace(element, request.text, isInline);
747
802
  if (!canType) {
803
+ takePrepared(false);
748
804
  options.send({ type: "typing-refused", path });
749
805
  return;
750
806
  }
807
+ takePrepared(true);
751
808
  options.send({ type: "typing-started", path });
752
809
  const editing = startRichTextEditing(element, {
753
810
  isInline,
@@ -765,6 +822,7 @@ function createPageTyping(options) {
765
822
  placeCaretAt(element, request.point);
766
823
  };
767
824
  return {
825
+ prepare,
768
826
  isTyping: () => stopEditing !== void 0,
769
827
  start,
770
828
  startRich,
@@ -832,6 +890,10 @@ function connectPage(document2, schema, send) {
832
890
  send({ type: "hover", path });
833
891
  },
834
892
  onClick: (pointer) => {
893
+ const kind = pointer === void 0 ? void 0 : typedKindAt(schema, pointer.path);
894
+ if (pointer !== void 0 && kind !== void 0) {
895
+ typing.prepare(pointer.element, kind);
896
+ }
835
897
  const target = pointer === void 0 ? void 0 : { path: pointer.path, point: pointer.point };
836
898
  send({ type: "click", target });
837
899
  },
@@ -786,6 +786,185 @@ function openingHoursField(options = {}) {
786
786
  });
787
787
  }
788
788
 
789
+ // src/schema/read-fields.ts
790
+ function readFields(input) {
791
+ if (!isRecord2(input)) return void 0;
792
+ const entries = Object.entries(input).map(
793
+ ([name, value]) => [name, readField(value)]
794
+ );
795
+ const readEntries = entries.flatMap(
796
+ ([name, field]) => field === void 0 ? [] : [[name, field]]
797
+ );
798
+ const isEveryFieldRead = readEntries.length === entries.length;
799
+ return isEveryFieldRead ? Object.fromEntries(readEntries) : void 0;
800
+ }
801
+ function readField(input) {
802
+ if (!isRecord2(input)) return void 0;
803
+ const label = input["label"];
804
+ if (typeof label !== "string") return void 0;
805
+ const kind = input["kind"];
806
+ return isFieldKind(kind) ? readFieldOfKind(kind, label, input) : void 0;
807
+ }
808
+ function readFieldOfKind(kind, label, input) {
809
+ switch (kind) {
810
+ case "text":
811
+ return {
812
+ kind,
813
+ label,
814
+ ...optional("multiline", input["multiline"], isBoolean),
815
+ ...optional("maxLength", input["maxLength"], isCount)
816
+ };
817
+ case "richText":
818
+ return {
819
+ kind,
820
+ label,
821
+ ...optional("inline", input["inline"], isBoolean),
822
+ ...optional("maxLength", input["maxLength"], isCount)
823
+ };
824
+ case "number":
825
+ return {
826
+ kind,
827
+ label,
828
+ ...optional("min", input["min"], isNumber),
829
+ ...optional("max", input["max"], isNumber)
830
+ };
831
+ case "date":
832
+ case "image":
833
+ case "link":
834
+ case "toggle":
835
+ case "url":
836
+ case "time":
837
+ return { kind, label };
838
+ case "choice":
839
+ case "choices":
840
+ return readChoiceField(kind, label, input["options"]);
841
+ case "reference":
842
+ case "references":
843
+ return readReferenceField(kind, label, input["collection"]);
844
+ case "group":
845
+ return readGroupField(label, input["fields"]);
846
+ case "collection":
847
+ return readCollectionField(label, input);
848
+ }
849
+ }
850
+ function readChoiceField(kind, label, input) {
851
+ if (!Array.isArray(input)) return void 0;
852
+ const options = input;
853
+ if (!options.every(isChoiceOption)) return void 0;
854
+ const read = options.map(({ value, label: optionLabel }) => ({
855
+ value,
856
+ label: optionLabel
857
+ }));
858
+ return { kind, label, options: read };
859
+ }
860
+ function readReferenceField(kind, label, collection2) {
861
+ return typeof collection2 === "string" ? { kind, label, collection: collection2 } : void 0;
862
+ }
863
+ function readGroupField(label, input) {
864
+ const fields = readFields(input);
865
+ return fields === void 0 ? void 0 : { kind: "group", label, fields };
866
+ }
867
+ function readCollectionField(label, input) {
868
+ const item = readFields(input["item"]);
869
+ if (item === void 0 || "id" in item) return void 0;
870
+ const itemTitle = input["itemTitle"];
871
+ const isTitleKnown = typeof itemTitle === "string" && itemTitle in item;
872
+ if (itemTitle !== void 0 && !isTitleKnown) return void 0;
873
+ return {
874
+ kind: "collection",
875
+ label,
876
+ item,
877
+ ...isTitleKnown ? { itemTitle } : {}
878
+ };
879
+ }
880
+ function optional(key, value, isOfKind) {
881
+ if (!isOfKind(value)) return {};
882
+ return { [key]: value };
883
+ }
884
+ var FIELD_KINDS = /* @__PURE__ */ new Set([
885
+ "text",
886
+ "richText",
887
+ "number",
888
+ "date",
889
+ "image",
890
+ "link",
891
+ "toggle",
892
+ "choice",
893
+ "choices",
894
+ "url",
895
+ "time",
896
+ "reference",
897
+ "references",
898
+ "group",
899
+ "collection"
900
+ ]);
901
+ function isFieldKind(value) {
902
+ return typeof value === "string" && FIELD_KINDS.has(value);
903
+ }
904
+ function isChoiceOption(value) {
905
+ return isRecord2(value) && typeof value["value"] === "string" && typeof value["label"] === "string";
906
+ }
907
+ function isRecord2(value) {
908
+ return typeof value === "object" && value !== null && !Array.isArray(value);
909
+ }
910
+ function isBoolean(value) {
911
+ return typeof value === "boolean";
912
+ }
913
+ function isNumber(value) {
914
+ return typeof value === "number" && Number.isFinite(value);
915
+ }
916
+ function isCount(value) {
917
+ return isNumber(value) && Number.isInteger(value) && value >= 0;
918
+ }
919
+
920
+ // src/schema/site-description.ts
921
+ var SITE_DESCRIPTION_PATH = "/seamlet/site.json";
922
+ var SITE_ID = /^[a-z0-9]+(-[a-z0-9]+)*$/u;
923
+ var PAGE_PATH = /^(\/[a-z0-9][a-z0-9-]*)+$/u;
924
+ var HOME_PAGE_PATH = "/";
925
+ function readSiteDescription(input) {
926
+ if (!isRecord3(input)) return void 0;
927
+ const { id, title } = input;
928
+ const schema = readFields(input["schema"]);
929
+ const isNamed = typeof id === "string" && SITE_ID.test(id) && isText(title);
930
+ if (!isNamed || schema === void 0) return void 0;
931
+ const initial = parseContent(schema, input["initialContent"]);
932
+ const pages = readPages(schema, input["pages"]);
933
+ if (initial.issues.length > 0 || pages === void 0) return void 0;
934
+ return { id, title, schema, initialContent: initial.content, pages };
935
+ }
936
+ function isSearchTextsGroup(schema, path) {
937
+ const target = resolveFieldPath(schema, path);
938
+ if (target?.kind !== "field" || target.field.kind !== "group") return false;
939
+ const { title, description } = target.field.fields;
940
+ return title?.kind === "text" && description?.kind === "text";
941
+ }
942
+ function readPages(schema, input) {
943
+ if (!Array.isArray(input)) return void 0;
944
+ const entries = input;
945
+ const pages = entries.map((entry) => readPage(schema, entry));
946
+ const read = pages.flatMap((page) => page === void 0 ? [] : [page]);
947
+ const [first, ...rest] = read;
948
+ const paths = new Set(read.map((page) => page.path));
949
+ const isSound = read.length === pages.length && first?.path === HOME_PAGE_PATH && paths.size === read.length;
950
+ return isSound ? [first, ...rest] : void 0;
951
+ }
952
+ function readPage(schema, input) {
953
+ if (!isRecord3(input)) return void 0;
954
+ const { path, title, searchTexts } = input;
955
+ const isPath = typeof path === "string" && (path === HOME_PAGE_PATH || PAGE_PATH.test(path));
956
+ if (!isPath || !isText(title)) return void 0;
957
+ if (searchTexts === void 0) return { path, title };
958
+ const isSearchTexts = typeof searchTexts === "string" && isSearchTextsGroup(schema, searchTexts);
959
+ return isSearchTexts ? { path, title, searchTexts } : void 0;
960
+ }
961
+ function isRecord3(value) {
962
+ return typeof value === "object" && value !== null && !Array.isArray(value);
963
+ }
964
+ function isText(value) {
965
+ return typeof value === "string" && value.trim() !== "";
966
+ }
967
+
789
968
  export {
790
969
  defineSchema,
791
970
  text,
@@ -831,5 +1010,9 @@ export {
831
1010
  seoFields,
832
1011
  contactsFields,
833
1012
  WEEKDAYS,
834
- openingHoursField
1013
+ openingHoursField,
1014
+ readFields,
1015
+ SITE_DESCRIPTION_PATH,
1016
+ readSiteDescription,
1017
+ isSearchTextsGroup
835
1018
  };
@@ -3,7 +3,7 @@ import {
3
3
  normalizedLine,
4
4
  readRichText,
5
5
  resolveFieldPath
6
- } from "./chunk-ZUJEM3P7.js";
6
+ } from "./chunk-NFUGV7JJ.js";
7
7
 
8
8
  // src/bridge/editor-shortcuts.ts
9
9
  function shortcutOf(event) {
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import {
2
2
  OWN_PHOTOS_PATH
3
3
  } from "./chunk-3BS5CE26.js";
4
+ import {
5
+ SITE_DESCRIPTION_PATH
6
+ } from "./chunk-NFUGV7JJ.js";
4
7
  import {
5
8
  isUploadedPhoto
6
9
  } from "./chunk-P2OQOZBC.js";
@@ -92,6 +95,11 @@ function seamlet(options) {
92
95
  entrypoint: "@seamlet/astro/routes/media",
93
96
  prerender: false
94
97
  });
98
+ injectRoute({
99
+ pattern: SITE_DESCRIPTION_PATH,
100
+ entrypoint: "@seamlet/astro/routes/site-description",
101
+ prerender: false
102
+ });
95
103
  injectRoute({
96
104
  pattern: `${OWN_PHOTOS_PATH}/[...path]`,
97
105
  entrypoint: "@seamlet/astro/routes/own-photos",
package/dist/protocol.js CHANGED
@@ -22,8 +22,8 @@ import {
22
22
  shortcutOf,
23
23
  toggleList,
24
24
  toggleMark
25
- } from "./chunk-27XIZEVS.js";
26
- import "./chunk-ZUJEM3P7.js";
25
+ } from "./chunk-UBBVOSON.js";
26
+ import "./chunk-NFUGV7JJ.js";
27
27
  export {
28
28
  OWN_PHOTOS_PATH,
29
29
  PROTOCOL_VERSION,
@@ -0,0 +1,5 @@
1
+ import { APIRoute } from 'astro';
2
+
3
+ declare const GET: APIRoute;
4
+
5
+ export { GET };
@@ -0,0 +1,8 @@
1
+ // src/routes/site-description.ts
2
+ import site from "virtual:seamlet/site";
3
+ var GET = () => Response.json(site.description, {
4
+ headers: { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" }
5
+ });
6
+ export {
7
+ GET
8
+ };
package/dist/schema.d.ts CHANGED
@@ -2,6 +2,7 @@ import { b as ImageValue, L as LinkValue, c as Field, d as CollectionField, F as
2
2
  export { i as ChoiceField, j as ChoiceOption, D as DateField, k as FieldKind, l as FieldValue, m as ImageFocus, N as NumberField, O as OptionValue, n as RecordOf, o as ReferenceField, p as ReferencesField, q as RichBlock, r as RichLine, s as RichSpan, R as RichText, t as RichTextField, u as ToggleField, V as ValueOf, v as asOneParagraph, w as choice, x as choices, y as collection, z as date, A as defaultContent, B as defaultValue, E as defineSchema, H as group, J as image, K as link, M as normalizedLine, P as number, Q as plainTextOf, S as readRichText, W as reference, X as references, Y as richText, Z as richTextOf, _ as text, $ as time, a0 as toggle, a1 as url } from './content-6k-E7NWf.js';
3
3
  import { F as FieldPath } from './parse-content-C9ldnmvj.js';
4
4
  export { C as ContentIssue, P as ParsedContent, c as countCharacters, f as formatFieldPath, p as parseContent } from './parse-content-C9ldnmvj.js';
5
+ export { a as SITE_DESCRIPTION_PATH, S as SiteDescription, b as SitePageDescription, i as isSearchTextsGroup, r as readSiteDescription } from './site-description-4Jx6Nq6X.js';
5
6
 
6
7
  declare const FIELD_PATH_ATTRIBUTE = "data-cms-path";
7
8
  interface FieldMarker {
@@ -171,4 +172,7 @@ declare function openingHoursField(options?: {
171
172
  note: TextField;
172
173
  }>;
173
174
 
174
- export { ChoicesField, CollectionField, type CollectionRecords, Content, ContentOf, type ContentPath, FIELD_PATH_ATTRIBUTE, Field, type FieldMarker, FieldPath, type FieldTarget, Fields, GroupField, ImageField, ImageValue, LinkField, type LinkKind, type LinkTarget, LinkValue, RECORD_ID_KEY, REVEALED_ATTRIBUTE, REVEAL_ATTRIBUTE, REVEAL_EVENT, RecordFields, TextField, TimeField, UrlField, WEEKDAYS, collectionAt, contactsFields, editable, findRecord, hrefOf, imagePosition, isRecordId, linkTargetOf, newRecord, openingHoursField, resolveFieldPath, seoFields, withRecordIds };
175
+ /** The schema the input describes, or undefined when it is not one. */
176
+ declare function readFields(input: unknown): Fields | undefined;
177
+
178
+ export { ChoicesField, CollectionField, type CollectionRecords, Content, ContentOf, type ContentPath, FIELD_PATH_ATTRIBUTE, Field, type FieldMarker, FieldPath, type FieldTarget, Fields, GroupField, ImageField, ImageValue, LinkField, type LinkKind, type LinkTarget, LinkValue, RECORD_ID_KEY, REVEALED_ATTRIBUTE, REVEAL_ATTRIBUTE, REVEAL_EVENT, RecordFields, TextField, TimeField, UrlField, WEEKDAYS, collectionAt, contactsFields, editable, findRecord, hrefOf, imagePosition, isRecordId, linkTargetOf, newRecord, openingHoursField, readFields, resolveFieldPath, seoFields, withRecordIds };
package/dist/schema.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  REVEALED_ATTRIBUTE,
5
5
  REVEAL_ATTRIBUTE,
6
6
  REVEAL_EVENT,
7
+ SITE_DESCRIPTION_PATH,
7
8
  WEEKDAYS,
8
9
  asOneParagraph,
9
10
  choice,
@@ -24,6 +25,7 @@ import {
24
25
  image,
25
26
  imagePosition,
26
27
  isRecordId,
28
+ isSearchTextsGroup,
27
29
  link,
28
30
  linkTargetOf,
29
31
  newRecord,
@@ -32,7 +34,9 @@ import {
32
34
  openingHoursField,
33
35
  parseContent,
34
36
  plainTextOf,
37
+ readFields,
35
38
  readRichText,
39
+ readSiteDescription,
36
40
  reference,
37
41
  references,
38
42
  resolveFieldPath,
@@ -44,13 +48,14 @@ import {
44
48
  toggle,
45
49
  url,
46
50
  withRecordIds
47
- } from "./chunk-ZUJEM3P7.js";
51
+ } from "./chunk-NFUGV7JJ.js";
48
52
  export {
49
53
  FIELD_PATH_ATTRIBUTE,
50
54
  RECORD_ID_KEY,
51
55
  REVEALED_ATTRIBUTE,
52
56
  REVEAL_ATTRIBUTE,
53
57
  REVEAL_EVENT,
58
+ SITE_DESCRIPTION_PATH,
54
59
  WEEKDAYS,
55
60
  asOneParagraph,
56
61
  choice,
@@ -71,6 +76,7 @@ export {
71
76
  image,
72
77
  imagePosition,
73
78
  isRecordId,
79
+ isSearchTextsGroup,
74
80
  link,
75
81
  linkTargetOf,
76
82
  newRecord,
@@ -79,7 +85,9 @@ export {
79
85
  openingHoursField,
80
86
  parseContent,
81
87
  plainTextOf,
88
+ readFields,
82
89
  readRichText,
90
+ readSiteDescription,
83
91
  reference,
84
92
  references,
85
93
  resolveFieldPath,
@@ -0,0 +1,36 @@
1
+ import { F as Fields, a as Content } from './content-6k-E7NWf.js';
2
+
3
+ /** Where a site hands out its description, on the origin of its preview. */
4
+ declare const SITE_DESCRIPTION_PATH = "/seamlet/site.json";
5
+ /** A page of a site, which the site renders itself. */
6
+ interface SitePageDescription {
7
+ /** The address of the page on the site: `/` for the first page, `/menu` for others. */
8
+ readonly path: string;
9
+ /** How the editor names the page in its list of pages. */
10
+ readonly title: string;
11
+ /**
12
+ * Where the content holds what search engines and link previews show for the page
13
+ * (`seo.menu`): a group of a `title` and a `description` text.
14
+ */
15
+ readonly searchTexts?: string;
16
+ }
17
+ interface SiteDescription {
18
+ /** The address of the site in the CMS (`/sites/<id>`). */
19
+ readonly id: string;
20
+ /** How the CMS names the site. */
21
+ readonly title: string;
22
+ readonly schema: Fields;
23
+ /** The content shown and edited until the first save. */
24
+ readonly initialContent: Content;
25
+ /** The pages of the site; the first is the home page at `/`. */
26
+ readonly pages: readonly [SitePageDescription, ...SitePageDescription[]];
27
+ }
28
+ /**
29
+ * The description the input gives, or undefined when it is not a whole and sound one: a known
30
+ * schema, an initial content with nothing to fix, the home page first, each page once.
31
+ */
32
+ declare function readSiteDescription(input: unknown): SiteDescription | undefined;
33
+ /** Whether the path names a group of a `title` and a `description` text. */
34
+ declare function isSearchTextsGroup(schema: Fields, path: string): boolean;
35
+
36
+ export { type SiteDescription as S, SITE_DESCRIPTION_PATH as a, type SitePageDescription as b, isSearchTextsGroup as i, readSiteDescription as r };
package/dist/site.d.ts CHANGED
@@ -1,16 +1,26 @@
1
1
  import { ImageMetadata } from 'astro';
2
2
  import { AstroComponentFactory } from 'astro/runtime/server/index.js';
3
3
  import { F as Fields, C as ContentOf } from './content-6k-E7NWf.js';
4
+ import { S as SiteDescription } from './site-description-4Jx6Nq6X.js';
4
5
 
6
+ /** A page of the site: how the editor names it, and the component that renders it. */
7
+ interface SeamletPage {
8
+ readonly title: string;
9
+ /** A component that takes the site's content as its `content` prop. */
10
+ readonly component: AstroComponentFactory;
11
+ /** Where the content holds the page's title and description for search engines (`seo.menu`). */
12
+ readonly searchTexts?: string;
13
+ }
5
14
  interface SeamletSiteDefinition<F extends Fields> {
6
15
  /** The site's address in the CMS: `/sites/<id>`. */
7
16
  readonly id: string;
17
+ /** How the CMS names the site: «Кофейня «Зерно»». */
18
+ readonly title: string;
8
19
  readonly schema: F;
9
- /**
10
- * The pages of the site by their address (`/`, `/menu`): components that take the site's content
11
- * as their `content` prop. The CMS lists the same addresses for its editor.
12
- */
13
- readonly pages: Readonly<Record<string, AstroComponentFactory>>;
20
+ /** The content the site shows, and the CMS edits, until the first publication. */
21
+ readonly initialContent: ContentOf<F>;
22
+ /** The pages of the site by their address (`/`, `/menu`); the home page `/` comes first. */
23
+ readonly pages: Readonly<Record<string, SeamletPage>>;
14
24
  /**
15
25
  * The site's own photo at the address its content gives it (`/menu/cafe/01.jpg`), so the CMS's
16
26
  * editor can show it in the photo's field. A site without own photos leaves it out.
@@ -26,8 +36,15 @@ interface SeamletSite<F extends Fields = Fields> {
26
36
  readonly loadPublishedContent: () => Promise<ContentOf<F>>;
27
37
  /** The site's own photo at an address its content gives it, if it has one there. */
28
38
  readonly ownPhotoAt: (src: string) => ImageMetadata | undefined;
39
+ /** What the site tells the CMS about itself (`SITE_DESCRIPTION_PATH`). */
40
+ readonly description: SiteDescription;
29
41
  }
30
42
  /** Describes the site for the integration: `src/seamlet.ts` exports it as its default. */
31
43
  declare function defineSeamletSite<F extends Fields>(definition: SeamletSiteDefinition<F>): SeamletSite<F>;
44
+ /**
45
+ * What the CMS published for the site with this id, in the shape of its schema: the site's initial
46
+ * content until the first publication. For a module that needs the content without the pages.
47
+ */
48
+ declare function loadPublishedContent<F extends Fields>(siteId: string, schema: F): Promise<ContentOf<F>>;
32
49
 
33
- export { type SeamletSite, type SeamletSiteDefinition, defineSeamletSite };
50
+ export { type SeamletPage, type SeamletSite, type SeamletSiteDefinition, defineSeamletSite, loadPublishedContent };
package/dist/site.js CHANGED
@@ -1,23 +1,49 @@
1
1
  import {
2
2
  parseContent
3
- } from "./chunk-ZUJEM3P7.js";
3
+ } from "./chunk-NFUGV7JJ.js";
4
4
 
5
5
  // src/site.ts
6
6
  import { cmsOrigin } from "virtual:seamlet/config";
7
+
8
+ // src/site-descriptions.ts
9
+ function describeSite(definition) {
10
+ const pages = Object.entries(definition.pages).map(
11
+ ([path, page]) => ({
12
+ path,
13
+ title: page.title,
14
+ ...page.searchTexts === void 0 ? {} : { searchTexts: page.searchTexts }
15
+ })
16
+ );
17
+ const [home, ...rest] = pages;
18
+ if (home === void 0) {
19
+ throw new Error(`The site "${definition.id}" has no pages`);
20
+ }
21
+ return {
22
+ id: definition.id,
23
+ title: definition.title,
24
+ schema: definition.schema,
25
+ initialContent: definition.initialContent,
26
+ pages: [home, ...rest]
27
+ };
28
+ }
29
+
30
+ // src/site.ts
7
31
  var HOME_PAGE_PATH = "/";
8
32
  function defineSeamletSite(definition) {
9
33
  const pages = new Map(Object.entries(definition.pages));
10
34
  return {
11
35
  id: definition.id,
12
36
  schema: definition.schema,
13
- pageAt: (rest) => pages.get(rest === void 0 ? HOME_PAGE_PATH : `/${rest}`),
14
- loadPublishedContent: async () => {
15
- const published = await fetchPublished(definition.id);
16
- return parseContent(definition.schema, published).content;
17
- },
18
- ownPhotoAt: (src) => definition.photos?.(src)
37
+ pageAt: (rest) => pages.get(rest === void 0 ? HOME_PAGE_PATH : `/${rest}`)?.component,
38
+ loadPublishedContent: () => loadPublishedContent(definition.id, definition.schema),
39
+ ownPhotoAt: (src) => definition.photos?.(src),
40
+ description: describeSite(definition)
19
41
  };
20
42
  }
43
+ async function loadPublishedContent(siteId, schema) {
44
+ const published = await fetchPublished(siteId);
45
+ return parseContent(schema, published).content;
46
+ }
21
47
  async function fetchPublished(siteId) {
22
48
  const url = new URL(`/api/sites/${siteId}/published`, cmsOrigin);
23
49
  const response = await fetch(url);
@@ -29,5 +55,6 @@ async function fetchPublished(siteId) {
29
55
  return response.json();
30
56
  }
31
57
  export {
32
- defineSeamletSite
58
+ defineSeamletSite,
59
+ loadPublishedContent
33
60
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seamlet/astro",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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",
@@ -47,6 +47,10 @@
47
47
  "types": "./dist/routes/own-photos.d.ts",
48
48
  "default": "./dist/routes/own-photos.js"
49
49
  },
50
+ "./routes/site-description": {
51
+ "types": "./dist/routes/site-description.d.ts",
52
+ "default": "./dist/routes/site-description.js"
53
+ },
50
54
  "./components/RichText.astro": "./components/RichText.astro"
51
55
  },
52
56
  "files": [