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,87 @@
1
+ {
2
+ "apiKey": "siteSettings",
3
+ "display": {
4
+ "titleFieldId": "e54b64bb-62c9-4a9c-8842-f8af5151feac"
5
+ },
6
+ "draftAndPublish": true,
7
+ "fields": [
8
+ {
9
+ "apiKey": "siteName",
10
+ "deprecated": false,
11
+ "editor": {
12
+ "id": "textInput",
13
+ "options": {}
14
+ },
15
+ "filterable": false,
16
+ "id": "e54b64bb-62c9-4a9c-8842-f8af5151feac",
17
+ "label": "Site name",
18
+ "localized": true,
19
+ "public": true,
20
+ "required": true,
21
+ "settings": {},
22
+ "sortable": false,
23
+ "type": "string",
24
+ "unique": false
25
+ },
26
+ {
27
+ "apiKey": "tagline",
28
+ "deprecated": false,
29
+ "editor": {
30
+ "id": "textarea",
31
+ "options": {}
32
+ },
33
+ "filterable": false,
34
+ "id": "51448036-f103-46fa-8e2c-25e56e1c25fd",
35
+ "label": "Tagline",
36
+ "localized": true,
37
+ "public": true,
38
+ "required": false,
39
+ "settings": {},
40
+ "sortable": false,
41
+ "type": "text",
42
+ "unique": false
43
+ },
44
+ {
45
+ "apiKey": "footer",
46
+ "deprecated": false,
47
+ "editor": {
48
+ "id": "textInput",
49
+ "options": {}
50
+ },
51
+ "filterable": false,
52
+ "id": "49c371ca-eb2f-4bed-b831-6dbc74ea6628",
53
+ "label": "Footer text",
54
+ "localized": true,
55
+ "public": true,
56
+ "required": false,
57
+ "settings": {},
58
+ "sortable": false,
59
+ "type": "string",
60
+ "unique": false
61
+ },
62
+ {
63
+ "apiKey": "colophon",
64
+ "deprecated": false,
65
+ "editor": {
66
+ "id": "richText",
67
+ "options": {}
68
+ },
69
+ "filterable": false,
70
+ "id": "797ba365-a0fe-4572-a90f-9b524d12dc06",
71
+ "label": "Colophon",
72
+ "localized": true,
73
+ "public": true,
74
+ "required": false,
75
+ "settings": {
76
+ "formatVersion": 1
77
+ },
78
+ "sortable": false,
79
+ "type": "richtext",
80
+ "unique": false
81
+ }
82
+ ],
83
+ "id": "a1471f8b-5ee4-4a27-a7e0-934bb872b479",
84
+ "kind": "singleton",
85
+ "label": "Site settings",
86
+ "localized": true
87
+ }
@@ -0,0 +1,52 @@
1
+ ---
2
+ import '../styles/site.css';
3
+ import { getSiteSettings } from '../lib/shapio.js';
4
+ import { articlesPath, colophonPath, HOME_SLUG, otherLocale, pagePath, stringsFor, type Locale } from '../lib/site.js';
5
+
6
+ type Props = {
7
+ title: string;
8
+ description?: string | null;
9
+ locale: Locale;
10
+ /** This page in the other locale. */
11
+ alternatePath?: string;
12
+ noindex?: boolean;
13
+ };
14
+
15
+ const { title, description, locale, alternatePath, noindex = false } = Astro.props;
16
+ const strings = stringsFor(locale);
17
+ const other = otherLocale(locale);
18
+ // The name, tagline and footer come from the siteSettings singleton (the built-in strings until it is published).
19
+ const settings = await getSiteSettings(locale);
20
+ const siteName = settings?.siteName ?? strings.siteName;
21
+ ---
22
+
23
+ <!doctype html>
24
+ <html lang={locale}>
25
+ <head>
26
+ <meta charset="utf-8" />
27
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
28
+ <title>{title} · {siteName}</title>
29
+ {description ? <meta name="description" content={description} /> : null}
30
+ {noindex ? <meta name="robots" content="noindex" /> : null}
31
+ {alternatePath ? <link rel="alternate" hreflang={other} href={alternatePath} /> : null}
32
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
33
+ </head>
34
+ <body>
35
+ <a class="skip" href="#content">{strings.skipToContent}</a>
36
+ <header class="site-header">
37
+ <a class="brand" href={pagePath(locale, HOME_SLUG)}>{siteName}</a>
38
+ {settings?.tagline ? <p class="tagline">{settings.tagline}</p> : null}
39
+ <nav aria-label="Main">
40
+ <a href={articlesPath(locale)}>{strings.articles}</a>
41
+ <a href={colophonPath(locale)}>{strings.colophon}</a>
42
+ <a href={alternatePath ?? pagePath(other, HOME_SLUG)} hreflang={other} lang={other}>{strings.switchTo}</a>
43
+ </nav>
44
+ </header>
45
+ <main id="content">
46
+ <slot />
47
+ </main>
48
+ <footer class="site-footer">
49
+ <p>{siteName} · {settings?.footer ?? 'Built with Shapio'} · Astro</p>
50
+ </footer>
51
+ </body>
52
+ </html>
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Build-time settings, from the environment (or `.env` in this folder, which `npm run seed` writes):
3
+ * - SHAPIO_URL: the Shapio origin (with BASE_PATH), e.g. https://cms.example.com.
4
+ * - SHAPIO_DELIVERY_TOKEN: a read-only delivery token (Settings → API tokens, delivery role).
5
+ * - SHAPIO_SNAPSHOT: optional; the publication snapshot to build. A generic-webhook trigger carries it;
6
+ * without it the build pins the latest snapshot when it starts.
7
+ * - SHAPIO_SITE: optional; the site key on a multi-site Shapio (default: the token's site, else the primary).
8
+ * - PUBLIC_SHAPIO_URL: the origin the browser calls for previews (defaults to SHAPIO_URL).
9
+ */
10
+ const read = (name: string): string | undefined => {
11
+ const fromVite = (import.meta as { env?: Record<string, string | undefined> }).env?.[name];
12
+ const value = fromVite ?? process.env[name];
13
+ return value === undefined || value === '' ? undefined : value;
14
+ };
15
+
16
+ export const shapioUrl = () => read('SHAPIO_URL') ?? 'http://localhost:4300';
17
+
18
+ export const deliveryToken = () => {
19
+ const token = read('SHAPIO_DELIVERY_TOKEN');
20
+ if (!token) {
21
+ throw new Error(
22
+ 'Set SHAPIO_DELIVERY_TOKEN (a Shapio delivery token) to build the site; `npm run seed` writes one to .env',
23
+ );
24
+ }
25
+ return token;
26
+ };
27
+
28
+ export const configuredSnapshot = (): number | undefined => {
29
+ const value = read('SHAPIO_SNAPSHOT');
30
+ if (value === undefined) {
31
+ return undefined;
32
+ }
33
+ const snapshot = Number(value);
34
+ if (!Number.isInteger(snapshot) || snapshot < 0) {
35
+ throw new Error(`SHAPIO_SNAPSHOT must be a publication snapshot number, got "${value}"`);
36
+ }
37
+ return snapshot;
38
+ };
39
+
40
+ export const publicShapioUrl = () => read('PUBLIC_SHAPIO_URL') ?? shapioUrl();
41
+
42
+ export const siteKey = () => read('SHAPIO_SITE');
@@ -0,0 +1,57 @@
1
+ import { createClient } from '@shapio/client';
2
+ import { renderArticle, renderPage } from './render.js';
3
+ import { DEFAULT_LOCALE, stringsFor } from './site.js';
4
+ import type { Article, DeliveryItem, Page } from './types.js';
5
+
6
+ /**
7
+ * Preview: Shapio opens `/preview/?model={modelKey}&id={entryId}&locale={locale}#token={token}` (the
8
+ * connection's preview URL template). `{modelKey}` is the model's route key, as in the delivery and preview
9
+ * APIs: the plural API ID of a collection (`pages`, `articles`). The token sits in the fragment, so it never reaches a server log or a
10
+ * Referer header; it is a scoped, expiring preview token, never an admin credential. The draft is read from
11
+ * `/api/preview/content/...` and rendered with the same functions as the published pages.
12
+ */
13
+ export const PREVIEW_MODELS = ['pages', 'articles'] as const;
14
+
15
+ export type PreviewRequest = {
16
+ token: string;
17
+ modelKey: (typeof PREVIEW_MODELS)[number];
18
+ entryId: string;
19
+ locale: string;
20
+ };
21
+
22
+ /** Reads the request from a preview URL; undefined when something is missing or unknown. */
23
+ export const parsePreviewUrl = (url: URL): PreviewRequest | undefined => {
24
+ const fragment = new URLSearchParams(url.hash.slice(1));
25
+ const token = fragment.get('token') ?? url.searchParams.get('token');
26
+ const modelKey = url.searchParams.get('model');
27
+ const entryId = url.searchParams.get('id');
28
+ if (!token || !entryId || !(PREVIEW_MODELS as readonly string[]).includes(modelKey ?? '')) {
29
+ return undefined;
30
+ }
31
+ return {
32
+ token,
33
+ modelKey: modelKey as PreviewRequest['modelKey'],
34
+ entryId,
35
+ locale: url.searchParams.get('locale') ?? DEFAULT_LOCALE,
36
+ };
37
+ };
38
+
39
+ /** Fetches the draft through the preview API and renders it. */
40
+ export const loadPreview = async (
41
+ shapioUrl: string,
42
+ request: PreviewRequest,
43
+ ): Promise<{ html: string; title: string }> => {
44
+ const client = createClient({ baseUrl: shapioUrl, token: request.token });
45
+ const params = new URLSearchParams({
46
+ locale: request.locale,
47
+ ...(request.modelKey === 'articles' ? { populate: 'author' } : {}),
48
+ });
49
+ const { data } = await client.request<DeliveryItem<Page | Article>>(
50
+ `/api/preview/content/${encodeURIComponent(request.modelKey)}/${encodeURIComponent(request.entryId)}?${params.toString()}`,
51
+ );
52
+ const context = { locale: request.locale, strings: stringsFor(request.locale) };
53
+ return {
54
+ title: data.title,
55
+ html: request.modelKey === 'pages' ? renderPage(data as Page) : renderArticle(data as Article, context),
56
+ };
57
+ };
@@ -0,0 +1,146 @@
1
+ import { shapioAttr, type EntryRef } from '@shapio/visual';
2
+ import { articlePath, formatDate, type Strings } from './site.js';
3
+ import type { Article, Author, Media, Section } from './types.js';
4
+
5
+ /**
6
+ * HTML for the site's content, as plain functions: the static pages render with them at build time and the
7
+ * preview page renders drafts with the same functions in the browser, so a preview looks exactly like the
8
+ * published page. Every string from the CMS is escaped; rich text arrives as Shapio's sanitised `html`.
9
+ */
10
+ export type RenderContext = { locale: string; strings: Strings };
11
+
12
+ const ESCAPES: Readonly<Record<string, string>> = {
13
+ '&': '&amp;',
14
+ '<': '&lt;',
15
+ '>': '&gt;',
16
+ '"': '&quot;',
17
+ "'": '&#39;',
18
+ };
19
+
20
+ export const escapeHtml = (value: string): string =>
21
+ value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
22
+
23
+ /** Links from content: web, mail, phone and site-relative only (a `javascript:` URL renders as `#`). */
24
+ export const safeHref = (value: string | null | undefined): string => {
25
+ const href = (value ?? '').trim();
26
+ return /^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i.test(href) ? escapeHtml(href) : '#';
27
+ };
28
+
29
+ /**
30
+ * `shapioAttr` as an HTML attribute string: the field an element shows, so Shapio's preview can take a click on
31
+ * it back to that field (@shapio/visual). Harmless on published pages (entry IDs and API IDs only).
32
+ */
33
+ export const visualAttributes = (entry: EntryRef, path: string): string =>
34
+ Object.entries(shapioAttr(entry, path))
35
+ .map(([name, value]) => ` ${name}="${escapeHtml(value)}"`)
36
+ .join('');
37
+
38
+ const paragraph = (text: string | null | undefined, className = '') =>
39
+ text ? `<p${className ? ` class="${className}"` : ''}>${escapeHtml(text)}</p>` : '';
40
+
41
+ type ImageOptions = { sizes: string; eager?: boolean; className?: string; attributes?: string };
42
+
43
+ /** A responsive `<img>`: WebP variants in `srcset`, intrinsic size set (no layout shift), lazy unless eager. */
44
+ export const responsiveImage = (
45
+ media: Media,
46
+ { sizes, eager = false, className, attributes: extra = '' }: ImageOptions,
47
+ ): string => {
48
+ const widths = media.variants
49
+ .filter((variant) => variant.name.startsWith('w') && variant.width !== null)
50
+ .sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
51
+ const largest = widths.at(-1);
52
+ const srcset = widths.map((variant) => `${variant.url} ${variant.width}w`);
53
+ const attributes = [
54
+ `src="${escapeHtml(largest?.url ?? media.url)}"`,
55
+ srcset.length > 0 ? `srcset="${escapeHtml(srcset.join(', '))}" sizes="${escapeHtml(sizes)}"` : '',
56
+ media.width && media.height ? `width="${media.width}" height="${media.height}"` : '',
57
+ `alt="${escapeHtml(media.alt)}"`,
58
+ eager ? 'loading="eager" fetchpriority="high"' : 'loading="lazy"',
59
+ 'decoding="async"',
60
+ className ? `class="${className}"` : '',
61
+ ];
62
+ return `<img ${attributes.filter(Boolean).join(' ')}${extra}>`;
63
+ };
64
+
65
+ const renderSection = (section: Section, index: number, entry: EntryRef): string => {
66
+ switch (section.__component) {
67
+ case 'hero': {
68
+ const heading = index === 0 ? 'h1' : 'h2';
69
+ return `<section class="hero">
70
+ <div class="hero-text">
71
+ <${heading}${visualAttributes(entry, `sections/${index}/heading`)}>${escapeHtml(section.heading)}</${heading}>
72
+ ${paragraph(section.subheading, 'lead')}
73
+ ${section.ctaLabel && section.ctaUrl ? `<a class="button" href="${safeHref(section.ctaUrl)}">${escapeHtml(section.ctaLabel)}</a>` : ''}
74
+ </div>
75
+ ${section.image ? responsiveImage(section.image, { sizes: '(min-width: 64rem) 50vw, 100vw', eager: index === 0, className: 'hero-image' }) : ''}
76
+ </section>`;
77
+ }
78
+ case 'featureGrid':
79
+ return `<section class="features">
80
+ ${section.heading ? `<h2>${escapeHtml(section.heading)}</h2>` : ''}
81
+ <ul class="feature-grid">${section.features
82
+ .map((feature) => `<li><h3>${escapeHtml(feature.title)}</h3>${paragraph(feature.description)}</li>`)
83
+ .join('')}</ul>
84
+ </section>`;
85
+ case 'gallery':
86
+ return `<section class="gallery">
87
+ ${section.heading ? `<h2>${escapeHtml(section.heading)}</h2>` : ''}
88
+ <ul class="gallery-grid">${section.images
89
+ .map(
90
+ (image) =>
91
+ `<li><figure>${responsiveImage(image, { sizes: '(min-width: 48rem) 33vw, 100vw' })}${image.caption ? `<figcaption>${escapeHtml(image.caption)}</figcaption>` : ''}</figure></li>`,
92
+ )
93
+ .join('')}</ul>
94
+ </section>`;
95
+ case 'callToAction':
96
+ return `<section class="cta">
97
+ <h2>${escapeHtml(section.heading)}</h2>
98
+ ${paragraph(section.text)}
99
+ <a class="button" href="${safeHref(section.buttonUrl)}">${escapeHtml(section.buttonLabel)}</a>
100
+ </section>`;
101
+ default:
102
+ // A section type this site does not know yet (added in Shapio after the last site deploy): skipped.
103
+ return '';
104
+ }
105
+ };
106
+
107
+ /** A page's dynamic zone. The page title is the heading when the page does not open with a hero. */
108
+ export const renderPage = (page: {
109
+ id: string;
110
+ locale: string;
111
+ title: string;
112
+ sections: Section[];
113
+ }): string => {
114
+ const opensWithHero = page.sections[0]?.__component === 'hero';
115
+ return `${opensWithHero ? '' : `<h1 class="page-title"${visualAttributes(page, 'title')}>${escapeHtml(page.title)}</h1>`}${page.sections
116
+ .map((section, index) => renderSection(section, index, page))
117
+ .join('\n')}`;
118
+ };
119
+
120
+ const authorOf = (article: Article): Author | null =>
121
+ typeof article.author === 'object' && article.author !== null ? article.author : null;
122
+
123
+ const byline = (article: Article, context: RenderContext) => {
124
+ const author = authorOf(article);
125
+ return `<p class="byline">${author ? `${context.strings.by} ${escapeHtml(author.name)} · ` : ''}<time datetime="${escapeHtml(article.publishedOn)}">${formatDate(article.publishedOn, context.locale)}</time></p>`;
126
+ };
127
+
128
+ export const renderArticle = (article: Article, context: RenderContext): string => {
129
+ const author = authorOf(article);
130
+ return `<article class="article" lang="${escapeHtml(article.locale)}">
131
+ <header>
132
+ <h1${visualAttributes(article, 'title')}>${escapeHtml(article.title)}</h1>
133
+ ${byline(article, context)}
134
+ </header>
135
+ ${article.cover ? responsiveImage(article.cover, { sizes: '(min-width: 48rem) 48rem, 100vw', eager: true, className: 'cover', attributes: visualAttributes(article, 'cover') }) : ''}
136
+ <div class="prose"${visualAttributes(article, 'body')}>${article.body?.html ?? ''}</div>
137
+ ${author ? `<aside class="author">${author.avatar ? responsiveImage(author.avatar, { sizes: '4rem', className: 'avatar' }) : ''}<div><p class="author-name">${escapeHtml(author.name)}</p>${paragraph(author.bio)}</div></aside>` : ''}
138
+ </article>`;
139
+ };
140
+
141
+ export const renderArticleCard = (article: Article, context: RenderContext): string => `<li class="card">
142
+ ${article.cover ? responsiveImage(article.cover, { sizes: '(min-width: 48rem) 33vw, 100vw' }) : ''}
143
+ <h2${visualAttributes(article, 'title')}><a href="${escapeHtml(articlePath(context.locale, article.slug))}">${escapeHtml(article.title)}</a></h2>
144
+ ${byline(article, context)}
145
+ ${paragraph(article.excerpt)}
146
+ </li>`;
@@ -0,0 +1,104 @@
1
+ import { createClient, ShapioApiError } from '@shapio/client';
2
+ import { configuredSnapshot, deliveryToken, shapioUrl, siteKey } from './config.js';
3
+ import type { Locale } from './site.js';
4
+ import type { Article, DeliveryList, Page, SiteSettings } from './types.js';
5
+
6
+ /**
7
+ * The site's read side: Shapio's delivery API through `@shapio/client`, pinned to one publication snapshot
8
+ * for the whole build. The snapshot is read once, when the build starts (or taken from SHAPIO_SNAPSHOT), and
9
+ * sent with every request, so a page built a minute later still shows the same moment: a publish during the
10
+ * build never yields a site that mixes old and new content.
11
+ */
12
+ const PAGE_SIZE = 100;
13
+
14
+ /** Delivery addresses a collection by its plural API ID (`/api/content/pages`), not its API ID (`page`). */
15
+ const PAGES = 'pages';
16
+ const ARTICLES = 'articles';
17
+
18
+ /**
19
+ * The fields each listing renders, sent as `fields=`: the response carries only these, and Shapio's field
20
+ * usage shows exactly what the site reads (a whole-model read would count every field as used).
21
+ */
22
+ const PAGE_FIELDS = 'title,slug,description,sections';
23
+ const ARTICLE_FIELDS = 'title,slug,excerpt,body,cover,author,publishedOn';
24
+
25
+ /** The `siteSettings` singleton is read by its API ID (singletons have no plural). */
26
+ const SITE_SETTINGS = 'siteSettings';
27
+ const SITE_SETTINGS_FIELDS = ['siteName', 'tagline', 'footer', 'colophon'];
28
+
29
+ let client: ReturnType<typeof createClient> | undefined;
30
+ const shapio = () =>
31
+ (client ??= createClient({ baseUrl: shapioUrl(), token: deliveryToken(), site: siteKey() }));
32
+
33
+ let pinned: Promise<number> | undefined;
34
+
35
+ /** The snapshot every request of this build reads. */
36
+ export const pinnedSnapshot = (): Promise<number> =>
37
+ (pinned ??= (async () => {
38
+ const configured = configuredSnapshot();
39
+ if (configured !== undefined) {
40
+ return configured;
41
+ }
42
+ const { snapshot } = await shapio().snapshots.current();
43
+ // Shared with any other copy of this module in the build (one value per build).
44
+ process.env.SHAPIO_SNAPSHOT = String(snapshot);
45
+ return snapshot;
46
+ })());
47
+
48
+ const query = (params: Record<string, string | number>) =>
49
+ new URLSearchParams(Object.entries(params).map(([key, value]) => [key, String(value)])).toString();
50
+
51
+ /** Every published entry of a collection (by plural API ID) in one locale, page by page, at the pinned snapshot. */
52
+ const listAll = async <T>(
53
+ routeKey: string,
54
+ locale: Locale,
55
+ extra: Record<string, string> = {},
56
+ ): Promise<T[]> => {
57
+ const snapshot = await pinnedSnapshot();
58
+ const entries: T[] = [];
59
+ for (let page = 1; ; page += 1) {
60
+ const result = await shapio().request<DeliveryList<T>>(
61
+ `/api/content/${routeKey}?${query({ locale, snapshot, page, pageSize: PAGE_SIZE, ...extra })}`,
62
+ );
63
+ entries.push(...result.data);
64
+ if (page >= result.meta.pagination.pageCount) {
65
+ return entries;
66
+ }
67
+ }
68
+ };
69
+
70
+ export const listPages = (locale: Locale) => listAll<Page>(PAGES, locale, { fields: PAGE_FIELDS });
71
+
72
+ export const listArticles = (locale: Locale) =>
73
+ listAll<Article>(ARTICLES, locale, {
74
+ fields: ARTICLE_FIELDS,
75
+ populate: 'author',
76
+ sort: 'publishedOn:desc',
77
+ });
78
+
79
+ const settingsByLocale = new Map<Locale, Promise<SiteSettings | null>>();
80
+
81
+ /** The site settings singleton in one locale at the pinned snapshot; null until it is published. */
82
+ export const getSiteSettings = (locale: Locale): Promise<SiteSettings | null> => {
83
+ let settings = settingsByLocale.get(locale);
84
+ if (!settings) {
85
+ settings = (async () => {
86
+ try {
87
+ const snapshot = await pinnedSnapshot();
88
+ const { data } = await shapio().delivery.singleton<SiteSettings>(SITE_SETTINGS, {
89
+ locale,
90
+ snapshot,
91
+ fields: SITE_SETTINGS_FIELDS,
92
+ });
93
+ return data;
94
+ } catch (error) {
95
+ if (error instanceof ShapioApiError && error.status === 404) {
96
+ return null;
97
+ }
98
+ throw error;
99
+ }
100
+ })();
101
+ settingsByLocale.set(locale, settings);
102
+ }
103
+ return settings;
104
+ };
@@ -0,0 +1,55 @@
1
+ /** The site's locales (they must exist in Shapio: the seed creates `fr`; `en` is Shapio's default). */
2
+ export const LOCALES = ['en', 'fr'] as const;
3
+ export type Locale = (typeof LOCALES)[number];
4
+ export const DEFAULT_LOCALE: Locale = 'en';
5
+
6
+ /** The page whose slug is `home` is each locale's front page. */
7
+ export const HOME_SLUG = 'home';
8
+
9
+ export const isLocale = (value: string): value is Locale => (LOCALES as readonly string[]).includes(value);
10
+
11
+ const STRINGS = {
12
+ en: {
13
+ siteName: 'Northwind Studio',
14
+ articles: 'Journal',
15
+ readMore: 'Read the article',
16
+ by: 'By',
17
+ publishedOn: 'Published',
18
+ switchTo: 'Français',
19
+ allArticles: 'All articles',
20
+ colophon: 'Colophon',
21
+ previewBanner: 'Preview: this is a draft, not the published page.',
22
+ previewLoading: 'Loading the draft…',
23
+ previewFailed: 'The preview could not be loaded',
24
+ skipToContent: 'Skip to content',
25
+ },
26
+ fr: {
27
+ siteName: 'Studio Northwind',
28
+ articles: 'Journal',
29
+ readMore: "Lire l'article",
30
+ by: 'Par',
31
+ publishedOn: 'Publié le',
32
+ switchTo: 'English',
33
+ allArticles: 'Tous les articles',
34
+ colophon: 'Colophon',
35
+ previewBanner: 'Aperçu : ceci est un brouillon, pas la page publiée.',
36
+ previewLoading: 'Chargement du brouillon…',
37
+ previewFailed: "L'aperçu n'a pas pu être chargé",
38
+ skipToContent: 'Aller au contenu',
39
+ },
40
+ } as const satisfies Record<Locale, Record<string, string>>;
41
+
42
+ export type Strings = (typeof STRINGS)[Locale];
43
+
44
+ export const stringsFor = (locale: string): Strings => STRINGS[isLocale(locale) ? locale : DEFAULT_LOCALE];
45
+
46
+ export const otherLocale = (locale: Locale): Locale => (locale === 'en' ? 'fr' : 'en');
47
+
48
+ export const formatDate = (iso: string, locale: string) =>
49
+ new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(iso));
50
+
51
+ export const pagePath = (locale: string, slug: string) =>
52
+ slug === HOME_SLUG ? `/${locale}/` : `/${locale}/${slug}/`;
53
+ export const articlesPath = (locale: string) => `/${locale}/articles/`;
54
+ export const articlePath = (locale: string, slug: string) => `/${locale}/articles/${slug}/`;
55
+ export const colophonPath = (locale: string) => `/${locale}/colophon/`;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Content as the Shapio delivery API returns it for this site's models (shapio/). Delivery
3
+ * entries are flat: system attributes next to the fields, keyed by API key. Media fields are asset views,
4
+ * rich text carries the JSON document plus a sanitised `html` rendering, populated relations are entries.
5
+ */
6
+ export type MediaVariant = {
7
+ name: string;
8
+ width: number | null;
9
+ height: number | null;
10
+ format: string;
11
+ mimeType: string;
12
+ url: string;
13
+ };
14
+
15
+ export type Media = {
16
+ id: string;
17
+ filename: string;
18
+ mimeType: string;
19
+ width: number | null;
20
+ height: number | null;
21
+ alt: string;
22
+ caption: string;
23
+ url: string;
24
+ variants: MediaVariant[];
25
+ };
26
+
27
+ export type RichText = { format: 'shapio-richtext'; version: number; html: string };
28
+
29
+ type EntryBase = {
30
+ id: string;
31
+ /** The locale that served the entry (differs from the requested one on fallback). */
32
+ locale: string;
33
+ createdAt: string;
34
+ updatedAt: string;
35
+ publishedAt?: string;
36
+ };
37
+
38
+ export type Feature = { title: string; description: string | null };
39
+
40
+ export type HeroSection = {
41
+ __component: 'hero';
42
+ heading: string;
43
+ subheading: string | null;
44
+ image: Media | null;
45
+ ctaLabel: string | null;
46
+ ctaUrl: string | null;
47
+ };
48
+ export type FeatureGridSection = { __component: 'featureGrid'; heading: string | null; features: Feature[] };
49
+ export type GallerySection = { __component: 'gallery'; heading: string | null; images: Media[] };
50
+ export type CallToActionSection = {
51
+ __component: 'callToAction';
52
+ heading: string;
53
+ text: string | null;
54
+ buttonLabel: string;
55
+ buttonUrl: string;
56
+ };
57
+ export type Section = HeroSection | FeatureGridSection | GallerySection | CallToActionSection;
58
+
59
+ export type Page = EntryBase & {
60
+ title: string;
61
+ slug: string;
62
+ description: string | null;
63
+ sections: Section[];
64
+ };
65
+
66
+ export type Author = EntryBase & { name: string; bio: string | null; avatar: Media | null };
67
+
68
+ export type Article = EntryBase & {
69
+ title: string;
70
+ slug: string;
71
+ excerpt: string | null;
72
+ body: RichText | null;
73
+ cover: Media | null;
74
+ /** Populated (`populate=author`) or just the ID; null when the author is not published. */
75
+ author: Author | string | null;
76
+ publishedOn: string;
77
+ };
78
+
79
+ /** The `siteSettings` singleton: one entry per site, read by its API ID. */
80
+ export type SiteSettings = EntryBase & {
81
+ siteName: string;
82
+ tagline: string | null;
83
+ footer: string | null;
84
+ colophon: RichText | null;
85
+ };
86
+
87
+ export type DeliveryList<T> = {
88
+ data: T[];
89
+ meta: {
90
+ locale: string;
91
+ snapshot: number;
92
+ pagination: { page: number; pageSize: number; total: number; pageCount: number };
93
+ };
94
+ };
95
+
96
+ export type DeliveryItem<T> = { data: T; meta: { locale: string; snapshot: number } };
@@ -0,0 +1,8 @@
1
+ ---
2
+ import Base from '../layouts/Base.astro';
3
+ ---
4
+
5
+ <Base title="Not found" locale="en" noindex>
6
+ <h1 class="page-title">Page not found</h1>
7
+ <p><a href="/en/">Back to the home page</a></p>
8
+ </Base>