@seamlet/astro 0.10.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,6 +4,15 @@ 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
@@ -89,7 +98,7 @@ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https
89
98
  });
90
99
  ```
91
100
 
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`.
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`.
93
102
 
94
103
  ## Fields
95
104
 
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/site.d.ts CHANGED
@@ -40,7 +40,8 @@ interface SeamletSite<F extends Fields = Fields> {
40
40
  declare function defineSeamletSite<F extends Fields>(definition: SeamletSiteDefinition<F>): SeamletSite<F>;
41
41
  /**
42
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.
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.
44
45
  */
45
46
  declare function loadPublishedContent<F extends Fields>(schema: F): Promise<ContentOf<F>>;
46
47
 
package/dist/site.js CHANGED
@@ -5,6 +5,27 @@ import {
5
5
  // src/site.ts
6
6
  import { cmsOrigin, siteId, siteTitle } from "virtual:seamlet/config";
7
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
+ }
28
+
8
29
  // src/site-descriptions.ts
9
30
  function describeSite(identity, definition) {
10
31
  const pages = Object.entries(definition.pages).map(
@@ -41,18 +62,17 @@ function defineSeamletSite(definition) {
41
62
  };
42
63
  }
43
64
  async function loadPublishedContent(schema) {
44
- const published = await fetchPublished(siteId);
45
- return parseContent(schema, published).content;
65
+ const url = new URL(`/api/sites/${siteId}/published`, cmsOrigin);
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;
46
69
  }
47
- async function fetchPublished(id) {
48
- const url = new URL(`/api/sites/${id}/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();
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.10.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",