create-shapio 0.2.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.
Files changed (161) hide show
  1. package/LICENSE +201 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +311 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +37 -0
  6. package/templates/astro/.env.example +10 -0
  7. package/templates/astro/README.md +31 -0
  8. package/templates/astro/astro.config.mjs +50 -0
  9. package/templates/astro/gitignore +4 -0
  10. package/templates/astro/package.json +31 -0
  11. package/templates/astro/public/favicon.svg +9 -0
  12. package/templates/astro/public/robots.txt +3 -0
  13. package/templates/astro/scripts/incrementalBuild.ts +142 -0
  14. package/templates/astro/scripts/lib/admin.ts +76 -0
  15. package/templates/astro/scripts/lib/callback.ts +46 -0
  16. package/templates/astro/scripts/lib/content.ts +462 -0
  17. package/templates/astro/scripts/lib/png.ts +47 -0
  18. package/templates/astro/scripts/reportStatus.ts +40 -0
  19. package/templates/astro/scripts/seed.ts +396 -0
  20. package/templates/astro/scripts/smoke.ts +127 -0
  21. package/templates/astro/scripts/smokeHttp.ts +162 -0
  22. package/templates/astro/shapio/components/callToAction.json +85 -0
  23. package/templates/astro/shapio/components/feature.json +46 -0
  24. package/templates/astro/shapio/components/featureGrid.json +51 -0
  25. package/templates/astro/shapio/components/gallery.json +52 -0
  26. package/templates/astro/shapio/components/hero.json +108 -0
  27. package/templates/astro/shapio/models/article.json +150 -0
  28. package/templates/astro/shapio/models/author.json +73 -0
  29. package/templates/astro/shapio/models/page.json +93 -0
  30. package/templates/astro/shapio/models/siteSettings.json +87 -0
  31. package/templates/astro/src/layouts/Base.astro +52 -0
  32. package/templates/astro/src/lib/config.ts +42 -0
  33. package/templates/astro/src/lib/preview.ts +57 -0
  34. package/templates/astro/src/lib/render.ts +146 -0
  35. package/templates/astro/src/lib/shapio.ts +104 -0
  36. package/templates/astro/src/lib/site.ts +55 -0
  37. package/templates/astro/src/lib/types.ts +96 -0
  38. package/templates/astro/src/pages/404.astro +8 -0
  39. package/templates/astro/src/pages/[locale]/[...slug].astro +28 -0
  40. package/templates/astro/src/pages/[locale]/articles/[slug].astro +25 -0
  41. package/templates/astro/src/pages/[locale]/articles/index.astro +22 -0
  42. package/templates/astro/src/pages/[locale]/colophon.astro +19 -0
  43. package/templates/astro/src/pages/build.json.ts +9 -0
  44. package/templates/astro/src/pages/index.astro +18 -0
  45. package/templates/astro/src/pages/preview.astro +21 -0
  46. package/templates/astro/src/scripts/preview.ts +47 -0
  47. package/templates/astro/src/styles/site.css +199 -0
  48. package/templates/astro/tsconfig.json +17 -0
  49. package/templates/next/.env.example +15 -0
  50. package/templates/next/README.md +69 -0
  51. package/templates/next/gitignore +5 -0
  52. package/templates/next/next.config.ts +69 -0
  53. package/templates/next/package.json +34 -0
  54. package/templates/next/public/favicon.svg +9 -0
  55. package/templates/next/scripts/lib/admin.ts +76 -0
  56. package/templates/next/scripts/lib/content.ts +462 -0
  57. package/templates/next/scripts/lib/png.ts +47 -0
  58. package/templates/next/scripts/seed.ts +396 -0
  59. package/templates/next/scripts/smokeHttp.ts +162 -0
  60. package/templates/next/shapio/components/callToAction.json +85 -0
  61. package/templates/next/shapio/components/feature.json +46 -0
  62. package/templates/next/shapio/components/featureGrid.json +51 -0
  63. package/templates/next/shapio/components/gallery.json +52 -0
  64. package/templates/next/shapio/components/hero.json +108 -0
  65. package/templates/next/shapio/models/article.json +150 -0
  66. package/templates/next/shapio/models/author.json +73 -0
  67. package/templates/next/shapio/models/page.json +93 -0
  68. package/templates/next/shapio/models/siteSettings.json +87 -0
  69. package/templates/next/src/app/[locale]/[[...slug]]/page.tsx +40 -0
  70. package/templates/next/src/app/[locale]/articles/[slug]/page.tsx +37 -0
  71. package/templates/next/src/app/[locale]/articles/page.tsx +28 -0
  72. package/templates/next/src/app/[locale]/colophon/page.tsx +25 -0
  73. package/templates/next/src/app/[locale]/layout.tsx +77 -0
  74. package/templates/next/src/app/api/revalidate/route.ts +31 -0
  75. package/templates/next/src/app/preview/layout.tsx +20 -0
  76. package/templates/next/src/app/preview/page.tsx +6 -0
  77. package/templates/next/src/app/site.css +199 -0
  78. package/templates/next/src/components/ArticleCard/index.tsx +19 -0
  79. package/templates/next/src/components/ArticleView/index.tsx +40 -0
  80. package/templates/next/src/components/Byline/index.tsx +15 -0
  81. package/templates/next/src/components/LocaleSwitch/index.tsx +16 -0
  82. package/templates/next/src/components/Preview/hooks/usePreviewDraft.ts +53 -0
  83. package/templates/next/src/components/Preview/index.tsx +33 -0
  84. package/templates/next/src/components/ResponsiveImage/index.tsx +36 -0
  85. package/templates/next/src/components/RichText/index.tsx +13 -0
  86. package/templates/next/src/components/Sections/CallToAction/index.tsx +14 -0
  87. package/templates/next/src/components/Sections/FeatureGrid/index.tsx +17 -0
  88. package/templates/next/src/components/Sections/Gallery/index.tsx +20 -0
  89. package/templates/next/src/components/Sections/Hero/index.tsx +31 -0
  90. package/templates/next/src/components/Sections/SectionView/index.tsx +30 -0
  91. package/templates/next/src/components/Sections/index.tsx +19 -0
  92. package/templates/next/src/lib/articles.ts +5 -0
  93. package/templates/next/src/lib/config.ts +57 -0
  94. package/templates/next/src/lib/liveSnapshot.ts +74 -0
  95. package/templates/next/src/lib/log.ts +4 -0
  96. package/templates/next/src/lib/preview.ts +56 -0
  97. package/templates/next/src/lib/revalidation.ts +91 -0
  98. package/templates/next/src/lib/revalidationTargets.test.ts +67 -0
  99. package/templates/next/src/lib/revalidationTargets.ts +66 -0
  100. package/templates/next/src/lib/shapio.ts +75 -0
  101. package/templates/next/src/lib/site.ts +66 -0
  102. package/templates/next/src/lib/types.ts +96 -0
  103. package/templates/next/src/lib/webhookEvent.test.ts +61 -0
  104. package/templates/next/src/lib/webhookEvent.ts +47 -0
  105. package/templates/next/src/styles.d.ts +3 -0
  106. package/templates/next/tsconfig.json +23 -0
  107. package/templates/sveltekit/.env.example +10 -0
  108. package/templates/sveltekit/README.md +38 -0
  109. package/templates/sveltekit/gitignore +4 -0
  110. package/templates/sveltekit/package.json +38 -0
  111. package/templates/sveltekit/scripts/lib/admin.ts +76 -0
  112. package/templates/sveltekit/scripts/lib/content.ts +462 -0
  113. package/templates/sveltekit/scripts/lib/png.ts +47 -0
  114. package/templates/sveltekit/scripts/seed.ts +396 -0
  115. package/templates/sveltekit/scripts/smokeHttp.ts +162 -0
  116. package/templates/sveltekit/shapio/components/callToAction.json +85 -0
  117. package/templates/sveltekit/shapio/components/feature.json +46 -0
  118. package/templates/sveltekit/shapio/components/featureGrid.json +51 -0
  119. package/templates/sveltekit/shapio/components/gallery.json +52 -0
  120. package/templates/sveltekit/shapio/components/hero.json +108 -0
  121. package/templates/sveltekit/shapio/models/article.json +150 -0
  122. package/templates/sveltekit/shapio/models/author.json +73 -0
  123. package/templates/sveltekit/shapio/models/page.json +93 -0
  124. package/templates/sveltekit/shapio/models/siteSettings.json +87 -0
  125. package/templates/sveltekit/src/app.html +12 -0
  126. package/templates/sveltekit/src/env.ts +16 -0
  127. package/templates/sveltekit/src/hooks.server.ts +9 -0
  128. package/templates/sveltekit/src/lib/articles.ts +5 -0
  129. package/templates/sveltekit/src/lib/components/ArticleCard.svelte +21 -0
  130. package/templates/sveltekit/src/lib/components/ArticleView.svelte +43 -0
  131. package/templates/sveltekit/src/lib/components/Byline.svelte +14 -0
  132. package/templates/sveltekit/src/lib/components/CallToAction.svelte +15 -0
  133. package/templates/sveltekit/src/lib/components/FeatureGrid.svelte +22 -0
  134. package/templates/sveltekit/src/lib/components/Gallery.svelte +25 -0
  135. package/templates/sveltekit/src/lib/components/Hero.svelte +24 -0
  136. package/templates/sveltekit/src/lib/components/ResponsiveImage.svelte +30 -0
  137. package/templates/sveltekit/src/lib/components/RichText.svelte +13 -0
  138. package/templates/sveltekit/src/lib/components/Sections.svelte +30 -0
  139. package/templates/sveltekit/src/lib/preview.ts +56 -0
  140. package/templates/sveltekit/src/lib/server/config.ts +46 -0
  141. package/templates/sveltekit/src/lib/server/shapio.ts +93 -0
  142. package/templates/sveltekit/src/lib/site.css +199 -0
  143. package/templates/sveltekit/src/lib/site.ts +66 -0
  144. package/templates/sveltekit/src/lib/types.ts +96 -0
  145. package/templates/sveltekit/src/routes/+layout.ts +3 -0
  146. package/templates/sveltekit/src/routes/+page.ts +7 -0
  147. package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +16 -0
  148. package/templates/sveltekit/src/routes/[locale]/+layout.svelte +32 -0
  149. package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.server.ts +28 -0
  150. package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.svelte +15 -0
  151. package/templates/sveltekit/src/routes/[locale]/articles/+page.server.ts +9 -0
  152. package/templates/sveltekit/src/routes/[locale]/articles/+page.svelte +17 -0
  153. package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.server.ts +25 -0
  154. package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.svelte +15 -0
  155. package/templates/sveltekit/src/routes/[locale]/colophon/+page.svelte +17 -0
  156. package/templates/sveltekit/src/routes/[locale]/colophon/+page.ts +5 -0
  157. package/templates/sveltekit/src/routes/preview/+page.server.ts +5 -0
  158. package/templates/sveltekit/src/routes/preview/+page.svelte +67 -0
  159. package/templates/sveltekit/static/favicon.svg +9 -0
  160. package/templates/sveltekit/tsconfig.json +14 -0
  161. package/templates/sveltekit/vite.config.ts +63 -0
@@ -0,0 +1,396 @@
1
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { parseArgs } from 'node:util';
5
+ import {
6
+ buildUploadForm,
7
+ ShapioApiError,
8
+ type MediaAsset,
9
+ type SchemaApplyInput,
10
+ type SchemaSyncResult,
11
+ type ShapioClient,
12
+ } from '@shapio/client';
13
+ import { connectAdmin } from './lib/admin.js';
14
+ import {
15
+ AUTHOR,
16
+ entries,
17
+ IMAGES,
18
+ SITE_SETTINGS,
19
+ type EntrySpec,
20
+ type ImageSpec,
21
+ type MediaIds,
22
+ } from './lib/content.js';
23
+ import { createPng } from './lib/png.js';
24
+
25
+ /**
26
+ * `npm run seed`: prepares a Shapio instance for this starter, idempotently (run it again to reset the
27
+ * content to the seed):
28
+ * 1. the `fr` locale;
29
+ * 2. the models and components in shapio/ through the schema apply API (live, no restart; the same planner
30
+ * as `shapio schema apply` and the admin);
31
+ * 3. placeholder images, an author, the site settings, pages and articles in English and French, published
32
+ * per locale;
33
+ * 4. a deployment connection named "Preview" whose preview URL opens this starter's /preview/ page (the
34
+ * entry document's Preview pane and visual editing; it triggers no builds). The site's URL is SITE_URL, else
35
+ * the starter's `--site-url` (its local dev server);
36
+ * 5. with `--revalidate-path` (the Next.js starter's /api/revalidate), a webhook named "Site revalidation" that
37
+ * sends publish, unpublish, delete, change set and schema events to SITE_URL + that path; its signing secret
38
+ * (rotated on every run, as Shapio shows it only once) is written to .env as SHAPIO_WEBHOOK_SECRET;
39
+ * 6. a delivery role and token for the build, written with SHAPIO_URL to .env in the current directory.
40
+ */
41
+ const SCHEMA_DIR = resolve(import.meta.dirname, '..', 'shapio');
42
+ const SCHEMA_KINDS = ['models', 'components'] as const;
43
+ const SITE_MODELS = ['page', 'article', 'author', 'siteSettings'];
44
+ const DELIVERY_ROLE_KEY = 'starter-delivery';
45
+ const DELIVERY_TOKEN_NAME = 'starter build';
46
+ const PREVIEW_CONNECTION_NAME = 'Preview';
47
+ /** Where Shapio opens a draft: the starter's /preview/ page, the token in the fragment (never sent to a server). */
48
+ const PREVIEW_PATH = '/preview/?model={modelKey}&id={entryId}&locale={locale}#token={token}';
49
+ /** A placeholder build hook: the connection has no triggers, so nothing is ever sent to it. */
50
+ const PREVIEW_BUILD_HOOK = 'https://build.invalid/shapio-starter';
51
+ const REVALIDATE_WEBHOOK_NAME = 'Site revalidation';
52
+ /** The events that change what a published site shows (the starter's route ignores any others). */
53
+ const REVALIDATE_EVENTS = [
54
+ 'entry.published',
55
+ 'entry.unpublished',
56
+ 'entry.deleted',
57
+ 'change_set.shipped',
58
+ 'schema.activated',
59
+ 'schema.deleted',
60
+ ];
61
+ /** A local site needs Shapio's OUTBOUND_PRIVATE_NETWORK_ALLOWLIST too; the webhook opts in for loopback only. */
62
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
63
+ const MEDIA_READY_TIMEOUT_MS = 120_000;
64
+ const SCHEMA_CHANGE_TIMEOUT_MS = 120_000;
65
+ const POLL_INTERVAL_MS = 500;
66
+ /** No lock file: a first apply. Definitions that already match are skipped by the server. */
67
+ const FIRST_APPLY_BASE: SchemaApplyInput['base'] = { formatVersion: 1, schemaVersion: 0, definitions: {} };
68
+
69
+ const log = (line: string) => process.stdout.write(`${line}\n`);
70
+
71
+ const ensureLocale = async (client: ShapioClient) => {
72
+ const locales = await client.admin.locales.list();
73
+ if (!locales.some((locale) => locale.code === 'fr')) {
74
+ await client.admin.locales.create({ code: 'fr', label: 'Français', fallbacks: ['en'] });
75
+ log('Created the fr locale');
76
+ }
77
+ };
78
+
79
+ /** The checked-in definition files (`shapio schema pull` format), models first. */
80
+ const readDefinitions = async (): Promise<unknown[]> => {
81
+ const definitions: unknown[] = [];
82
+ for (const kind of SCHEMA_KINDS) {
83
+ const names = (await readdir(join(SCHEMA_DIR, kind))).filter((name) => name.endsWith('.json')).sort();
84
+ for (const name of names) {
85
+ definitions.push(JSON.parse(await readFile(join(SCHEMA_DIR, kind, name), 'utf8')));
86
+ }
87
+ }
88
+ return definitions;
89
+ };
90
+
91
+ /** A planned change waits for its prerequisite jobs (e.g. an index); this waits until it is live. */
92
+ const waitForChange = async (client: ShapioClient, item: SchemaSyncResult) => {
93
+ const deadline = Date.now() + SCHEMA_CHANGE_TIMEOUT_MS;
94
+ for (;;) {
95
+ const change = await client.admin.schema.change(item.changeId ?? '');
96
+ if (change.status === 'activated') {
97
+ return;
98
+ }
99
+ if (change.status === 'failed' || change.status === 'cancelled') {
100
+ throw new Error(`The ${item.apiKey} change ${change.status}: ${JSON.stringify(change.error)}`);
101
+ }
102
+ if (Date.now() > deadline) {
103
+ throw new Error(`The ${item.apiKey} change is still ${change.status}; is the Shapio worker running?`);
104
+ }
105
+ await delay(POLL_INTERVAL_MS);
106
+ }
107
+ };
108
+
109
+ const applySchema = async (client: ShapioClient) => {
110
+ const response = await client.admin.schema.apply({
111
+ definitions: await readDefinitions(),
112
+ base: FIRST_APPLY_BASE,
113
+ prune: false,
114
+ dryRun: false,
115
+ acknowledgeBreaking: false,
116
+ acknowledgeDestructive: false,
117
+ });
118
+ for (const item of response.results) {
119
+ if (item.outcome === 'pending') {
120
+ await waitForChange(client, item);
121
+ }
122
+ log(`Schema: ${item.apiKey} ${item.decision.action}`);
123
+ }
124
+ log(`Schema version ${response.schemaVersion}`);
125
+ };
126
+
127
+ const findAsset = async (client: ShapioClient, filename: string) =>
128
+ (await client.admin.media.assets.list({ search: filename, limit: 50 })).items.find(
129
+ (asset) => asset.filename === filename,
130
+ );
131
+
132
+ /** Uploads a generated PNG through the grant flow (the same for local disk and S3 storage), once. */
133
+ const ensureImage = async (client: ShapioClient, spec: ImageSpec): Promise<MediaAsset> => {
134
+ const existing = await findAsset(client, spec.filename);
135
+ if (existing) {
136
+ return existing;
137
+ }
138
+ const png = createPng(spec.width, spec.height, spec.from, spec.to);
139
+ const grant = await client.admin.media.uploads.create({
140
+ filename: spec.filename,
141
+ mimeType: 'image/png',
142
+ sizeBytes: png.length,
143
+ });
144
+ const upload = await fetch(grant.upload.url, {
145
+ method: 'POST',
146
+ body: buildUploadForm(grant, new Blob([new Uint8Array(png)], { type: 'image/png' }), spec.filename),
147
+ });
148
+ if (!upload.ok) {
149
+ throw new Error(`Uploading ${spec.filename} failed: HTTP ${upload.status} ${await upload.text()}`);
150
+ }
151
+ const asset = await client.admin.media.uploads.confirm(grant.grantId);
152
+ const updated = await client.admin.media.assets.update(asset.id, {
153
+ expectedVersion: asset.version,
154
+ alt: spec.alt,
155
+ });
156
+ log(`Uploaded ${spec.filename}`);
157
+ return updated;
158
+ };
159
+
160
+ /** Variants are rendered by a background job; the site's srcsets need them. */
161
+ const waitForMedia = async (client: ShapioClient, ids: readonly string[]) => {
162
+ const deadline = Date.now() + MEDIA_READY_TIMEOUT_MS;
163
+ for (;;) {
164
+ const assets = await Promise.all(ids.map((id) => client.admin.media.assets.get(id)));
165
+ if (assets.every((asset) => asset.status !== 'processing')) {
166
+ return;
167
+ }
168
+ if (Date.now() > deadline) {
169
+ throw new Error('Media processing did not finish in time; is the Shapio worker running?');
170
+ }
171
+ await delay(500);
172
+ }
173
+ };
174
+
175
+ const ensureMedia = async (client: ShapioClient): Promise<MediaIds> => {
176
+ const ids = {} as MediaIds;
177
+ for (const [key, spec] of Object.entries(IMAGES) as Array<[keyof MediaIds, ImageSpec]>) {
178
+ ids[key] = (await ensureImage(client, spec)).id;
179
+ }
180
+ await waitForMedia(client, Object.values(ids));
181
+ return ids;
182
+ };
183
+
184
+ const ensureAuthor = async (client: ShapioClient, avatar: string) => {
185
+ const page = await client.admin.content.list('author', { pageSize: 100 });
186
+ const existing = page.items.find((item) => item.data.name === AUTHOR.name);
187
+ const data = { ...AUTHOR, avatar };
188
+ const entry = existing
189
+ ? await client.admin.content.update('author', existing.id, { expectedVersion: existing.version, data })
190
+ : await client.admin.content.create('author', { data });
191
+ await client.admin.content.publish('author', entry.id);
192
+ return entry.id;
193
+ };
194
+
195
+ /** The locale's draft version, or null when the entry has none in that locale yet. */
196
+ const versionIn = async (client: ShapioClient, model: string, id: string, locale: string) => {
197
+ try {
198
+ return (await client.admin.content.get(model, id, { locale })).version;
199
+ } catch (error) {
200
+ if (error instanceof ShapioApiError && error.status === 404) {
201
+ return null;
202
+ }
203
+ throw error;
204
+ }
205
+ };
206
+
207
+ /** The singleton's one entry, created on the first run, then updated in both locales and published. */
208
+ const ensureSiteSettings = async (client: ShapioClient) => {
209
+ const existing = (await client.admin.content.list('siteSettings', { locale: 'en' })).items[0];
210
+ const id = existing
211
+ ? (
212
+ await client.admin.content.update('siteSettings', existing.id, {
213
+ locale: 'en',
214
+ expectedVersion: await versionIn(client, 'siteSettings', existing.id, 'en'),
215
+ data: SITE_SETTINGS.en,
216
+ })
217
+ ).id
218
+ : (await client.admin.content.create('siteSettings', { locale: 'en', data: SITE_SETTINGS.en })).id;
219
+ await client.admin.content.update('siteSettings', id, {
220
+ locale: 'fr',
221
+ expectedVersion: await versionIn(client, 'siteSettings', id, 'fr'),
222
+ data: SITE_SETTINGS.fr,
223
+ });
224
+ await client.admin.content.publish('siteSettings', id, { locales: ['en', 'fr'] });
225
+ log('Published siteSettings (en, fr)');
226
+ };
227
+
228
+ const upsertEntry = async (client: ShapioClient, spec: EntrySpec) => {
229
+ const found = await client.admin.content.list(spec.model, {
230
+ filters: { slug: { $eq: spec.slug } },
231
+ locale: 'en',
232
+ });
233
+ let id = found.items[0]?.id;
234
+ if (id) {
235
+ const version = await versionIn(client, spec.model, id, 'en');
236
+ await client.admin.content.update(spec.model, id, {
237
+ locale: 'en',
238
+ expectedVersion: version,
239
+ data: spec.content.en,
240
+ });
241
+ } else {
242
+ id = (await client.admin.content.create(spec.model, { locale: 'en', data: spec.content.en })).id;
243
+ }
244
+ const frVersion = await versionIn(client, spec.model, id, 'fr');
245
+ await client.admin.content.update(spec.model, id, {
246
+ locale: 'fr',
247
+ expectedVersion: frVersion,
248
+ data: spec.content.fr,
249
+ });
250
+ if (spec.publish) {
251
+ await client.admin.content.publish(spec.model, id, { locales: ['en', 'fr'] });
252
+ }
253
+ log(`${spec.publish ? 'Published' : 'Saved draft'} ${spec.model} ${spec.slug} (en, fr)`);
254
+ };
255
+
256
+ /** A delivery role that reads the site's models, and a fresh token bound to it (older ones are revoked). */
257
+ const createDeliveryToken = async (client: ShapioClient) => {
258
+ const summary = await client.admin.schema.summary();
259
+ const modelIds = summary.definitions
260
+ .filter((definition) => SITE_MODELS.includes(definition.apiKey))
261
+ .map((definition) => definition.id);
262
+ const permissions = modelIds.map((modelId) => ({
263
+ action: 'read' as const,
264
+ modelId,
265
+ condition: null,
266
+ fieldIds: null,
267
+ }));
268
+ const roles = await client.admin.roles.list();
269
+ const existing = roles.find((role) => role.key === DELIVERY_ROLE_KEY);
270
+ const role = existing
271
+ ? await client.admin.roles.update(existing.id, { expectedVersion: existing.version, permissions })
272
+ : await client.admin.roles.create({
273
+ key: DELIVERY_ROLE_KEY,
274
+ name: 'Starter site (delivery)',
275
+ description: 'Reads published pages, articles, authors and the site settings for the starter build.',
276
+ kind: 'delivery',
277
+ permissions,
278
+ });
279
+ for (const token of await client.admin.tokens.list()) {
280
+ if (token.name === DELIVERY_TOKEN_NAME && !token.revokedAt) {
281
+ await client.admin.tokens.revoke(token.id);
282
+ }
283
+ }
284
+ return (await client.admin.tokens.create({ name: DELIVERY_TOKEN_NAME, roleId: role.id })).token;
285
+ };
286
+
287
+ /** The "Preview" connection, created once and pointed at this starter's /preview/ page on every run. */
288
+ const ensurePreviewConnection = async (client: ShapioClient, siteUrl: string) => {
289
+ const previewUrlTemplate = `${siteUrl.replace(/\/+$/, '')}${PREVIEW_PATH}`;
290
+ const existing = (await client.admin.deployments.connections.list()).find(
291
+ (connection) => connection.name === PREVIEW_CONNECTION_NAME,
292
+ );
293
+ if (existing) {
294
+ await client.admin.deployments.connections.update(existing.id, {
295
+ expectedVersion: existing.version,
296
+ previewUrlTemplate,
297
+ });
298
+ } else {
299
+ await client.admin.deployments.connections.create({
300
+ name: PREVIEW_CONNECTION_NAME,
301
+ provider: 'generic_webhook',
302
+ settings: { url: PREVIEW_BUILD_HOOK },
303
+ secrets: {},
304
+ triggerPolicy: [],
305
+ previewUrlTemplate,
306
+ });
307
+ }
308
+ log(`Preview opens ${previewUrlTemplate}`);
309
+ };
310
+
311
+ /** The "Site revalidation" webhook, created once and pointed at the site on every run; returns a fresh secret. */
312
+ const ensureRevalidateWebhook = async (client: ShapioClient, siteUrl: string, path: string) => {
313
+ const url = `${siteUrl.replace(/\/+$/, '')}${path}`;
314
+ const allowPrivateNetwork = LOOPBACK_HOSTS.has(new URL(url).hostname);
315
+ const existing = (await client.admin.webhooks.list()).find(
316
+ (webhook) => webhook.name === REVALIDATE_WEBHOOK_NAME,
317
+ );
318
+ const settings = { url, events: REVALIDATE_EVENTS, enabled: true, allowPrivateNetwork };
319
+ let created;
320
+ if (existing) {
321
+ await client.admin.webhooks.update(existing.id, { expectedVersion: existing.version, ...settings });
322
+ created = await client.admin.webhooks.rotateSecret(existing.id);
323
+ } else {
324
+ created = await client.admin.webhooks.create({ name: REVALIDATE_WEBHOOK_NAME, ...settings });
325
+ }
326
+ log(`Webhook "${REVALIDATE_WEBHOOK_NAME}" calls ${url}`);
327
+ return created.secret;
328
+ };
329
+
330
+ /**
331
+ * Shapio refuses a webhook to a loopback or private address unless its OUTBOUND_PRIVATE_NETWORK_ALLOWLIST
332
+ * covers it. The site still builds and serves without the webhook, so the seed says how to turn it on and
333
+ * carries on; the revalidation route answers 503 until SHAPIO_WEBHOOK_SECRET is set.
334
+ */
335
+ const tryRevalidateWebhook = async (client: ShapioClient, siteUrl: string, path: string) => {
336
+ try {
337
+ return await ensureRevalidateWebhook(client, siteUrl, path);
338
+ } catch (error) {
339
+ if (error instanceof ShapioApiError && error.code === 'DESTINATION_NOT_ALLOWED') {
340
+ log(
341
+ `Skipped the "${REVALIDATE_WEBHOOK_NAME}" webhook: ${error.message} For a local site, start Shapio with ` +
342
+ 'OUTBOUND_PRIVATE_NETWORK_ALLOWLIST=127.0.0.1/32,::1/128 and run the seed again.',
343
+ );
344
+ return undefined;
345
+ }
346
+ throw error;
347
+ }
348
+ };
349
+
350
+ const writeEnv = async (url: string, deliveryToken: string, webhookSecret: string | undefined) => {
351
+ const path = resolve('.env');
352
+ const lines = [
353
+ '# Written by the seed. The delivery token is read-only; keep this file out of git.',
354
+ `SHAPIO_URL=${url}`,
355
+ `SHAPIO_DELIVERY_TOKEN=${deliveryToken}`,
356
+ ...(webhookSecret ? [`SHAPIO_WEBHOOK_SECRET=${webhookSecret}`] : []),
357
+ ];
358
+ await writeFile(path, `${lines.join('\n')}\n`, { mode: 0o600 });
359
+ log(
360
+ `Wrote SHAPIO_URL, SHAPIO_DELIVERY_TOKEN${webhookSecret ? ' and SHAPIO_WEBHOOK_SECRET' : ''} to ${path}`,
361
+ );
362
+ };
363
+
364
+ const main = async () => {
365
+ const { values } = parseArgs({
366
+ options: { 'site-url': { type: 'string' }, 'revalidate-path': { type: 'string' } },
367
+ });
368
+ const siteUrl = process.env.SITE_URL || values['site-url'] || 'http://localhost:4321';
369
+ const admin = await connectAdmin(process.env);
370
+ try {
371
+ await ensureLocale(admin.client);
372
+ await applySchema(admin.client);
373
+ const media = await ensureMedia(admin.client);
374
+ await ensureSiteSettings(admin.client);
375
+ const authorId = await ensureAuthor(admin.client, media.avatar);
376
+ for (const spec of entries(media, authorId)) {
377
+ await upsertEntry(admin.client, spec);
378
+ }
379
+ await ensurePreviewConnection(admin.client, siteUrl);
380
+ const revalidatePath = values['revalidate-path'];
381
+ const webhookSecret = revalidatePath
382
+ ? await tryRevalidateWebhook(admin.client, siteUrl, revalidatePath)
383
+ : undefined;
384
+ await writeEnv(admin.url, await createDeliveryToken(admin.client), webhookSecret);
385
+ log('Seeded. Build the site with: npm run build');
386
+ } finally {
387
+ await admin.close();
388
+ }
389
+ };
390
+
391
+ main().catch((error: unknown) => {
392
+ process.stderr.write(
393
+ `seed failed: ${error instanceof ShapioApiError ? `${error.code}: ${error.message} ${JSON.stringify(error.details ?? '')}` : error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
394
+ );
395
+ process.exitCode = 1;
396
+ });
@@ -0,0 +1,162 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { parseArgs } from 'node:util';
3
+
4
+ /**
5
+ * `npm run smoke`, after `npm run seed`, `npm run build` and starting the built site (`npm run start` or
6
+ * `npm run preview`): fetches the site's pages over HTTP and checks the seeded content in English and French,
7
+ * the site settings singleton, rich text, media variants and the author, that the draft article is not
8
+ * served, that content carries the visual-editing attributes (@shapio/visual's `shapioAttr`), and that the
9
+ * preview page is served with a `frame-ancestors` policy. The same checks run against every starter, so all
10
+ * three render the same blog.
11
+ *
12
+ * `--url` is the running site's origin (default: SMOKE_URL, then http://localhost:4321). `--revalidate` (the
13
+ * Next.js starter) also checks its on-demand revalidation route: an unsigned POST to /api/revalidate is refused,
14
+ * and an event signed with SHAPIO_WEBHOOK_SECRET is answered 200 with the paths it revalidated.
15
+ */
16
+ const DRAFT_TITLE = 'Coming soon: our winter projects';
17
+ const ARTICLE = 'modelling-without-a-deploy';
18
+
19
+ type PageCheck = { path: string; expect: Array<[needle: string, label: string]> };
20
+
21
+ const PAGES: readonly PageCheck[] = [
22
+ {
23
+ path: '/en/',
24
+ expect: [
25
+ ['Content that changes as fast as your product', 'hero heading'],
26
+ ['Static delivery', 'feature grid'],
27
+ ['From the studio', 'gallery'],
28
+ ['.webp', 'responsive image variants'],
29
+ ['Northwind Studio', 'site name from the siteSettings singleton'],
30
+ ['Built with Shapio', 'footer from the siteSettings singleton'],
31
+ ],
32
+ },
33
+ {
34
+ path: '/fr/',
35
+ expect: [
36
+ ['Un contenu qui évolue aussi vite que votre produit', 'hero heading'],
37
+ ['lang="fr"', 'lang="fr"'],
38
+ ['Studio Northwind', 'site name from the siteSettings singleton'],
39
+ ],
40
+ },
41
+ {
42
+ path: '/en/articles/',
43
+ expect: [
44
+ ['Modelling content without a deploy', 'article list'],
45
+ ['Why our builds pin a snapshot', 'second article'],
46
+ ],
47
+ },
48
+ {
49
+ path: `/en/articles/${ARTICLE}/`,
50
+ expect: [
51
+ ['Modelling content without a deploy', 'title'],
52
+ ['<table', 'rich-text table'],
53
+ ['With Shapio', 'rich-text table cell'],
54
+ ['Ada Moreau', 'populated author'],
55
+ ['data-shapio-entry="', 'visual-editing entry attribute'],
56
+ ['data-shapio-path="title"', 'visual-editing attribute on the title'],
57
+ ['data-shapio-path="body"', 'visual-editing attribute on the body'],
58
+ ],
59
+ },
60
+ { path: `/fr/articles/${ARTICLE}/`, expect: [['Modéliser du contenu sans déploiement', 'title']] },
61
+ { path: '/fr/about/', expect: [['À propos du studio', 'page from the page collection']] },
62
+ { path: '/en/colophon/', expect: [['pinned to one publication snapshot per build', 'singleton page']] },
63
+ ];
64
+
65
+ const failures: string[] = [];
66
+ const check = (condition: boolean, message: string) => {
67
+ process.stdout.write(`${condition ? 'ok ' : 'FAIL'} ${message}\n`);
68
+ if (!condition) {
69
+ failures.push(message);
70
+ }
71
+ };
72
+
73
+ const fetchPage = async (origin: string, path: string) => {
74
+ const response = await fetch(new URL(path, origin));
75
+ return { status: response.status, html: await response.text() };
76
+ };
77
+
78
+ const REVALIDATE_PATH = '/api/revalidate/';
79
+
80
+ const postEvent = (origin: string, body: string, headers: Record<string, string>) =>
81
+ fetch(new URL(REVALIDATE_PATH, origin), {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json', ...headers },
84
+ body,
85
+ });
86
+
87
+ /** Signed like Shapio's webhooks: `v1=<hex HMAC-SHA256(secret, "<timestamp>.<body>")>`. */
88
+ const signatureHeaders = (secret: string, body: string) => {
89
+ const timestamp = String(Math.floor(Date.now() / 1000));
90
+ const signature = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
91
+ return { 'x-shapio-timestamp': timestamp, 'x-shapio-signature': `v1=${signature}` };
92
+ };
93
+
94
+ const checkRevalidation = async (origin: string) => {
95
+ const secret = process.env.SHAPIO_WEBHOOK_SECRET;
96
+ if (!secret) {
97
+ check(
98
+ false,
99
+ `${REVALIDATE_PATH}: SHAPIO_WEBHOOK_SECRET is set (the seed writes it with --revalidate-path)`,
100
+ );
101
+ return;
102
+ }
103
+ const body = JSON.stringify({
104
+ id: `smoke-${Date.now()}`,
105
+ type: 'entry.published',
106
+ createdAt: new Date().toISOString(),
107
+ site: null,
108
+ data: { modelKey: 'article', entryId: 'smoke', locale: 'en' },
109
+ });
110
+ const unsigned = await postEvent(origin, body, {});
111
+ check(
112
+ unsigned.status === 401,
113
+ `${REVALIDATE_PATH}: an unsigned event is refused (HTTP ${unsigned.status})`,
114
+ );
115
+ const response = await postEvent(origin, body, signatureHeaders(secret, body));
116
+ const text = await response.text();
117
+ check(response.status === 200, `${REVALIDATE_PATH}: a signed event is accepted (HTTP ${response.status})`);
118
+ let result: { revalidated?: unknown; skipped?: unknown; from?: unknown; to?: unknown } = {};
119
+ try {
120
+ result = JSON.parse(text) as typeof result;
121
+ } catch {
122
+ // Reported by the check below.
123
+ }
124
+ const listed = Array.isArray(result.revalidated) || typeof result.skipped === 'string';
125
+ check(listed, `${REVALIDATE_PATH}: the answer lists the revalidated paths: ${text}`);
126
+ };
127
+
128
+ const main = async () => {
129
+ const { values } = parseArgs({
130
+ options: { url: { type: 'string' }, revalidate: { type: 'boolean', default: false } },
131
+ });
132
+ const origin = values.url ?? process.env.SMOKE_URL ?? 'http://localhost:4321';
133
+ for (const page of PAGES) {
134
+ const { status, html } = await fetchPage(origin, page.path);
135
+ check(status === 200, `${page.path}: HTTP ${status}`);
136
+ for (const [needle, label] of page.expect) {
137
+ check(html.includes(needle), `${page.path}: ${label}`);
138
+ }
139
+ }
140
+ const preview = await fetch(new URL('/preview/', origin));
141
+ check(preview.status === 200, `/preview/: HTTP ${preview.status}`);
142
+ check(
143
+ /frame-ancestors/.test(preview.headers.get('content-security-policy') ?? ''),
144
+ '/preview/: frame-ancestors policy (only the site and Shapio may frame it)',
145
+ );
146
+ const list = await fetchPage(origin, '/en/articles/');
147
+ check(!list.html.includes(DRAFT_TITLE), 'the unpublished draft is not listed');
148
+ const draft = await fetchPage(origin, '/en/articles/winter-projects/');
149
+ check(draft.status === 404, `the unpublished draft is not served (HTTP ${draft.status})`);
150
+ if (values.revalidate) {
151
+ await checkRevalidation(origin);
152
+ }
153
+ if (failures.length > 0) {
154
+ throw new Error(`${failures.length} check(s) failed against ${origin}`);
155
+ }
156
+ process.stdout.write(`Smoke test passed against ${origin}.\n`);
157
+ };
158
+
159
+ main().catch((error: unknown) => {
160
+ process.stderr.write(`smoke: ${error instanceof Error ? error.message : String(error)}\n`);
161
+ process.exitCode = 1;
162
+ });
@@ -0,0 +1,85 @@
1
+ {
2
+ "apiKey": "callToAction",
3
+ "category": "Sections",
4
+ "display": {},
5
+ "fields": [
6
+ {
7
+ "apiKey": "heading",
8
+ "deprecated": false,
9
+ "editor": {
10
+ "id": "textInput",
11
+ "options": {}
12
+ },
13
+ "filterable": false,
14
+ "id": "f4022efd-611f-46da-96da-1f0a550fcd98",
15
+ "label": "Heading",
16
+ "localized": false,
17
+ "public": true,
18
+ "required": true,
19
+ "settings": {},
20
+ "sortable": false,
21
+ "type": "string",
22
+ "unique": false
23
+ },
24
+ {
25
+ "apiKey": "text",
26
+ "deprecated": false,
27
+ "editor": {
28
+ "id": "textarea",
29
+ "options": {}
30
+ },
31
+ "filterable": false,
32
+ "id": "9a5169a7-3116-496f-8073-806e80d02053",
33
+ "label": "Text",
34
+ "localized": false,
35
+ "public": true,
36
+ "required": false,
37
+ "settings": {},
38
+ "sortable": false,
39
+ "type": "text",
40
+ "unique": false
41
+ },
42
+ {
43
+ "apiKey": "buttonLabel",
44
+ "deprecated": false,
45
+ "editor": {
46
+ "id": "textInput",
47
+ "options": {}
48
+ },
49
+ "filterable": false,
50
+ "id": "377c9217-615d-4928-b2a3-621e6ea51aa5",
51
+ "label": "Button label",
52
+ "localized": false,
53
+ "public": true,
54
+ "required": true,
55
+ "settings": {},
56
+ "sortable": false,
57
+ "type": "string",
58
+ "unique": false
59
+ },
60
+ {
61
+ "apiKey": "buttonUrl",
62
+ "deprecated": false,
63
+ "description": "A full URL, a mailto: or tel: link, or a path on this site such as /en/articles/",
64
+ "editor": {
65
+ "id": "textInput",
66
+ "options": {}
67
+ },
68
+ "filterable": false,
69
+ "id": "fb10a97c-2199-4d5f-99e0-8ea83313a4f6",
70
+ "label": "Button link",
71
+ "localized": false,
72
+ "public": true,
73
+ "required": true,
74
+ "settings": {
75
+ "pattern": "^(https?://|mailto:|tel:|/)"
76
+ },
77
+ "sortable": false,
78
+ "type": "string",
79
+ "unique": false
80
+ }
81
+ ],
82
+ "id": "be8255cc-2588-4204-950e-ad3d84db4f2e",
83
+ "kind": "component",
84
+ "label": "Call to action"
85
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "apiKey": "feature",
3
+ "category": "Marketing",
4
+ "display": {},
5
+ "fields": [
6
+ {
7
+ "apiKey": "title",
8
+ "deprecated": false,
9
+ "editor": {
10
+ "id": "textInput",
11
+ "options": {}
12
+ },
13
+ "filterable": false,
14
+ "id": "11cba9b7-59b3-4f79-b6c0-4e6b1e1e732e",
15
+ "label": "Title",
16
+ "localized": false,
17
+ "public": true,
18
+ "required": true,
19
+ "settings": {},
20
+ "sortable": false,
21
+ "type": "string",
22
+ "unique": false
23
+ },
24
+ {
25
+ "apiKey": "description",
26
+ "deprecated": false,
27
+ "editor": {
28
+ "id": "textarea",
29
+ "options": {}
30
+ },
31
+ "filterable": false,
32
+ "id": "c14bbc67-0f0f-4455-8e4c-443d4e3ed4b2",
33
+ "label": "Description",
34
+ "localized": false,
35
+ "public": true,
36
+ "required": false,
37
+ "settings": {},
38
+ "sortable": false,
39
+ "type": "text",
40
+ "unique": false
41
+ }
42
+ ],
43
+ "id": "66138d48-8c29-499e-ba51-86e5fb1b372b",
44
+ "kind": "component",
45
+ "label": "Feature"
46
+ }