@seamlet/astro 0.9.0 → 0.11.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
@@ -4,13 +4,34 @@ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https
4
4
 
5
5
  ## How a site connects
6
6
 
7
+ The quick way, in an Astro project:
8
+
9
+ ```bash
10
+ npx @seamlet/astro init --title "Кофейня «Зерно»" # seamlet.json, src/schema.ts, src/seamlet.ts, a first page component
11
+ npx astro add @seamlet/astro node # the integration, and an adapter for the preview
12
+ ```
13
+
14
+ `init` takes the id from the name in `package.json` unless `--id` gives it, and the CMS `https://cms.seamlet.ru` unless `--cms` names another; it never overwrites a file the site has, and says what to render in a page. The steps below are what it starts, one by one.
15
+
7
16
  1. Install the package. The site needs an adapter, because the preview is rendered on request; its own pages may stay static.
8
17
 
9
18
  ```bash
10
19
  npm install @seamlet/astro
11
20
  ```
12
21
 
13
- 2. Describe the content with a schema, and mark the elements that show it:
22
+ 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.
23
+
24
+ ```json
25
+ {
26
+ "id": "zerno",
27
+ "title": "Кофейня «Зерно»",
28
+ "cms": "https://cms.seamlet.ru"
29
+ }
30
+ ```
31
+
32
+ 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.
33
+
34
+ 3. Describe the content with a schema, and mark the elements that show it:
14
35
 
15
36
  ```ts
16
37
  // src/schema.ts
@@ -46,7 +67,7 @@ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https
46
67
  <h1 {...edit("title")}>{content.title}</h1>
47
68
  ```
48
69
 
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`).
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`).
50
71
 
51
72
  ```ts
52
73
  // src/seamlet.ts
@@ -56,8 +77,6 @@ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https
56
77
  import { schema } from "./schema";
57
78
 
58
79
  export default defineSeamletSite({
59
- id: "zerno",
60
- title: "Кофейня «Зерно»",
61
80
  schema,
62
81
  initialContent,
63
82
  pages: {
@@ -68,20 +87,18 @@ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https
68
87
 
69
88
  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
89
 
71
- 4. Add the integration:
90
+ 5. Add the integration; it reads `seamlet.json` and `src/seamlet.ts` itself:
72
91
 
73
92
  ```js
74
93
  // astro.config.mjs
75
94
  import seamlet from "@seamlet/astro";
76
95
 
77
96
  export default defineConfig({
78
- integrations: [
79
- seamlet({ cms: "https://cms.example.com", site: "./src/seamlet.ts" }),
80
- ],
97
+ integrations: [seamlet()],
81
98
  });
82
99
  ```
83
100
 
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`.
101
+ 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`.
85
102
 
86
103
  ## Fields
87
104
 
@@ -142,8 +159,6 @@ const MENU_FILES = import.meta.glob<{ default: ImageMetadata }>(
142
159
  );
143
160
 
144
161
  export default defineSeamletSite({
145
- id: "zerno",
146
- title: "Кофейня «Зерно»",
147
162
  schema,
148
163
  initialContent,
149
164
  pages: { "/": { title: "Главная", component: HomePage } },
@@ -228,7 +243,7 @@ What was opened stays open while the page is rendered anew after every edit.
228
243
 
229
244
  - `/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.
230
245
  - 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.
246
+ - `/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.
232
247
  - `/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.
233
248
  - `/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.
234
249
 
package/dist/bridge.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  shortcutOf,
19
19
  toggleList,
20
20
  toggleMark
21
- } from "./chunk-UBBVOSON.js";
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-NFUGV7JJ.js";
30
+ } from "./chunk-DRDPKFHZ.js";
31
31
 
32
32
  // src/bridge/typed-fields.ts
33
33
  function typedKindAt(schema, path) {
@@ -3,7 +3,7 @@ import {
3
3
  normalizedLine,
4
4
  readRichText,
5
5
  resolveFieldPath
6
- } from "./chunk-NFUGV7JJ.js";
6
+ } from "./chunk-DRDPKFHZ.js";
7
7
 
8
8
  // src/bridge/editor-shortcuts.ts
9
9
  function shortcutOf(event) {
@@ -917,16 +917,54 @@ function isCount(value) {
917
917
  return isNumber(value) && Number.isInteger(value) && value >= 0;
918
918
  }
919
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
+
920
959
  // src/schema/site-description.ts
921
960
  var SITE_DESCRIPTION_PATH = "/seamlet/site.json";
922
- var SITE_ID = /^[a-z0-9]+(-[a-z0-9]+)*$/u;
923
961
  var PAGE_PATH = /^(\/[a-z0-9][a-z0-9-]*)+$/u;
924
962
  var HOME_PAGE_PATH = "/";
925
963
  function readSiteDescription(input) {
926
- if (!isRecord3(input)) return void 0;
964
+ if (!isRecord4(input)) return void 0;
927
965
  const { id, title } = input;
928
966
  const schema = readFields(input["schema"]);
929
- const isNamed = typeof id === "string" && SITE_ID.test(id) && isText(title);
967
+ const isNamed = isSiteId(id) && isText2(title);
930
968
  if (!isNamed || schema === void 0) return void 0;
931
969
  const initial = parseContent(schema, input["initialContent"]);
932
970
  const pages = readPages(schema, input["pages"]);
@@ -950,18 +988,18 @@ function readPages(schema, input) {
950
988
  return isSound ? [first, ...rest] : void 0;
951
989
  }
952
990
  function readPage(schema, input) {
953
- if (!isRecord3(input)) return void 0;
991
+ if (!isRecord4(input)) return void 0;
954
992
  const { path, title, searchTexts } = input;
955
993
  const isPath = typeof path === "string" && (path === HOME_PAGE_PATH || PAGE_PATH.test(path));
956
- if (!isPath || !isText(title)) return void 0;
994
+ if (!isPath || !isText2(title)) return void 0;
957
995
  if (searchTexts === void 0) return { path, title };
958
996
  const isSearchTexts = typeof searchTexts === "string" && isSearchTextsGroup(schema, searchTexts);
959
997
  return isSearchTexts ? { path, title, searchTexts } : void 0;
960
998
  }
961
- function isRecord3(value) {
999
+ function isRecord4(value) {
962
1000
  return typeof value === "object" && value !== null && !Array.isArray(value);
963
1001
  }
964
- function isText(value) {
1002
+ function isText2(value) {
965
1003
  return typeof value === "string" && value.trim() !== "";
966
1004
  }
967
1005
 
@@ -1012,6 +1050,9 @@ export {
1012
1050
  WEEKDAYS,
1013
1051
  openingHoursField,
1014
1052
  readFields,
1053
+ SITE_CONFIG_FILE,
1054
+ isSiteId,
1055
+ readSiteConfig,
1015
1056
  SITE_DESCRIPTION_PATH,
1016
1057
  readSiteDescription,
1017
1058
  isSearchTextsGroup
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ SITE_CONFIG_FILE,
4
+ isSiteId,
5
+ readSiteConfig
6
+ } from "./chunk-DRDPKFHZ.js";
7
+
8
+ // src/cli.ts
9
+ import process from "process";
10
+
11
+ // src/init/init-command.ts
12
+ import { readFile } from "fs/promises";
13
+ import { join as join2 } from "path";
14
+ import { parseArgs } from "util";
15
+
16
+ // src/init/site-files.ts
17
+ var SITE_MODULE_PATH = "src/seamlet.ts";
18
+ var SCHEMA_PATH = "src/schema.ts";
19
+ var HOME_PAGE_PATH = "src/components/HomePage.astro";
20
+ var JSON_INDENT = 2;
21
+ function siteFilesOf(config) {
22
+ return [
23
+ {
24
+ path: SITE_CONFIG_FILE,
25
+ text: `${JSON.stringify(config, null, JSON_INDENT)}
26
+ `
27
+ },
28
+ { path: SCHEMA_PATH, text: schemaModuleOf(config.title) },
29
+ { path: SITE_MODULE_PATH, text: SITE_MODULE },
30
+ { path: HOME_PAGE_PATH, text: HOME_PAGE }
31
+ ];
32
+ }
33
+ function siteIdOf(packageName) {
34
+ const unscoped = packageName.replace(/^@[^/]+\//u, "");
35
+ const id = unscoped.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "");
36
+ return isSiteId(id) ? id : void 0;
37
+ }
38
+ function schemaModuleOf(title) {
39
+ return `import { defineSchema, text, type ContentOf } from "@seamlet/astro/schema";
40
+
41
+ // What the site's owner edits in the CMS. Add a field here, show it on a page and mark its element
42
+ // with edit(...): the CMS builds its form from this schema alone.
43
+ export const schema = defineSchema({
44
+ title: text({ label: "\u0417\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A", maxLength: 80 }),
45
+ });
46
+
47
+ export type Content = ContentOf<typeof schema>;
48
+
49
+ /** What the site shows, and the CMS edits, until the first publication. */
50
+ export const initialContent: Content = {
51
+ title: ${JSON.stringify(title)},
52
+ };
53
+ `;
54
+ }
55
+ var SITE_MODULE = `import { defineSeamletSite } from "@seamlet/astro/site";
56
+ import HomePage from "./components/HomePage.astro";
57
+ import { initialContent, schema } from "./schema";
58
+
59
+ // The site as the CMS knows it: its schema, the content it starts with and its pages (its id and
60
+ // title are in seamlet.json). Every page is a component that takes the content as its prop.
61
+ export default defineSeamletSite({
62
+ schema,
63
+ initialContent,
64
+ pages: {
65
+ "/": { title: "\u0413\u043B\u0430\u0432\u043D\u0430\u044F", component: HomePage },
66
+ },
67
+ });
68
+ `;
69
+ var HOME_PAGE = `---
70
+ import { editable } from "@seamlet/astro/schema";
71
+ import type { Content } from "../schema";
72
+
73
+ interface Props {
74
+ content: Content;
75
+ }
76
+
77
+ const { content } = Astro.props;
78
+ // The CMS's editor lets the owner click the marked elements; a path the content lacks does not compile.
79
+ const edit = editable<Content>();
80
+ ---
81
+
82
+ <h1 {...edit("title")}>{content.title}</h1>
83
+ `;
84
+
85
+ // src/init/write-site-files.ts
86
+ import { mkdir, writeFile } from "fs/promises";
87
+ import { dirname, join } from "path";
88
+ var ALREADY_EXISTS = "EEXIST";
89
+ async function writeSiteFiles(root, files) {
90
+ const written = [];
91
+ const kept = [];
92
+ for (const file of files) {
93
+ const isWritten = await writeNewFile(join(root, file.path), file.text);
94
+ (isWritten ? written : kept).push(file.path);
95
+ }
96
+ return { written, kept };
97
+ }
98
+ async function writeNewFile(path, text) {
99
+ await mkdir(dirname(path), { recursive: true });
100
+ try {
101
+ await writeFile(path, text, { flag: "wx" });
102
+ return true;
103
+ } catch (error) {
104
+ if (isAlreadyThere(error)) return false;
105
+ throw new Error(`@seamlet/astro: cannot write ${path}`, { cause: error });
106
+ }
107
+ }
108
+ function isAlreadyThere(error) {
109
+ return error instanceof Error && "code" in error && error.code === ALREADY_EXISTS;
110
+ }
111
+
112
+ // src/init/init-command.ts
113
+ var DEFAULT_CMS = "https://cms.seamlet.ru";
114
+ var SUCCESS = 0;
115
+ var FAILURE = 1;
116
+ var MISUSE = 2;
117
+ var USAGE = `Usage: npx @seamlet/astro init [--id <id>] [--title <title>] [--cms <address>]
118
+
119
+ Writes ${SITE_CONFIG_FILE} and a first module of the site in the Astro project here. The id is the
120
+ site's name in the CMS (lower-case letters, digits and hyphens); by default it comes from the
121
+ name in package.json, the title from the id, the CMS is ${DEFAULT_CMS}.`;
122
+ var NEXT_STEPS = `
123
+ Next:
124
+ npx astro add @seamlet/astro node the integration, and an adapter for the preview
125
+ render a page with its content, such as src/pages/index.astro:
126
+ ---
127
+ import site from "../seamlet";
128
+ import HomePage from "../components/HomePage.astro";
129
+ const content = await site.loadPublishedContent();
130
+ ---
131
+ <HomePage content={content} />`;
132
+ async function runInit(run) {
133
+ const options = optionsOf(run.argv);
134
+ if (options === void 0) {
135
+ run.log(USAGE);
136
+ return MISUSE;
137
+ }
138
+ const packageName = await packageNameOf(run.root);
139
+ const id = options.id ?? siteIdOf(packageName ?? "");
140
+ const reading = readSiteConfig({
141
+ id,
142
+ title: options.title ?? id,
143
+ cms: options.cms ?? DEFAULT_CMS
144
+ });
145
+ if (reading.status === "invalid") {
146
+ const fields = reading.invalidFields.map((field) => `--${field}`).join(", ");
147
+ run.log(`Give the site ${fields}.
148
+
149
+ ${USAGE}`);
150
+ return FAILURE;
151
+ }
152
+ const files = await writeSiteFiles(run.root, siteFilesOf(reading.config));
153
+ for (const path of files.written) run.log(`wrote ${path}`);
154
+ for (const path of files.kept)
155
+ run.log(`kept ${path}: the site has it already`);
156
+ run.log(NEXT_STEPS);
157
+ return SUCCESS;
158
+ }
159
+ function optionsOf(argv) {
160
+ try {
161
+ const { positionals, values } = parseArgs({
162
+ args: [...argv],
163
+ allowPositionals: true,
164
+ options: {
165
+ id: { type: "string" },
166
+ title: { type: "string" },
167
+ cms: { type: "string" }
168
+ }
169
+ });
170
+ const isInit = positionals.length === 1 && positionals[0] === "init";
171
+ return isInit ? { id: values.id, title: values.title, cms: values.cms } : void 0;
172
+ } catch {
173
+ return void 0;
174
+ }
175
+ }
176
+ async function packageNameOf(root) {
177
+ try {
178
+ const manifest = JSON.parse(
179
+ await readFile(join2(root, "package.json"), "utf8")
180
+ );
181
+ const name = manifest?.name;
182
+ return typeof name === "string" ? name : void 0;
183
+ } catch {
184
+ return void 0;
185
+ }
186
+ }
187
+
188
+ // src/cli.ts
189
+ var code = await runInit({
190
+ root: process.cwd(),
191
+ argv: process.argv.slice(2),
192
+ log: (line) => {
193
+ console.log(line);
194
+ }
195
+ });
196
+ process.exitCode = code;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,5 @@
1
1
  import { AstroIntegration } from 'astro';
2
2
 
3
- interface SeamletOptions {
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 { type SeamletOptions, seamlet as default };
5
+ export { seamlet as default };
package/dist/index.js CHANGED
@@ -2,14 +2,17 @@ import {
2
2
  OWN_PHOTOS_PATH
3
3
  } from "./chunk-3BS5CE26.js";
4
4
  import {
5
- SITE_DESCRIPTION_PATH
6
- } from "./chunk-NFUGV7JJ.js";
5
+ SITE_CONFIG_FILE,
6
+ SITE_DESCRIPTION_PATH,
7
+ readSiteConfig
8
+ } from "./chunk-DRDPKFHZ.js";
7
9
  import {
8
10
  isUploadedPhoto
9
11
  } from "./chunk-P2OQOZBC.js";
10
12
 
11
13
  // src/index.ts
12
- import { fileURLToPath } from "url";
14
+ import process from "process";
15
+ import { fileURLToPath as fileURLToPath2 } from "url";
13
16
 
14
17
  // src/uploaded-photos.ts
15
18
  import { mkdir, readdir, readFile, writeFile } from "fs/promises";
@@ -53,22 +56,78 @@ async function htmlFilesIn(directory) {
53
56
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith(HTML_EXTENSION)).map((entry) => join(entry.parentPath, entry.name));
54
57
  }
55
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
+
56
104
  // src/index.ts
57
105
  var CONFIG_MODULE = "virtual:seamlet/config";
58
106
  var SITE_MODULE = "virtual:seamlet/site";
59
107
  var RESOLVED_CONFIG_MODULE = `\0${CONFIG_MODULE}`;
60
- function seamlet(options) {
61
- const cmsOrigin = new URL(options.cms).origin;
108
+ var SITE_MODULE_FILE = "src/seamlet.ts";
109
+ var CMS_ORIGIN_VARIABLE = "CMS_ORIGIN";
110
+ function seamlet() {
111
+ let siteConfig;
62
112
  return {
63
113
  name: "@seamlet/astro",
64
114
  hooks: {
65
- "astro:config:setup": ({
115
+ "astro:config:setup": async ({
66
116
  config,
117
+ addWatchFile,
67
118
  injectRoute,
68
119
  injectScript,
69
120
  updateConfig
70
121
  }) => {
71
- const siteModule = fileURLToPath(new URL(options.site, config.root));
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
+ );
72
131
  updateConfig({
73
132
  vite: {
74
133
  plugins: [
@@ -79,7 +138,7 @@ function seamlet(options) {
79
138
  if (id === SITE_MODULE) return siteModule;
80
139
  return void 0;
81
140
  },
82
- load: (id) => id === RESOLVED_CONFIG_MODULE ? `export const cmsOrigin = ${JSON.stringify(cmsOrigin)};` : void 0
141
+ load: (id) => id === RESOLVED_CONFIG_MODULE ? configModuleOf(loaded) : void 0
83
142
  }
84
143
  ]
85
144
  }
@@ -110,15 +169,23 @@ function seamlet(options) {
110
169
  // the built pages refer to are copied next to them. Pages rendered on request take them
111
170
  // through the route above.
112
171
  "astro:build:done": async ({ dir, logger }) => {
172
+ if (siteConfig === void 0) return;
113
173
  const copied = await copyUploadedPhotos({
114
- directory: fileURLToPath(dir),
115
- cmsOrigin
174
+ directory: fileURLToPath2(dir),
175
+ cmsOrigin: siteConfig.cms
116
176
  });
117
177
  if (copied > 0) logger.info(`copied ${String(copied)} uploaded photos`);
118
178
  }
119
179
  }
120
180
  };
121
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
+ }
122
189
  export {
123
190
  seamlet as default
124
191
  };
package/dist/protocol.js CHANGED
@@ -22,8 +22,8 @@ import {
22
22
  shortcutOf,
23
23
  toggleList,
24
24
  toggleMark
25
- } from "./chunk-UBBVOSON.js";
26
- import "./chunk-NFUGV7JJ.js";
25
+ } from "./chunk-2E7VDSUK.js";
26
+ import "./chunk-DRDPKFHZ.js";
27
27
  export {
28
28
  OWN_PHOTOS_PATH,
29
29
  PROTOCOL_VERSION,
package/dist/schema.d.ts CHANGED
@@ -175,4 +175,27 @@ declare function openingHoursField(options?: {
175
175
  /** The schema the input describes, or undefined when it is not one. */
176
176
  declare function readFields(input: unknown): Fields | undefined;
177
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 };
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,7 @@ import {
4
4
  REVEALED_ATTRIBUTE,
5
5
  REVEAL_ATTRIBUTE,
6
6
  REVEAL_EVENT,
7
+ SITE_CONFIG_FILE,
7
8
  SITE_DESCRIPTION_PATH,
8
9
  WEEKDAYS,
9
10
  asOneParagraph,
@@ -26,6 +27,7 @@ import {
26
27
  imagePosition,
27
28
  isRecordId,
28
29
  isSearchTextsGroup,
30
+ isSiteId,
29
31
  link,
30
32
  linkTargetOf,
31
33
  newRecord,
@@ -36,6 +38,7 @@ import {
36
38
  plainTextOf,
37
39
  readFields,
38
40
  readRichText,
41
+ readSiteConfig,
39
42
  readSiteDescription,
40
43
  reference,
41
44
  references,
@@ -48,13 +51,14 @@ import {
48
51
  toggle,
49
52
  url,
50
53
  withRecordIds
51
- } from "./chunk-NFUGV7JJ.js";
54
+ } from "./chunk-DRDPKFHZ.js";
52
55
  export {
53
56
  FIELD_PATH_ATTRIBUTE,
54
57
  RECORD_ID_KEY,
55
58
  REVEALED_ATTRIBUTE,
56
59
  REVEAL_ATTRIBUTE,
57
60
  REVEAL_EVENT,
61
+ SITE_CONFIG_FILE,
58
62
  SITE_DESCRIPTION_PATH,
59
63
  WEEKDAYS,
60
64
  asOneParagraph,
@@ -77,6 +81,7 @@ export {
77
81
  imagePosition,
78
82
  isRecordId,
79
83
  isSearchTextsGroup,
84
+ isSiteId,
80
85
  link,
81
86
  linkTargetOf,
82
87
  newRecord,
@@ -87,6 +92,7 @@ export {
87
92
  plainTextOf,
88
93
  readFields,
89
94
  readRichText,
95
+ readSiteConfig,
90
96
  readSiteDescription,
91
97
  reference,
92
98
  references,
package/dist/site.d.ts CHANGED
@@ -11,11 +11,8 @@ interface SeamletPage {
11
11
  /** Where the content holds the page's title and description for search engines (`seo.menu`). */
12
12
  readonly searchTexts?: string;
13
13
  }
14
+ /** The site's content and pages; its id and title come from `seamlet.json`. */
14
15
  interface SeamletSiteDefinition<F extends Fields> {
15
- /** The site's address in the CMS: `/sites/<id>`. */
16
- readonly id: string;
17
- /** How the CMS names the site: «Кофейня «Зерно»». */
18
- readonly title: string;
19
16
  readonly schema: F;
20
17
  /** The content the site shows, and the CMS edits, until the first publication. */
21
18
  readonly initialContent: ContentOf<F>;
@@ -42,9 +39,10 @@ interface SeamletSite<F extends Fields = Fields> {
42
39
  /** Describes the site for the integration: `src/seamlet.ts` exports it as its default. */
43
40
  declare function defineSeamletSite<F extends Fields>(definition: SeamletSiteDefinition<F>): SeamletSite<F>;
44
41
  /**
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.
42
+ * What the CMS published for the site, in the shape of its schema: the site's initial content until
43
+ * the first publication, and while the CMS does not know the site yet. For a module that needs the
44
+ * content without the pages.
47
45
  */
48
- declare function loadPublishedContent<F extends Fields>(siteId: string, schema: F): Promise<ContentOf<F>>;
46
+ declare function loadPublishedContent<F extends Fields>(schema: F): Promise<ContentOf<F>>;
49
47
 
50
48
  export { type SeamletPage, type SeamletSite, type SeamletSiteDefinition, defineSeamletSite, loadPublishedContent };
package/dist/site.js CHANGED
@@ -1,12 +1,33 @@
1
1
  import {
2
2
  parseContent
3
- } from "./chunk-NFUGV7JJ.js";
3
+ } from "./chunk-DRDPKFHZ.js";
4
4
 
5
5
  // src/site.ts
6
- import { cmsOrigin } from "virtual:seamlet/config";
6
+ import { cmsOrigin, siteId, siteTitle } from "virtual:seamlet/config";
7
+
8
+ // src/published-answers.ts
9
+ var NOT_FOUND = 404;
10
+ var UNKNOWN_SITE = "unknown-site";
11
+ async function readPublishedAnswer(response, url) {
12
+ if (response.ok)
13
+ return { status: "published", content: await response.json() };
14
+ const isUnknownSite = response.status === NOT_FOUND && await errorOf(response) === UNKNOWN_SITE;
15
+ if (isUnknownSite) return { status: "unknown-site" };
16
+ throw new Error(
17
+ `Loading the published content from ${url.href} failed with status ${String(response.status)}`
18
+ );
19
+ }
20
+ async function errorOf(response) {
21
+ try {
22
+ const body = await response.json();
23
+ return typeof body === "object" && body !== null ? body.error : void 0;
24
+ } catch {
25
+ return void 0;
26
+ }
27
+ }
7
28
 
8
29
  // src/site-descriptions.ts
9
- function describeSite(definition) {
30
+ function describeSite(identity, definition) {
10
31
  const pages = Object.entries(definition.pages).map(
11
32
  ([path, page]) => ({
12
33
  path,
@@ -16,11 +37,11 @@ function describeSite(definition) {
16
37
  );
17
38
  const [home, ...rest] = pages;
18
39
  if (home === void 0) {
19
- throw new Error(`The site "${definition.id}" has no pages`);
40
+ throw new Error(`The site "${identity.id}" has no pages`);
20
41
  }
21
42
  return {
22
- id: definition.id,
23
- title: definition.title,
43
+ id: identity.id,
44
+ title: identity.title,
24
45
  schema: definition.schema,
25
46
  initialContent: definition.initialContent,
26
47
  pages: [home, ...rest]
@@ -32,27 +53,26 @@ var HOME_PAGE_PATH = "/";
32
53
  function defineSeamletSite(definition) {
33
54
  const pages = new Map(Object.entries(definition.pages));
34
55
  return {
35
- id: definition.id,
56
+ id: siteId,
36
57
  schema: definition.schema,
37
58
  pageAt: (rest) => pages.get(rest === void 0 ? HOME_PAGE_PATH : `/${rest}`)?.component,
38
- loadPublishedContent: () => loadPublishedContent(definition.id, definition.schema),
59
+ loadPublishedContent: () => loadPublishedContent(definition.schema),
39
60
  ownPhotoAt: (src) => definition.photos?.(src),
40
- description: describeSite(definition)
61
+ description: describeSite({ id: siteId, title: siteTitle }, definition)
41
62
  };
42
63
  }
43
- async function loadPublishedContent(siteId, schema) {
44
- const published = await fetchPublished(siteId);
45
- return parseContent(schema, published).content;
46
- }
47
- async function fetchPublished(siteId) {
64
+ async function loadPublishedContent(schema) {
48
65
  const url = new URL(`/api/sites/${siteId}/published`, cmsOrigin);
49
- const response = await fetch(url);
50
- if (!response.ok) {
51
- throw new Error(
52
- `Loading the published content from ${url.href} failed with status ${String(response.status)}`
53
- );
54
- }
55
- return response.json();
66
+ const answer = await readPublishedAnswer(await fetch(url), url);
67
+ const content = answer.status === "published" ? answer.content : await initialContentOfSite();
68
+ return parseContent(schema, content).content;
69
+ }
70
+ async function initialContentOfSite() {
71
+ console.warn(
72
+ `@seamlet/astro: the CMS at ${cmsOrigin} does not know the site "${siteId}" yet; its initial content is shown`
73
+ );
74
+ const { default: site } = await import("virtual:seamlet/site");
75
+ return site.description.initialContent;
56
76
  }
57
77
  export {
58
78
  defineSeamletSite,
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@seamlet/astro",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
7
+ "bin": {
8
+ "seamlet-astro": "./dist/cli.js"
9
+ },
7
10
  "repository": {
8
11
  "type": "git",
9
12
  "url": "git+https://github.com/seamlet/sdk.git",