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,43 @@
1
+ <script lang="ts">
2
+ import { shapioAttr } from '@shapio/visual';
3
+ import { authorOf } from '../articles';
4
+ import type { Strings } from '../site';
5
+ import type { Article } from '../types';
6
+ import Byline from './Byline.svelte';
7
+ import ResponsiveImage from './ResponsiveImage.svelte';
8
+ import RichText from './RichText.svelte';
9
+
10
+ type Props = { article: Article; locale: string; strings: Strings };
11
+ let { article, locale, strings }: Props = $props();
12
+ const author = $derived(authorOf(article));
13
+ </script>
14
+
15
+ <article class="article" lang={article.locale}>
16
+ <header>
17
+ <h1 {...shapioAttr(article, 'title')}>{article.title}</h1>
18
+ <Byline {article} {locale} {strings} />
19
+ </header>
20
+ {#if article.cover}
21
+ <ResponsiveImage
22
+ media={article.cover}
23
+ sizes="(min-width: 48rem) 48rem, 100vw"
24
+ eager
25
+ class="cover"
26
+ visual={shapioAttr(article, 'cover')}
27
+ />
28
+ {/if}
29
+ <RichText value={article.body} visual={shapioAttr(article, 'body')} />
30
+ {#if author}
31
+ <aside class="author">
32
+ {#if author.avatar}
33
+ <ResponsiveImage media={author.avatar} sizes="4rem" class="avatar" />
34
+ {/if}
35
+ <div>
36
+ <p class="author-name">{author.name}</p>
37
+ {#if author.bio}
38
+ <p>{author.bio}</p>
39
+ {/if}
40
+ </div>
41
+ </aside>
42
+ {/if}
43
+ </article>
@@ -0,0 +1,14 @@
1
+ <script lang="ts">
2
+ import { authorOf } from '../articles';
3
+ import { formatDate, type Strings } from '../site';
4
+ import type { Article } from '../types';
5
+
6
+ type Props = { article: Article; locale: string; strings: Strings };
7
+ let { article, locale, strings }: Props = $props();
8
+ const author = $derived(authorOf(article));
9
+ </script>
10
+
11
+ <p class="byline">
12
+ {#if author}{strings.by} {author.name} ·
13
+ {/if}<time datetime={article.publishedOn}>{formatDate(article.publishedOn, locale)}</time>
14
+ </p>
@@ -0,0 +1,15 @@
1
+ <script lang="ts">
2
+ import { safeHref } from '../site';
3
+ import type { CallToActionSection } from '../types';
4
+
5
+ type Props = { section: CallToActionSection };
6
+ let { section }: Props = $props();
7
+ </script>
8
+
9
+ <section class="cta">
10
+ <h2>{section.heading}</h2>
11
+ {#if section.text}
12
+ <p>{section.text}</p>
13
+ {/if}
14
+ <a class="button" href={safeHref(section.buttonUrl)}>{section.buttonLabel}</a>
15
+ </section>
@@ -0,0 +1,22 @@
1
+ <script lang="ts">
2
+ import type { FeatureGridSection } from '../types';
3
+
4
+ type Props = { section: FeatureGridSection };
5
+ let { section }: Props = $props();
6
+ </script>
7
+
8
+ <section class="features">
9
+ {#if section.heading}
10
+ <h2>{section.heading}</h2>
11
+ {/if}
12
+ <ul class="feature-grid">
13
+ {#each section.features as feature (feature.title)}
14
+ <li>
15
+ <h3>{feature.title}</h3>
16
+ {#if feature.description}
17
+ <p>{feature.description}</p>
18
+ {/if}
19
+ </li>
20
+ {/each}
21
+ </ul>
22
+ </section>
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ import type { GallerySection } from '../types';
3
+ import ResponsiveImage from './ResponsiveImage.svelte';
4
+
5
+ type Props = { section: GallerySection };
6
+ let { section }: Props = $props();
7
+ </script>
8
+
9
+ <section class="gallery">
10
+ {#if section.heading}
11
+ <h2>{section.heading}</h2>
12
+ {/if}
13
+ <ul class="gallery-grid">
14
+ {#each section.images as image (image.id)}
15
+ <li>
16
+ <figure>
17
+ <ResponsiveImage media={image} sizes="(min-width: 48rem) 33vw, 100vw" />
18
+ {#if image.caption}
19
+ <figcaption>{image.caption}</figcaption>
20
+ {/if}
21
+ </figure>
22
+ </li>
23
+ {/each}
24
+ </ul>
25
+ </section>
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ import type { ShapioAttributes } from '@shapio/visual';
3
+ import { safeHref } from '../site';
4
+ import type { HeroSection } from '../types';
5
+ import ResponsiveImage from './ResponsiveImage.svelte';
6
+
7
+ type Props = { section: HeroSection; isFirst: boolean; visual?: ShapioAttributes };
8
+ let { section, isFirst, visual }: Props = $props();
9
+ </script>
10
+
11
+ <section class="hero">
12
+ <div class="hero-text">
13
+ <svelte:element this={isFirst ? 'h1' : 'h2'} {...visual}>{section.heading}</svelte:element>
14
+ {#if section.subheading}
15
+ <p class="lead">{section.subheading}</p>
16
+ {/if}
17
+ {#if section.ctaLabel && section.ctaUrl}
18
+ <a class="button" href={safeHref(section.ctaUrl)}>{section.ctaLabel}</a>
19
+ {/if}
20
+ </div>
21
+ {#if section.image}
22
+ <ResponsiveImage media={section.image} sizes="(min-width: 64rem) 50vw, 100vw" eager={isFirst} class="hero-image" />
23
+ {/if}
24
+ </section>
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import type { ShapioAttributes } from '@shapio/visual';
3
+ import type { Media } from '../types';
4
+
5
+ /** `visual`: the field the image shows (`shapioAttr`), for visual editing. */
6
+ type Props = { media: Media; sizes: string; eager?: boolean; class?: string; visual?: ShapioAttributes };
7
+ let { media, sizes, eager = false, class: className, visual }: Props = $props();
8
+
9
+ // WebP variants in srcset, intrinsic size set (no layout shift), lazy unless eager.
10
+ const widths = $derived(
11
+ media.variants
12
+ .filter((variant) => variant.name.startsWith('w') && variant.width !== null)
13
+ .sort((a, b) => (a.width ?? 0) - (b.width ?? 0)),
14
+ );
15
+ const srcset = $derived(widths.map((variant) => `${variant.url} ${variant.width}w`).join(', '));
16
+ </script>
17
+
18
+ <img
19
+ src={widths.at(-1)?.url ?? media.url}
20
+ srcset={srcset || undefined}
21
+ sizes={srcset ? sizes : undefined}
22
+ width={media.width ?? undefined}
23
+ height={media.height ?? undefined}
24
+ alt={media.alt}
25
+ loading={eager ? 'eager' : 'lazy'}
26
+ fetchpriority={eager ? 'high' : undefined}
27
+ decoding="async"
28
+ class={className}
29
+ {...visual}
30
+ />
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import type { ShapioAttributes } from '@shapio/visual';
3
+ import type { RichText } from '../types';
4
+
5
+ /** `visual`: the field it shows (`shapioAttr`), for visual editing. */
6
+ type Props = { value: RichText | null; visual?: ShapioAttributes };
7
+ let { value, visual }: Props = $props();
8
+ </script>
9
+
10
+ <!-- Shapio's rich text arrives with a sanitised `html` rendering next to the JSON document. -->
11
+ {#if value}
12
+ <div class="prose" {...visual}>{@html value.html}</div>
13
+ {/if}
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import { shapioAttr } from '@shapio/visual';
3
+ import type { Page } from '../types';
4
+ import CallToAction from './CallToAction.svelte';
5
+ import FeatureGrid from './FeatureGrid.svelte';
6
+ import Gallery from './Gallery.svelte';
7
+ import Hero from './Hero.svelte';
8
+
9
+ /** A page's dynamic zone. The page title is the heading when the page does not open with a hero. */
10
+ type Props = { page: Pick<Page, 'id' | 'title' | 'sections' | 'locale'> };
11
+ let { page }: Props = $props();
12
+ </script>
13
+
14
+ <div lang={page.locale}>
15
+ {#if page.sections[0]?.__component !== 'hero'}
16
+ <h1 class="page-title" {...shapioAttr(page, 'title')}>{page.title}</h1>
17
+ {/if}
18
+ {#each page.sections as section, index (`${section.__component}-${index}`)}
19
+ {#if section.__component === 'hero'}
20
+ <Hero {section} isFirst={index === 0} visual={shapioAttr(page, `sections/${index}/heading`)} />
21
+ {:else if section.__component === 'featureGrid'}
22
+ <FeatureGrid {section} />
23
+ {:else if section.__component === 'gallery'}
24
+ <Gallery {section} />
25
+ {:else if section.__component === 'callToAction'}
26
+ <CallToAction {section} />
27
+ {/if}
28
+ <!-- Any other section type (added in Shapio after the last deploy) is skipped. -->
29
+ {/each}
30
+ </div>
@@ -0,0 +1,56 @@
1
+ import { createClient } from '@shapio/client';
2
+ import { DEFAULT_LOCALE, isLocale, type Locale } from './site';
3
+ import type { Article, DeliveryItem, Page } from './types';
4
+
5
+ /**
6
+ * Preview: Shapio opens `/preview/?model={modelKey}&id={entryId}&locale={locale}#token={token}` (the
7
+ * connection's preview URL template). `{modelKey}` is the model's route key (`pages`, `articles`). The token
8
+ * sits in the fragment, so it never reaches a server log or a Referer header; it is a scoped, expiring preview
9
+ * token, never an admin credential. The draft is read in the browser from `/api/preview/content/...` and
10
+ * rendered with the same components as the prerendered pages.
11
+ */
12
+ export const PREVIEW_MODELS = ['pages', 'articles'] as const;
13
+
14
+ export type PreviewRequest = {
15
+ token: string;
16
+ modelKey: (typeof PREVIEW_MODELS)[number];
17
+ entryId: string;
18
+ locale: Locale;
19
+ };
20
+
21
+ export type PreviewDraft =
22
+ | { modelKey: 'pages'; entry: Page; locale: Locale }
23
+ | { modelKey: 'articles'; entry: Article; locale: Locale };
24
+
25
+ /** Reads the request from a preview URL; undefined when something is missing or unknown. */
26
+ export const parsePreviewUrl = (url: URL): PreviewRequest | undefined => {
27
+ const fragment = new URLSearchParams(url.hash.slice(1));
28
+ const token = fragment.get('token') ?? url.searchParams.get('token');
29
+ const modelKey = url.searchParams.get('model');
30
+ const entryId = url.searchParams.get('id');
31
+ const locale = url.searchParams.get('locale') ?? DEFAULT_LOCALE;
32
+ if (!token || !entryId || !(PREVIEW_MODELS as readonly string[]).includes(modelKey ?? '')) {
33
+ return undefined;
34
+ }
35
+ return {
36
+ token,
37
+ modelKey: modelKey as PreviewRequest['modelKey'],
38
+ entryId,
39
+ locale: isLocale(locale) ? locale : DEFAULT_LOCALE,
40
+ };
41
+ };
42
+
43
+ /** Fetches the draft through the preview API. */
44
+ export const loadDraft = async (shapioUrl: string, request: PreviewRequest): Promise<PreviewDraft> => {
45
+ const client = createClient({ baseUrl: shapioUrl, token: request.token });
46
+ const params = new URLSearchParams({
47
+ locale: request.locale,
48
+ ...(request.modelKey === 'articles' ? { populate: 'author' } : {}),
49
+ });
50
+ const { data } = await client.request<DeliveryItem<Page | Article>>(
51
+ `/api/preview/content/${encodeURIComponent(request.modelKey)}/${encodeURIComponent(request.entryId)}?${params.toString()}`,
52
+ );
53
+ return request.modelKey === 'pages'
54
+ ? { modelKey: 'pages', entry: data as Page, locale: request.locale }
55
+ : { modelKey: 'articles', entry: data as Article, locale: request.locale };
56
+ };
@@ -0,0 +1,46 @@
1
+ import {
2
+ PUBLIC_SHAPIO_URL,
3
+ SHAPIO_DELIVERY_TOKEN,
4
+ SHAPIO_SITE,
5
+ SHAPIO_SNAPSHOT,
6
+ SHAPIO_URL,
7
+ } from '$app/env/private';
8
+
9
+ /**
10
+ * Build-time settings, from the environment (or `.env` in this folder, which `npm run seed` writes), declared
11
+ * in src/env.ts:
12
+ * - SHAPIO_URL: the Shapio origin (with BASE_PATH), e.g. https://cms.example.com.
13
+ * - SHAPIO_DELIVERY_TOKEN: a read-only delivery token (Settings → API tokens, delivery role).
14
+ * - SHAPIO_SNAPSHOT: optional; the publication snapshot to build. Without it the build pins the latest
15
+ * snapshot when prerendering starts, so every page shows the same moment.
16
+ * - SHAPIO_SITE: optional; the site key on a multi-site Shapio (default: the token's site, else the primary).
17
+ * - PUBLIC_SHAPIO_URL: the Shapio URL the browser calls for previews (defaults to SHAPIO_URL).
18
+ */
19
+ export const shapioUrl = () => SHAPIO_URL ?? 'http://localhost:4300';
20
+
21
+ /** Handed to the preview page at build time: the URL its browser code reads drafts from. */
22
+ export const publicShapioUrl = () => PUBLIC_SHAPIO_URL ?? shapioUrl();
23
+
24
+ export const deliveryToken = () => {
25
+ const token = SHAPIO_DELIVERY_TOKEN;
26
+ if (!token) {
27
+ throw new Error(
28
+ 'Set SHAPIO_DELIVERY_TOKEN (a Shapio delivery token) to build the site; `npm run seed` writes one to .env',
29
+ );
30
+ }
31
+ return token;
32
+ };
33
+
34
+ export const siteKey = () => SHAPIO_SITE;
35
+
36
+ export const configuredSnapshot = (): number | undefined => {
37
+ const value = SHAPIO_SNAPSHOT;
38
+ if (value === undefined) {
39
+ return undefined;
40
+ }
41
+ const snapshot = Number(value);
42
+ if (!Number.isInteger(snapshot) || snapshot < 0) {
43
+ throw new Error(`SHAPIO_SNAPSHOT must be a publication snapshot number, got "${value}"`);
44
+ }
45
+ return snapshot;
46
+ };
@@ -0,0 +1,93 @@
1
+ import { createClient, ShapioApiError, type DeliveryListQuery } from '@shapio/client';
2
+ import type { Locale } from '../site';
3
+ import type { Article, Page, SiteSettings } from '../types';
4
+ import { configuredSnapshot, deliveryToken, shapioUrl, siteKey } from './config';
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 prerendering starts (or taken from SHAPIO_SNAPSHOT),
9
+ * and sent with every request, so a publish during the build never yields a site that mixes old and new.
10
+ */
11
+ const PAGE_SIZE = 100;
12
+
13
+ /** Delivery addresses a collection by its plural API ID, a singleton by its API ID. */
14
+ const PAGES = 'pages';
15
+ const ARTICLES = 'articles';
16
+ const SITE_SETTINGS = 'siteSettings';
17
+
18
+ /** The fields each read renders (`fields=`): smaller responses, and accurate field usage in Shapio. */
19
+ const PAGE_FIELDS = ['title', 'slug', 'description', 'sections'];
20
+ const ARTICLE_FIELDS = ['title', 'slug', 'excerpt', 'body', 'cover', 'author', 'publishedOn'];
21
+ const SITE_SETTINGS_FIELDS = ['siteName', 'tagline', 'footer', 'colophon'];
22
+
23
+ const createShapio = () => createClient({ baseUrl: shapioUrl(), token: deliveryToken(), site: siteKey() });
24
+
25
+ let client: ReturnType<typeof createShapio> | undefined;
26
+ const shapio = () => (client ??= createShapio());
27
+
28
+ let pinned: Promise<number> | undefined;
29
+
30
+ /** The snapshot every request of this build reads. */
31
+ const snapshot = (): Promise<number> =>
32
+ (pinned ??= (async () => configuredSnapshot() ?? (await shapio().snapshots.current()).snapshot)());
33
+
34
+ /** Each collection is read once per locale, however many pages render from it. */
35
+ const memo = new Map<string, Promise<unknown>>();
36
+ const once = <T>(key: string, read: () => Promise<T>): Promise<T> => {
37
+ let value = memo.get(key) as Promise<T> | undefined;
38
+ if (!value) {
39
+ value = read();
40
+ memo.set(key, value);
41
+ }
42
+ return value;
43
+ };
44
+
45
+ /** Every published entry of a collection in one locale, page by page, at the pinned snapshot. */
46
+ const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T[]> => {
47
+ const entries: T[] = [];
48
+ for (let page = 1; ; page += 1) {
49
+ const result = await shapio().delivery.list<T>(routeKey, {
50
+ ...query,
51
+ snapshot: await snapshot(),
52
+ page,
53
+ pageSize: PAGE_SIZE,
54
+ });
55
+ entries.push(...result.data);
56
+ if (page >= result.meta.pagination.pageCount) {
57
+ return entries;
58
+ }
59
+ }
60
+ };
61
+
62
+ export const listPages = (locale: Locale) =>
63
+ once(`pages:${locale}`, () => listAll<Page>(PAGES, { locale, fields: PAGE_FIELDS }));
64
+
65
+ export const listArticles = (locale: Locale) =>
66
+ once(`articles:${locale}`, () =>
67
+ listAll<Article>(ARTICLES, {
68
+ locale,
69
+ fields: ARTICLE_FIELDS,
70
+ populate: ['author'],
71
+ sort: [{ field: 'publishedOn', direction: 'desc' }],
72
+ }),
73
+ );
74
+
75
+ /** The siteSettings singleton in one locale; null until it is published. */
76
+ export const getSiteSettings = (locale: Locale): Promise<SiteSettings | null> =>
77
+ once(`settings:${locale}`, () => readSiteSettings(locale));
78
+
79
+ const readSiteSettings = async (locale: Locale): Promise<SiteSettings | null> => {
80
+ try {
81
+ const { data } = await shapio().delivery.singleton<SiteSettings>(SITE_SETTINGS, {
82
+ locale,
83
+ snapshot: await snapshot(),
84
+ fields: SITE_SETTINGS_FIELDS,
85
+ });
86
+ return data;
87
+ } catch (error) {
88
+ if (error instanceof ShapioApiError && error.status === 404) {
89
+ return null;
90
+ }
91
+ throw error;
92
+ }
93
+ };
@@ -0,0 +1,199 @@
1
+ /* The example site's whole stylesheet: system fonts, the Shapio palette, light and dark. */
2
+ :root {
3
+ --ink: #0f172a;
4
+ --ivory: #faf9f6;
5
+ --cobalt: #2563eb;
6
+ --periwinkle: #a5b4fc;
7
+ --muted: #475569;
8
+ --surface: #ffffff;
9
+ --border: #e2e8f0;
10
+ color-scheme: light dark;
11
+ font-family:
12
+ system-ui,
13
+ -apple-system,
14
+ 'Segoe UI',
15
+ Roboto,
16
+ sans-serif;
17
+ line-height: 1.6;
18
+ color: var(--ink);
19
+ background: var(--ivory);
20
+ }
21
+ @media (prefers-color-scheme: dark) {
22
+ :root {
23
+ --ink: #e2e8f0;
24
+ --ivory: #0f172a;
25
+ --cobalt: #a5b4fc;
26
+ --muted: #94a3b8;
27
+ --surface: #1e293b;
28
+ --border: #334155;
29
+ }
30
+ }
31
+ * {
32
+ box-sizing: border-box;
33
+ }
34
+ body {
35
+ margin: 0;
36
+ }
37
+ img {
38
+ max-width: 100%;
39
+ height: auto;
40
+ display: block;
41
+ }
42
+ a {
43
+ color: var(--cobalt);
44
+ }
45
+ .skip {
46
+ position: absolute;
47
+ left: -999px;
48
+ }
49
+ .skip:focus {
50
+ left: 1rem;
51
+ top: 1rem;
52
+ background: var(--surface);
53
+ padding: 0.5rem;
54
+ }
55
+ .site-header,
56
+ .site-footer,
57
+ main {
58
+ max-width: 64rem;
59
+ margin: 0 auto;
60
+ padding: 1rem;
61
+ }
62
+ .site-header {
63
+ display: flex;
64
+ flex-wrap: wrap;
65
+ gap: 1rem;
66
+ align-items: center;
67
+ justify-content: space-between;
68
+ }
69
+ .site-header nav {
70
+ display: flex;
71
+ gap: 1rem;
72
+ }
73
+ .brand {
74
+ font-weight: 800;
75
+ text-decoration: none;
76
+ color: inherit;
77
+ font-size: 1.25rem;
78
+ }
79
+ .tagline {
80
+ flex: 1;
81
+ margin: 0;
82
+ color: var(--muted);
83
+ font-size: 0.875rem;
84
+ }
85
+ .site-footer {
86
+ color: var(--muted);
87
+ font-size: 0.875rem;
88
+ border-top: 1px solid var(--border);
89
+ }
90
+ .button {
91
+ display: inline-block;
92
+ background: var(--cobalt);
93
+ color: var(--ivory);
94
+ padding: 0.75rem 1.25rem;
95
+ border-radius: 0.5rem;
96
+ text-decoration: none;
97
+ font-weight: 600;
98
+ }
99
+ .hero {
100
+ display: grid;
101
+ gap: 2rem;
102
+ align-items: center;
103
+ padding: 2rem 0;
104
+ }
105
+ @media (min-width: 64rem) {
106
+ .hero {
107
+ grid-template-columns: 1fr 1fr;
108
+ }
109
+ }
110
+ .hero h1 {
111
+ font-size: clamp(2rem, 5vw, 3.25rem);
112
+ line-height: 1.1;
113
+ margin: 0 0 1rem;
114
+ }
115
+ .lead {
116
+ font-size: 1.25rem;
117
+ color: var(--muted);
118
+ }
119
+ .hero-image,
120
+ .cover {
121
+ border-radius: 0.75rem;
122
+ }
123
+ .feature-grid,
124
+ .gallery-grid,
125
+ .cards {
126
+ list-style: none;
127
+ padding: 0;
128
+ display: grid;
129
+ gap: 1.5rem;
130
+ grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
131
+ }
132
+ .feature-grid li,
133
+ .card {
134
+ background: var(--surface);
135
+ border: 1px solid var(--border);
136
+ border-radius: 0.75rem;
137
+ padding: 1.25rem;
138
+ }
139
+ .gallery figure {
140
+ margin: 0;
141
+ }
142
+ .gallery img {
143
+ border-radius: 0.5rem;
144
+ aspect-ratio: 4 / 3;
145
+ object-fit: cover;
146
+ width: 100%;
147
+ }
148
+ .cta {
149
+ text-align: center;
150
+ padding: 3rem 1rem;
151
+ background: var(--surface);
152
+ border-radius: 1rem;
153
+ margin: 2rem 0;
154
+ }
155
+ .article {
156
+ max-width: 48rem;
157
+ margin: 0 auto;
158
+ }
159
+ .byline {
160
+ color: var(--muted);
161
+ }
162
+ .prose table {
163
+ border-collapse: collapse;
164
+ width: 100%;
165
+ }
166
+ .prose th,
167
+ .prose td {
168
+ border: 1px solid var(--border);
169
+ padding: 0.5rem;
170
+ text-align: left;
171
+ }
172
+ .prose img {
173
+ border-radius: 0.5rem;
174
+ }
175
+ .author {
176
+ display: flex;
177
+ gap: 1rem;
178
+ align-items: center;
179
+ border-top: 1px solid var(--border);
180
+ margin-top: 2rem;
181
+ padding-top: 1rem;
182
+ }
183
+ .avatar {
184
+ width: 4rem;
185
+ height: 4rem;
186
+ border-radius: 50%;
187
+ object-fit: cover;
188
+ }
189
+ .author-name {
190
+ font-weight: 700;
191
+ margin: 0;
192
+ }
193
+ .preview-banner {
194
+ background: #fef3c7;
195
+ color: #78350f;
196
+ padding: 0.5rem 1rem;
197
+ text-align: center;
198
+ font-weight: 600;
199
+ }
@@ -0,0 +1,66 @@
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
+ /** Reads a route's `[locale]` segment; the layout only generates known locales (dynamicParams = false). */
12
+ export const toLocale = (value: string): Locale => (isLocale(value) ? value : DEFAULT_LOCALE);
13
+
14
+ const STRINGS = {
15
+ en: {
16
+ siteName: 'Northwind Studio',
17
+ articles: 'Journal',
18
+ colophon: 'Colophon',
19
+ by: 'By',
20
+ switchTo: 'Français',
21
+ skipToContent: 'Skip to content',
22
+ footer: 'Built with Shapio',
23
+ previewBanner: 'Preview: this is a draft, not the published page.',
24
+ previewLoading: 'Loading the draft…',
25
+ previewFailed: 'The preview could not be loaded',
26
+ previewIncomplete: 'This preview link is incomplete.',
27
+ },
28
+ fr: {
29
+ siteName: 'Studio Northwind',
30
+ articles: 'Journal',
31
+ colophon: 'Colophon',
32
+ by: 'Par',
33
+ switchTo: 'English',
34
+ skipToContent: 'Aller au contenu',
35
+ footer: 'Construit avec Shapio',
36
+ previewBanner: 'Aperçu : ceci est un brouillon, pas la page publiée.',
37
+ previewLoading: 'Chargement du brouillon…',
38
+ previewFailed: "L'aperçu n'a pas pu être chargé",
39
+ previewIncomplete: "Ce lien d'aperçu est incomplet.",
40
+ },
41
+ } as const satisfies Record<Locale, Record<string, string>>;
42
+
43
+ export type Strings = (typeof STRINGS)[Locale];
44
+
45
+ export const stringsFor = (locale: Locale): Strings => STRINGS[locale];
46
+
47
+ export const otherLocale = (locale: Locale): Locale => (locale === 'en' ? 'fr' : 'en');
48
+
49
+ export const formatDate = (iso: string, locale: string) =>
50
+ new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(iso));
51
+
52
+ export const pagePath = (locale: string, slug: string) =>
53
+ slug === HOME_SLUG ? `/${locale}/` : `/${locale}/${slug}/`;
54
+ export const articlesPath = (locale: string) => `/${locale}/articles/`;
55
+ export const articlePath = (locale: string, slug: string) => `/${locale}/articles/${slug}/`;
56
+ export const colophonPath = (locale: string) => `/${locale}/colophon/`;
57
+
58
+ /** The same path in another locale (slugs are shared across locales in this starter's models). */
59
+ export const switchLocale = (pathname: string, locale: Locale) =>
60
+ pathname.replace(/^\/(en|fr)(?=\/|$)/, `/${locale}`);
61
+
62
+ /** Links from content: web, mail, phone and site-relative only (a `javascript:` URL becomes `#`). */
63
+ export const safeHref = (value: string | null | undefined): string => {
64
+ const href = (value ?? '').trim();
65
+ return /^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i.test(href) ? href : '#';
66
+ };