@seamlet/astro 0.8.1 → 0.10.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 +28 -12
- package/dist/bridge.js +2 -2
- package/dist/{chunk-27XIZEVS.js → chunk-2E7VDSUK.js} +1 -1
- package/dist/{chunk-ZUJEM3P7.js → chunk-DRDPKFHZ.js} +225 -1
- package/dist/index.d.ts +2 -8
- package/dist/index.js +83 -8
- package/dist/protocol.js +2 -2
- package/dist/routes/site-description.d.ts +5 -0
- package/dist/routes/site-description.js +8 -0
- package/dist/schema.d.ts +28 -1
- package/dist/schema.js +15 -1
- package/dist/site-description-4Jx6Nq6X.d.ts +36 -0
- package/dist/site.d.ts +22 -8
- package/dist/site.js +39 -12
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -10,7 +10,19 @@ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https
|
|
|
10
10
|
npm install @seamlet/astro
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
2.
|
|
13
|
+
2. Name the site in `seamlet.json` at its root: its id in the CMS (lower-case letters, digits and single hyphens; its address there, `/sites/<id>`, the name of its content and the host of its preview), how the CMS names it, and the CMS it belongs to. The CMS reads the same file from the site's repository, so the id is written once.
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"id": "zerno",
|
|
18
|
+
"title": "Кофейня «Зерно»",
|
|
19
|
+
"cms": "https://cms.seamlet.ru"
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The environment variable `CMS_ORIGIN` takes the place of `cms` when it is set: a build for a CMS in development, or the CMS's own builder. A build without the file, or with a field it cannot read, stops and says which field to fix.
|
|
24
|
+
|
|
25
|
+
3. Describe the content with a schema, and mark the elements that show it:
|
|
14
26
|
|
|
15
27
|
```ts
|
|
16
28
|
// src/schema.ts
|
|
@@ -46,35 +58,38 @@ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https
|
|
|
46
58
|
<h1 {...edit("title")}>{content.title}</h1>
|
|
47
59
|
```
|
|
48
60
|
|
|
49
|
-
|
|
61
|
+
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`).
|
|
50
62
|
|
|
51
63
|
```ts
|
|
52
64
|
// src/seamlet.ts
|
|
53
65
|
import { defineSeamletSite } from "@seamlet/astro/site";
|
|
54
66
|
import HomePage from "./components/HomePage.astro";
|
|
67
|
+
import { initialContent } from "./initial-content";
|
|
55
68
|
import { schema } from "./schema";
|
|
56
69
|
|
|
57
70
|
export default defineSeamletSite({
|
|
58
|
-
id: "zerno",
|
|
59
71
|
schema,
|
|
60
|
-
|
|
72
|
+
initialContent,
|
|
73
|
+
pages: {
|
|
74
|
+
"/": { title: "Главная", component: HomePage, searchTexts: "seo.home" },
|
|
75
|
+
},
|
|
61
76
|
});
|
|
62
77
|
```
|
|
63
78
|
|
|
64
|
-
|
|
79
|
+
The CMS takes all of it from the site itself, so connecting a site, or changing its schema, needs no change of the CMS.
|
|
80
|
+
|
|
81
|
+
5. Add the integration; it reads `seamlet.json` and `src/seamlet.ts` itself:
|
|
65
82
|
|
|
66
83
|
```js
|
|
67
84
|
// astro.config.mjs
|
|
68
85
|
import seamlet from "@seamlet/astro";
|
|
69
86
|
|
|
70
87
|
export default defineConfig({
|
|
71
|
-
integrations: [
|
|
72
|
-
seamlet({ cms: "https://cms.example.com", site: "./src/seamlet.ts" }),
|
|
73
|
-
],
|
|
88
|
+
integrations: [seamlet()],
|
|
74
89
|
});
|
|
75
90
|
```
|
|
76
91
|
|
|
77
|
-
The site's pages take their published content with `site.loadPublishedContent()`: at build time for a static site, on request otherwise.
|
|
92
|
+
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(schema)` of `@seamlet/astro/site`.
|
|
78
93
|
|
|
79
94
|
## Fields
|
|
80
95
|
|
|
@@ -135,9 +150,9 @@ const MENU_FILES = import.meta.glob<{ default: ImageMetadata }>(
|
|
|
135
150
|
);
|
|
136
151
|
|
|
137
152
|
export default defineSeamletSite({
|
|
138
|
-
id: "zerno",
|
|
139
153
|
schema,
|
|
140
|
-
|
|
154
|
+
initialContent,
|
|
155
|
+
pages: { "/": { title: "Главная", component: HomePage } },
|
|
141
156
|
photos: (src) => MENU_FILES[src.replace("/menu/", "./assets/menu/")]?.default,
|
|
142
157
|
});
|
|
143
158
|
```
|
|
@@ -219,9 +234,10 @@ What was opened stays open while the page is rendered anew after every edit.
|
|
|
219
234
|
|
|
220
235
|
- `/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
236
|
- 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.
|
|
237
|
+
- `/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.
|
|
222
238
|
- `/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
239
|
- `/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
240
|
|
|
225
241
|
A site never renders content as raw HTML (`set:html`): the editor acts on what its preview says.
|
|
226
242
|
|
|
227
|
-
|
|
243
|
+
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,7 +18,7 @@ import {
|
|
|
18
18
|
shortcutOf,
|
|
19
19
|
toggleList,
|
|
20
20
|
toggleMark
|
|
21
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-2E7VDSUK.js";
|
|
22
22
|
import {
|
|
23
23
|
FIELD_PATH_ATTRIBUTE,
|
|
24
24
|
REVEALED_ATTRIBUTE,
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
asOneParagraph,
|
|
28
28
|
readRichText,
|
|
29
29
|
resolveFieldPath
|
|
30
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-DRDPKFHZ.js";
|
|
31
31
|
|
|
32
32
|
// src/bridge/typed-fields.ts
|
|
33
33
|
function typedKindAt(schema, path) {
|
|
@@ -786,6 +786,223 @@ 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-config.ts
|
|
921
|
+
var SITE_CONFIG_FILE = "seamlet.json";
|
|
922
|
+
var SITE_ID = /^[a-z0-9]+(-[a-z0-9]+)*$/u;
|
|
923
|
+
var LONGEST_SITE_ID = 63;
|
|
924
|
+
var WEB_PROTOCOLS = /* @__PURE__ */ new Set(["https:", "http:"]);
|
|
925
|
+
var SITE_CONFIG_FIELDS = ["id", "title", "cms"];
|
|
926
|
+
function isSiteId(value) {
|
|
927
|
+
return typeof value === "string" && value.length <= LONGEST_SITE_ID && SITE_ID.test(value);
|
|
928
|
+
}
|
|
929
|
+
function readSiteConfig(input) {
|
|
930
|
+
const fields = isRecord3(input) ? input : {};
|
|
931
|
+
const { id, title } = fields;
|
|
932
|
+
const cms = originOf(fields["cms"]);
|
|
933
|
+
const hasId = isSiteId(id);
|
|
934
|
+
const hasTitle = isText(title);
|
|
935
|
+
const hasCms = cms !== void 0;
|
|
936
|
+
if (hasId && hasTitle && hasCms) {
|
|
937
|
+
return { status: "read", config: { id, title: title.trim(), cms } };
|
|
938
|
+
}
|
|
939
|
+
const validity = {
|
|
940
|
+
id: hasId,
|
|
941
|
+
title: hasTitle,
|
|
942
|
+
cms: hasCms
|
|
943
|
+
};
|
|
944
|
+
const invalidFields = SITE_CONFIG_FIELDS.filter((field) => !validity[field]);
|
|
945
|
+
return { status: "invalid", invalidFields };
|
|
946
|
+
}
|
|
947
|
+
function originOf(value) {
|
|
948
|
+
if (typeof value !== "string" || !URL.canParse(value)) return void 0;
|
|
949
|
+
const url2 = new URL(value);
|
|
950
|
+
return WEB_PROTOCOLS.has(url2.protocol) ? url2.origin : void 0;
|
|
951
|
+
}
|
|
952
|
+
function isText(value) {
|
|
953
|
+
return typeof value === "string" && value.trim() !== "";
|
|
954
|
+
}
|
|
955
|
+
function isRecord3(value) {
|
|
956
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/schema/site-description.ts
|
|
960
|
+
var SITE_DESCRIPTION_PATH = "/seamlet/site.json";
|
|
961
|
+
var PAGE_PATH = /^(\/[a-z0-9][a-z0-9-]*)+$/u;
|
|
962
|
+
var HOME_PAGE_PATH = "/";
|
|
963
|
+
function readSiteDescription(input) {
|
|
964
|
+
if (!isRecord4(input)) return void 0;
|
|
965
|
+
const { id, title } = input;
|
|
966
|
+
const schema = readFields(input["schema"]);
|
|
967
|
+
const isNamed = isSiteId(id) && isText2(title);
|
|
968
|
+
if (!isNamed || schema === void 0) return void 0;
|
|
969
|
+
const initial = parseContent(schema, input["initialContent"]);
|
|
970
|
+
const pages = readPages(schema, input["pages"]);
|
|
971
|
+
if (initial.issues.length > 0 || pages === void 0) return void 0;
|
|
972
|
+
return { id, title, schema, initialContent: initial.content, pages };
|
|
973
|
+
}
|
|
974
|
+
function isSearchTextsGroup(schema, path) {
|
|
975
|
+
const target = resolveFieldPath(schema, path);
|
|
976
|
+
if (target?.kind !== "field" || target.field.kind !== "group") return false;
|
|
977
|
+
const { title, description } = target.field.fields;
|
|
978
|
+
return title?.kind === "text" && description?.kind === "text";
|
|
979
|
+
}
|
|
980
|
+
function readPages(schema, input) {
|
|
981
|
+
if (!Array.isArray(input)) return void 0;
|
|
982
|
+
const entries = input;
|
|
983
|
+
const pages = entries.map((entry) => readPage(schema, entry));
|
|
984
|
+
const read = pages.flatMap((page) => page === void 0 ? [] : [page]);
|
|
985
|
+
const [first, ...rest] = read;
|
|
986
|
+
const paths = new Set(read.map((page) => page.path));
|
|
987
|
+
const isSound = read.length === pages.length && first?.path === HOME_PAGE_PATH && paths.size === read.length;
|
|
988
|
+
return isSound ? [first, ...rest] : void 0;
|
|
989
|
+
}
|
|
990
|
+
function readPage(schema, input) {
|
|
991
|
+
if (!isRecord4(input)) return void 0;
|
|
992
|
+
const { path, title, searchTexts } = input;
|
|
993
|
+
const isPath = typeof path === "string" && (path === HOME_PAGE_PATH || PAGE_PATH.test(path));
|
|
994
|
+
if (!isPath || !isText2(title)) return void 0;
|
|
995
|
+
if (searchTexts === void 0) return { path, title };
|
|
996
|
+
const isSearchTexts = typeof searchTexts === "string" && isSearchTextsGroup(schema, searchTexts);
|
|
997
|
+
return isSearchTexts ? { path, title, searchTexts } : void 0;
|
|
998
|
+
}
|
|
999
|
+
function isRecord4(value) {
|
|
1000
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1001
|
+
}
|
|
1002
|
+
function isText2(value) {
|
|
1003
|
+
return typeof value === "string" && value.trim() !== "";
|
|
1004
|
+
}
|
|
1005
|
+
|
|
789
1006
|
export {
|
|
790
1007
|
defineSchema,
|
|
791
1008
|
text,
|
|
@@ -831,5 +1048,12 @@ export {
|
|
|
831
1048
|
seoFields,
|
|
832
1049
|
contactsFields,
|
|
833
1050
|
WEEKDAYS,
|
|
834
|
-
openingHoursField
|
|
1051
|
+
openingHoursField,
|
|
1052
|
+
readFields,
|
|
1053
|
+
SITE_CONFIG_FILE,
|
|
1054
|
+
isSiteId,
|
|
1055
|
+
readSiteConfig,
|
|
1056
|
+
SITE_DESCRIPTION_PATH,
|
|
1057
|
+
readSiteDescription,
|
|
1058
|
+
isSearchTextsGroup
|
|
835
1059
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { AstroIntegration } from 'astro';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
/** The address of the CMS, such as `https://cms.example.com`. */
|
|
5
|
-
readonly cms: string;
|
|
6
|
-
/** The module of the site, relative to the project: `defineSeamletSite(…)` as its default export. */
|
|
7
|
-
readonly site: string;
|
|
8
|
-
}
|
|
9
|
-
declare function seamlet(options: SeamletOptions): AstroIntegration;
|
|
3
|
+
declare function seamlet(): AstroIntegration;
|
|
10
4
|
|
|
11
|
-
export {
|
|
5
|
+
export { seamlet as default };
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
2
|
OWN_PHOTOS_PATH
|
|
3
3
|
} from "./chunk-3BS5CE26.js";
|
|
4
|
+
import {
|
|
5
|
+
SITE_CONFIG_FILE,
|
|
6
|
+
SITE_DESCRIPTION_PATH,
|
|
7
|
+
readSiteConfig
|
|
8
|
+
} from "./chunk-DRDPKFHZ.js";
|
|
4
9
|
import {
|
|
5
10
|
isUploadedPhoto
|
|
6
11
|
} from "./chunk-P2OQOZBC.js";
|
|
7
12
|
|
|
8
13
|
// src/index.ts
|
|
9
|
-
import
|
|
14
|
+
import process from "process";
|
|
15
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
10
16
|
|
|
11
17
|
// src/uploaded-photos.ts
|
|
12
18
|
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
|
|
@@ -50,22 +56,78 @@ async function htmlFilesIn(directory) {
|
|
|
50
56
|
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(HTML_EXTENSION)).map((entry) => join(entry.parentPath, entry.name));
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
// src/site-config-file.ts
|
|
60
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
61
|
+
import { fileURLToPath } from "url";
|
|
62
|
+
var FIELD_RULES = {
|
|
63
|
+
id: "the site's id in the CMS: lower-case letters, digits and single hyphens, such as coffee-shop",
|
|
64
|
+
title: "how the CMS names the site, such as \xAB\u041A\u043E\u0444\u0435\u0439\u043D\u044F \xAB\u0417\u0435\u0440\u043D\u043E\xBB\xBB",
|
|
65
|
+
cms: "the address of the CMS, such as https://cms.seamlet.ru"
|
|
66
|
+
};
|
|
67
|
+
async function loadSiteConfig(root, cmsOverride) {
|
|
68
|
+
const file = new URL(SITE_CONFIG_FILE, root);
|
|
69
|
+
const parsed = await readJsonFile(file);
|
|
70
|
+
const input = cmsOverride === void 0 ? parsed : { ...asObject(parsed), cms: cmsOverride };
|
|
71
|
+
const reading = readSiteConfig(input);
|
|
72
|
+
if (reading.status === "read") return reading.config;
|
|
73
|
+
throw new Error(invalidFieldsMessage(file, reading.invalidFields));
|
|
74
|
+
}
|
|
75
|
+
async function readJsonFile(file) {
|
|
76
|
+
let text;
|
|
77
|
+
try {
|
|
78
|
+
text = await readFile2(file, "utf8");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`@seamlet/astro: ${fileURLToPath(file)} is missing: the site names its id, its title and its CMS there`,
|
|
82
|
+
{ cause: error }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(text);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new Error(`@seamlet/astro: ${fileURLToPath(file)} is not JSON`, {
|
|
89
|
+
cause: error
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function asObject(value) {
|
|
94
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
95
|
+
}
|
|
96
|
+
function invalidFieldsMessage(file, fields) {
|
|
97
|
+
const lines = fields.map((field) => ` "${field}": ${FIELD_RULES[field]}`);
|
|
98
|
+
return [
|
|
99
|
+
`@seamlet/astro: ${fileURLToPath(file)} needs these fields:`,
|
|
100
|
+
...lines
|
|
101
|
+
].join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
53
104
|
// src/index.ts
|
|
54
105
|
var CONFIG_MODULE = "virtual:seamlet/config";
|
|
55
106
|
var SITE_MODULE = "virtual:seamlet/site";
|
|
56
107
|
var RESOLVED_CONFIG_MODULE = `\0${CONFIG_MODULE}`;
|
|
57
|
-
|
|
58
|
-
|
|
108
|
+
var SITE_MODULE_FILE = "src/seamlet.ts";
|
|
109
|
+
var CMS_ORIGIN_VARIABLE = "CMS_ORIGIN";
|
|
110
|
+
function seamlet() {
|
|
111
|
+
let siteConfig;
|
|
59
112
|
return {
|
|
60
113
|
name: "@seamlet/astro",
|
|
61
114
|
hooks: {
|
|
62
|
-
"astro:config:setup": ({
|
|
115
|
+
"astro:config:setup": async ({
|
|
63
116
|
config,
|
|
117
|
+
addWatchFile,
|
|
64
118
|
injectRoute,
|
|
65
119
|
injectScript,
|
|
66
120
|
updateConfig
|
|
67
121
|
}) => {
|
|
68
|
-
const
|
|
122
|
+
const loaded = await loadSiteConfig(
|
|
123
|
+
config.root,
|
|
124
|
+
process.env[CMS_ORIGIN_VARIABLE]
|
|
125
|
+
);
|
|
126
|
+
siteConfig = loaded;
|
|
127
|
+
addWatchFile(new URL(SITE_CONFIG_FILE, config.root));
|
|
128
|
+
const siteModule = fileURLToPath2(
|
|
129
|
+
new URL(SITE_MODULE_FILE, config.root)
|
|
130
|
+
);
|
|
69
131
|
updateConfig({
|
|
70
132
|
vite: {
|
|
71
133
|
plugins: [
|
|
@@ -76,7 +138,7 @@ function seamlet(options) {
|
|
|
76
138
|
if (id === SITE_MODULE) return siteModule;
|
|
77
139
|
return void 0;
|
|
78
140
|
},
|
|
79
|
-
load: (id) => id === RESOLVED_CONFIG_MODULE ?
|
|
141
|
+
load: (id) => id === RESOLVED_CONFIG_MODULE ? configModuleOf(loaded) : void 0
|
|
80
142
|
}
|
|
81
143
|
]
|
|
82
144
|
}
|
|
@@ -92,6 +154,11 @@ function seamlet(options) {
|
|
|
92
154
|
entrypoint: "@seamlet/astro/routes/media",
|
|
93
155
|
prerender: false
|
|
94
156
|
});
|
|
157
|
+
injectRoute({
|
|
158
|
+
pattern: SITE_DESCRIPTION_PATH,
|
|
159
|
+
entrypoint: "@seamlet/astro/routes/site-description",
|
|
160
|
+
prerender: false
|
|
161
|
+
});
|
|
95
162
|
injectRoute({
|
|
96
163
|
pattern: `${OWN_PHOTOS_PATH}/[...path]`,
|
|
97
164
|
entrypoint: "@seamlet/astro/routes/own-photos",
|
|
@@ -102,15 +169,23 @@ function seamlet(options) {
|
|
|
102
169
|
// the built pages refer to are copied next to them. Pages rendered on request take them
|
|
103
170
|
// through the route above.
|
|
104
171
|
"astro:build:done": async ({ dir, logger }) => {
|
|
172
|
+
if (siteConfig === void 0) return;
|
|
105
173
|
const copied = await copyUploadedPhotos({
|
|
106
|
-
directory:
|
|
107
|
-
cmsOrigin
|
|
174
|
+
directory: fileURLToPath2(dir),
|
|
175
|
+
cmsOrigin: siteConfig.cms
|
|
108
176
|
});
|
|
109
177
|
if (copied > 0) logger.info(`copied ${String(copied)} uploaded photos`);
|
|
110
178
|
}
|
|
111
179
|
}
|
|
112
180
|
};
|
|
113
181
|
}
|
|
182
|
+
function configModuleOf(config) {
|
|
183
|
+
return [
|
|
184
|
+
`export const siteId = ${JSON.stringify(config.id)};`,
|
|
185
|
+
`export const siteTitle = ${JSON.stringify(config.title)};`,
|
|
186
|
+
`export const cmsOrigin = ${JSON.stringify(config.cms)};`
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
114
189
|
export {
|
|
115
190
|
seamlet as default
|
|
116
191
|
};
|
package/dist/protocol.js
CHANGED
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,30 @@ declare function openingHoursField(options?: {
|
|
|
171
172
|
note: TextField;
|
|
172
173
|
}>;
|
|
173
174
|
|
|
174
|
-
|
|
175
|
+
/** The schema the input describes, or undefined when it is not one. */
|
|
176
|
+
declare function readFields(input: unknown): Fields | undefined;
|
|
177
|
+
|
|
178
|
+
/** The file at the root of a site's repository. */
|
|
179
|
+
declare const SITE_CONFIG_FILE = "seamlet.json";
|
|
180
|
+
interface SiteConfig {
|
|
181
|
+
/** The site's id in the CMS: its address there (`/sites/<id>`) and the name of its content. */
|
|
182
|
+
readonly id: string;
|
|
183
|
+
/** How the CMS names the site: «Кофейня «Зерно»». */
|
|
184
|
+
readonly title: string;
|
|
185
|
+
/** The origin of the CMS the site belongs to: `https://cms.seamlet.ru`. */
|
|
186
|
+
readonly cms: string;
|
|
187
|
+
}
|
|
188
|
+
type SiteConfigField = keyof SiteConfig;
|
|
189
|
+
type SiteConfigReading = {
|
|
190
|
+
readonly status: "read";
|
|
191
|
+
readonly config: SiteConfig;
|
|
192
|
+
} | {
|
|
193
|
+
readonly status: "invalid";
|
|
194
|
+
/** The fields that are missing or not what they must be. */
|
|
195
|
+
readonly invalidFields: readonly SiteConfigField[];
|
|
196
|
+
};
|
|
197
|
+
declare function isSiteId(value: unknown): value is string;
|
|
198
|
+
/** The config the input gives (the parsed `seamlet.json`); fields it does not know are left out. */
|
|
199
|
+
declare function readSiteConfig(input: unknown): SiteConfigReading;
|
|
200
|
+
|
|
201
|
+
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, SITE_CONFIG_FILE, type SiteConfig, type SiteConfigField, type SiteConfigReading, TextField, TimeField, UrlField, WEEKDAYS, collectionAt, contactsFields, editable, findRecord, hrefOf, imagePosition, isRecordId, isSiteId, linkTargetOf, newRecord, openingHoursField, readFields, readSiteConfig, resolveFieldPath, seoFields, withRecordIds };
|
package/dist/schema.js
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
REVEALED_ATTRIBUTE,
|
|
5
5
|
REVEAL_ATTRIBUTE,
|
|
6
6
|
REVEAL_EVENT,
|
|
7
|
+
SITE_CONFIG_FILE,
|
|
8
|
+
SITE_DESCRIPTION_PATH,
|
|
7
9
|
WEEKDAYS,
|
|
8
10
|
asOneParagraph,
|
|
9
11
|
choice,
|
|
@@ -24,6 +26,8 @@ import {
|
|
|
24
26
|
image,
|
|
25
27
|
imagePosition,
|
|
26
28
|
isRecordId,
|
|
29
|
+
isSearchTextsGroup,
|
|
30
|
+
isSiteId,
|
|
27
31
|
link,
|
|
28
32
|
linkTargetOf,
|
|
29
33
|
newRecord,
|
|
@@ -32,7 +36,10 @@ import {
|
|
|
32
36
|
openingHoursField,
|
|
33
37
|
parseContent,
|
|
34
38
|
plainTextOf,
|
|
39
|
+
readFields,
|
|
35
40
|
readRichText,
|
|
41
|
+
readSiteConfig,
|
|
42
|
+
readSiteDescription,
|
|
36
43
|
reference,
|
|
37
44
|
references,
|
|
38
45
|
resolveFieldPath,
|
|
@@ -44,13 +51,15 @@ import {
|
|
|
44
51
|
toggle,
|
|
45
52
|
url,
|
|
46
53
|
withRecordIds
|
|
47
|
-
} from "./chunk-
|
|
54
|
+
} from "./chunk-DRDPKFHZ.js";
|
|
48
55
|
export {
|
|
49
56
|
FIELD_PATH_ATTRIBUTE,
|
|
50
57
|
RECORD_ID_KEY,
|
|
51
58
|
REVEALED_ATTRIBUTE,
|
|
52
59
|
REVEAL_ATTRIBUTE,
|
|
53
60
|
REVEAL_EVENT,
|
|
61
|
+
SITE_CONFIG_FILE,
|
|
62
|
+
SITE_DESCRIPTION_PATH,
|
|
54
63
|
WEEKDAYS,
|
|
55
64
|
asOneParagraph,
|
|
56
65
|
choice,
|
|
@@ -71,6 +80,8 @@ export {
|
|
|
71
80
|
image,
|
|
72
81
|
imagePosition,
|
|
73
82
|
isRecordId,
|
|
83
|
+
isSearchTextsGroup,
|
|
84
|
+
isSiteId,
|
|
74
85
|
link,
|
|
75
86
|
linkTargetOf,
|
|
76
87
|
newRecord,
|
|
@@ -79,7 +90,10 @@ export {
|
|
|
79
90
|
openingHoursField,
|
|
80
91
|
parseContent,
|
|
81
92
|
plainTextOf,
|
|
93
|
+
readFields,
|
|
82
94
|
readRichText,
|
|
95
|
+
readSiteConfig,
|
|
96
|
+
readSiteDescription,
|
|
83
97
|
reference,
|
|
84
98
|
references,
|
|
85
99
|
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,23 @@
|
|
|
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
|
+
}
|
|
14
|
+
/** The site's content and pages; its id and title come from `seamlet.json`. */
|
|
5
15
|
interface SeamletSiteDefinition<F extends Fields> {
|
|
6
|
-
/** The site's address in the CMS: `/sites/<id>`. */
|
|
7
|
-
readonly id: string;
|
|
8
16
|
readonly schema: F;
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
readonly pages: Readonly<Record<string, AstroComponentFactory>>;
|
|
17
|
+
/** The content the site shows, and the CMS edits, until the first publication. */
|
|
18
|
+
readonly initialContent: ContentOf<F>;
|
|
19
|
+
/** The pages of the site by their address (`/`, `/menu`); the home page `/` comes first. */
|
|
20
|
+
readonly pages: Readonly<Record<string, SeamletPage>>;
|
|
14
21
|
/**
|
|
15
22
|
* The site's own photo at the address its content gives it (`/menu/cafe/01.jpg`), so the CMS's
|
|
16
23
|
* editor can show it in the photo's field. A site without own photos leaves it out.
|
|
@@ -26,8 +33,15 @@ interface SeamletSite<F extends Fields = Fields> {
|
|
|
26
33
|
readonly loadPublishedContent: () => Promise<ContentOf<F>>;
|
|
27
34
|
/** The site's own photo at an address its content gives it, if it has one there. */
|
|
28
35
|
readonly ownPhotoAt: (src: string) => ImageMetadata | undefined;
|
|
36
|
+
/** What the site tells the CMS about itself (`SITE_DESCRIPTION_PATH`). */
|
|
37
|
+
readonly description: SiteDescription;
|
|
29
38
|
}
|
|
30
39
|
/** Describes the site for the integration: `src/seamlet.ts` exports it as its default. */
|
|
31
40
|
declare function defineSeamletSite<F extends Fields>(definition: SeamletSiteDefinition<F>): SeamletSite<F>;
|
|
41
|
+
/**
|
|
42
|
+
* What the CMS published for the site, in the shape of its schema: the site's initial content until
|
|
43
|
+
* the first publication. For a module that needs the content without the pages.
|
|
44
|
+
*/
|
|
45
|
+
declare function loadPublishedContent<F extends Fields>(schema: F): Promise<ContentOf<F>>;
|
|
32
46
|
|
|
33
|
-
export { type SeamletSite, type SeamletSiteDefinition, defineSeamletSite };
|
|
47
|
+
export { type SeamletPage, type SeamletSite, type SeamletSiteDefinition, defineSeamletSite, loadPublishedContent };
|
package/dist/site.js
CHANGED
|
@@ -1,25 +1,51 @@
|
|
|
1
1
|
import {
|
|
2
2
|
parseContent
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-DRDPKFHZ.js";
|
|
4
|
+
|
|
5
|
+
// src/site.ts
|
|
6
|
+
import { cmsOrigin, siteId, siteTitle } from "virtual:seamlet/config";
|
|
7
|
+
|
|
8
|
+
// src/site-descriptions.ts
|
|
9
|
+
function describeSite(identity, 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 "${identity.id}" has no pages`);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
id: identity.id,
|
|
23
|
+
title: identity.title,
|
|
24
|
+
schema: definition.schema,
|
|
25
|
+
initialContent: definition.initialContent,
|
|
26
|
+
pages: [home, ...rest]
|
|
27
|
+
};
|
|
28
|
+
}
|
|
4
29
|
|
|
5
30
|
// src/site.ts
|
|
6
|
-
import { cmsOrigin } from "virtual:seamlet/config";
|
|
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
|
-
id:
|
|
35
|
+
id: siteId,
|
|
12
36
|
schema: definition.schema,
|
|
13
|
-
pageAt: (rest) => pages.get(rest === void 0 ? HOME_PAGE_PATH : `/${rest}`),
|
|
14
|
-
loadPublishedContent:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
},
|
|
18
|
-
ownPhotoAt: (src) => definition.photos?.(src)
|
|
37
|
+
pageAt: (rest) => pages.get(rest === void 0 ? HOME_PAGE_PATH : `/${rest}`)?.component,
|
|
38
|
+
loadPublishedContent: () => loadPublishedContent(definition.schema),
|
|
39
|
+
ownPhotoAt: (src) => definition.photos?.(src),
|
|
40
|
+
description: describeSite({ id: siteId, title: siteTitle }, definition)
|
|
19
41
|
};
|
|
20
42
|
}
|
|
21
|
-
async function
|
|
22
|
-
const
|
|
43
|
+
async function loadPublishedContent(schema) {
|
|
44
|
+
const published = await fetchPublished(siteId);
|
|
45
|
+
return parseContent(schema, published).content;
|
|
46
|
+
}
|
|
47
|
+
async function fetchPublished(id) {
|
|
48
|
+
const url = new URL(`/api/sites/${id}/published`, cmsOrigin);
|
|
23
49
|
const response = await fetch(url);
|
|
24
50
|
if (!response.ok) {
|
|
25
51
|
throw new Error(
|
|
@@ -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.
|
|
3
|
+
"version": "0.10.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": [
|