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,28 @@
1
+ ---
2
+ import Base from '../../layouts/Base.astro';
3
+ import { renderPage } from '../../lib/render.js';
4
+ import { listPages } from '../../lib/shapio.js';
5
+ import { HOME_SLUG, LOCALES, otherLocale, pagePath, type Locale } from '../../lib/site.js';
6
+ import type { Page } from '../../lib/types.js';
7
+
8
+ /** Every published page in every locale; `home` is the locale's front page (`/en/`, `/fr/`). */
9
+ export const getStaticPaths = async () => {
10
+ const paths = [];
11
+ for (const locale of LOCALES) {
12
+ for (const page of await listPages(locale)) {
13
+ paths.push({
14
+ params: { locale, slug: page.slug === HOME_SLUG ? undefined : page.slug },
15
+ props: { page, locale },
16
+ });
17
+ }
18
+ }
19
+ return paths;
20
+ };
21
+
22
+ type Props = { page: Page; locale: Locale };
23
+ const { page, locale } = Astro.props;
24
+ ---
25
+
26
+ <Base title={page.title} description={page.description} locale={locale} alternatePath={pagePath(otherLocale(locale), page.slug)}>
27
+ <div lang={page.locale} set:html={renderPage(page)} />
28
+ </Base>
@@ -0,0 +1,25 @@
1
+ ---
2
+ import Base from '../../../layouts/Base.astro';
3
+ import { renderArticle } from '../../../lib/render.js';
4
+ import { listArticles } from '../../../lib/shapio.js';
5
+ import { articlePath, LOCALES, otherLocale, stringsFor, type Locale } from '../../../lib/site.js';
6
+ import type { Article } from '../../../lib/types.js';
7
+
8
+ export const getStaticPaths = async () => {
9
+ const paths = [];
10
+ for (const locale of LOCALES) {
11
+ for (const article of await listArticles(locale)) {
12
+ paths.push({ params: { locale, slug: article.slug }, props: { locale, article } });
13
+ }
14
+ }
15
+ return paths;
16
+ };
17
+
18
+ type Props = { locale: Locale; article: Article };
19
+ const { locale, article } = Astro.props;
20
+ const context = { locale, strings: stringsFor(locale) };
21
+ ---
22
+
23
+ <Base title={article.title} description={article.excerpt} locale={locale} alternatePath={articlePath(otherLocale(locale), article.slug)}>
24
+ <Fragment set:html={renderArticle(article, context)} />
25
+ </Base>
@@ -0,0 +1,22 @@
1
+ ---
2
+ import Base from '../../../layouts/Base.astro';
3
+ import { renderArticleCard } from '../../../lib/render.js';
4
+ import { listArticles } from '../../../lib/shapio.js';
5
+ import { articlesPath, LOCALES, otherLocale, stringsFor, type Locale } from '../../../lib/site.js';
6
+ import type { Article } from '../../../lib/types.js';
7
+
8
+ export const getStaticPaths = async () =>
9
+ Promise.all(
10
+ LOCALES.map(async (locale) => ({ params: { locale }, props: { locale, articles: await listArticles(locale) } })),
11
+ );
12
+
13
+ type Props = { locale: Locale; articles: Article[] };
14
+ const { locale, articles } = Astro.props;
15
+ const strings = stringsFor(locale);
16
+ const context = { locale, strings };
17
+ ---
18
+
19
+ <Base title={strings.articles} locale={locale} alternatePath={articlesPath(otherLocale(locale))}>
20
+ <h1 class="page-title">{strings.articles}</h1>
21
+ <ul class="cards" set:html={articles.map((article) => renderArticleCard(article, context)).join('')} />
22
+ </Base>
@@ -0,0 +1,19 @@
1
+ ---
2
+ import Base from '../../layouts/Base.astro';
3
+ import { getSiteSettings } from '../../lib/shapio.js';
4
+ import { colophonPath, LOCALES, otherLocale, stringsFor, type Locale } from '../../lib/site.js';
5
+
6
+ /** The singleton page: the siteSettings entry's colophon, one per locale. */
7
+ export const getStaticPaths = () => LOCALES.map((locale) => ({ params: { locale }, props: { locale } }));
8
+
9
+ type Props = { locale: Locale };
10
+ const { locale } = Astro.props;
11
+ const strings = stringsFor(locale);
12
+ const settings = await getSiteSettings(locale);
13
+ ---
14
+
15
+ <Base title={strings.colophon} locale={locale} alternatePath={colophonPath(otherLocale(locale))}>
16
+ <h1 class="page-title">{strings.colophon}</h1>
17
+ {settings?.tagline ? <p class="lead">{settings.tagline}</p> : null}
18
+ <div class="prose" set:html={settings?.colophon?.html ?? ''} />
19
+ </Base>
@@ -0,0 +1,9 @@
1
+ import { pinnedSnapshot } from '../lib/shapio.js';
2
+ import { LOCALES } from '../lib/site.js';
3
+
4
+ /** `/build.json`: which publication snapshot this build shows (for checks and deployment debugging). */
5
+ export const GET = async () =>
6
+ new Response(
7
+ JSON.stringify({ snapshot: await pinnedSnapshot(), locales: LOCALES, builtAt: new Date().toISOString() }),
8
+ { headers: { 'content-type': 'application/json' } },
9
+ );
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { DEFAULT_LOCALE, HOME_SLUG, pagePath } from '../lib/site.js';
3
+
4
+ const target = pagePath(DEFAULT_LOCALE, HOME_SLUG);
5
+ ---
6
+
7
+ <!doctype html>
8
+ <html lang="en">
9
+ <head>
10
+ <meta charset="utf-8" />
11
+ <title>Redirecting…</title>
12
+ <meta http-equiv="refresh" content={`0; url=${target}`} />
13
+ <link rel="canonical" href={target} />
14
+ </head>
15
+ <body>
16
+ <a href={target}>Continue to the site</a>
17
+ </body>
18
+ </html>
@@ -0,0 +1,21 @@
1
+ ---
2
+ import '../styles/site.css';
3
+ import { publicShapioUrl } from '../lib/config.js';
4
+ ---
5
+
6
+ <!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <meta name="robots" content="noindex" />
12
+ <meta name="referrer" content="no-referrer" />
13
+ <title>Preview</title>
14
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
15
+ </head>
16
+ <body data-shapio-url={publicShapioUrl()}>
17
+ <div class="preview-banner" id="preview-banner" role="status">Preview</div>
18
+ <main id="content" aria-live="polite"></main>
19
+ <script src="../scripts/preview.ts"></script>
20
+ </body>
21
+ </html>
@@ -0,0 +1,47 @@
1
+ import { initVisualEditing } from '@shapio/visual';
2
+ import { loadPreview, parsePreviewUrl, type PreviewRequest } from '../lib/preview.js';
3
+ import { stringsFor } from '../lib/site.js';
4
+
5
+ /**
6
+ * The preview page's browser side: read the request, drop the token from the address bar, render the draft.
7
+ * Inside Shapio's preview pane (`?shapio-visual=1`), @shapio/visual turns on visual editing: a click on a
8
+ * field focuses it in Shapio, and each save there re-renders the draft here (with the token kept in memory,
9
+ * since the address bar no longer has it).
10
+ */
11
+ const show = (element: HTMLElement | null, text: string) => {
12
+ if (element) {
13
+ element.textContent = text;
14
+ }
15
+ };
16
+
17
+ const render = async (shapioUrl: string, request: PreviewRequest, content: HTMLElement) => {
18
+ const strings = stringsFor(request.locale);
19
+ try {
20
+ const { html, title } = await loadPreview(shapioUrl, request);
21
+ document.title = `${title} · ${strings.siteName}`;
22
+ content.innerHTML = html;
23
+ } catch (error) {
24
+ show(content, `${strings.previewFailed}: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+ };
27
+
28
+ const run = async () => {
29
+ const content = document.getElementById('content');
30
+ const banner = document.getElementById('preview-banner');
31
+ const request = parsePreviewUrl(new URL(window.location.href));
32
+ // The token must not linger in the address bar, history or a shared screenshot.
33
+ window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`);
34
+ if (!request || !content) {
35
+ show(content, 'This preview link is incomplete.');
36
+ return;
37
+ }
38
+ const strings = stringsFor(request.locale);
39
+ const shapioUrl = document.body.dataset.shapioUrl ?? '';
40
+ document.documentElement.lang = request.locale;
41
+ show(banner, strings.previewBanner);
42
+ show(content, strings.previewLoading);
43
+ await render(shapioUrl, request, content);
44
+ initVisualEditing({ origin: shapioUrl, onRefresh: () => render(shapioUrl, request, content) });
45
+ };
46
+
47
+ void run();
@@ -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,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2024",
4
+ "lib": ["es2024", "dom", "dom.iterable"],
5
+ "module": "nodenext",
6
+ "moduleResolution": "nodenext",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "verbatimModuleSyntax": true,
10
+ "isolatedModules": true,
11
+ "resolveJsonModule": true,
12
+ "skipLibCheck": true,
13
+ "noEmit": true,
14
+ "types": ["node"]
15
+ },
16
+ "include": ["src/**/*.ts", "scripts/**/*.ts"]
17
+ }
@@ -0,0 +1,15 @@
1
+ # Copy to .env, or let `npm run seed` write SHAPIO_URL and SHAPIO_DELIVERY_TOKEN for you.
2
+ SHAPIO_URL=http://localhost:4300
3
+ # A read-only delivery token (Shapio admin: Settings → API tokens, a delivery role).
4
+ SHAPIO_DELIVERY_TOKEN=
5
+ # Optional: build this publication snapshot instead of the latest one. The site then stays at it:
6
+ # /api/revalidate does nothing.
7
+ # SHAPIO_SNAPSHOT=42
8
+ # The signing secret of the Shapio webhook that calls /api/revalidate (`npm run seed` writes it).
9
+ SHAPIO_WEBHOOK_SECRET=
10
+ # Optional: the site key on a multi-site Shapio (default: the token's site, else the primary site).
11
+ # SHAPIO_SITE=marketing
12
+ # Next.js collects anonymous telemetry unless this is set; the npm scripts set it too.
13
+ NEXT_TELEMETRY_DISABLED=1
14
+ # Optional: the Shapio URL the browser calls for draft previews, when it differs from SHAPIO_URL.
15
+ # NEXT_PUBLIC_SHAPIO_URL=https://cms.example.com
@@ -0,0 +1,69 @@
1
+ # Shapio starter: Next.js
2
+
3
+ A [Next.js](https://nextjs.org) (App Router) site built from [Shapio](https://github.com/mybrokengnome/shapio)
4
+ content: pages made of sections, a journal of articles with an author, and the `siteSettings` singleton, in
5
+ English and French. Every page is generated at build time from one pinned publication snapshot and served by
6
+ `next start`, which refreshes only the pages a publish changed (on-demand revalidation, below).
7
+
8
+ You need a running Shapio (>= 0.1) with an owner account.
9
+
10
+ ```sh
11
+ npm install
12
+ SHAPIO_URL=http://localhost:4300 SHAPIO_ADMIN_EMAIL=you@example.com SHAPIO_ADMIN_PASSWORD='…' npm run seed
13
+ npm run build
14
+ npm run start # http://localhost:3000/ (redirects to /en/)
15
+ npm run smoke # in another shell: checks the served pages over HTTP
16
+ npm run smoke -- --revalidate # also checks /api/revalidate with a signed test event
17
+ npm test # unit tests (path mapping, signature check)
18
+ ```
19
+
20
+ `npm run seed` applies the models in `shapio/` to Shapio live, creates the content and writes `SHAPIO_URL` and
21
+ `SHAPIO_DELIVERY_TOKEN` to `.env`. `SHAPIO_SNAPSHOT` pins a build to an exact snapshot (by default
22
+ `next.config.ts` pins the latest one when the build starts); `SHAPIO_SITE` picks the site on a multi-site
23
+ instance. Next.js telemetry is off: `NEXT_TELEMETRY_DISABLED=1` in the npm scripts and `.env.example`.
24
+
25
+ | File | Role |
26
+ | ---------------------------- | ------------------------------------------------------------------- |
27
+ | `next.config.ts` | pins the snapshot; `/` redirects to `/en/`; `frame-ancestors` |
28
+ | `src/lib/shapio.ts` | reads the delivery API with `@shapio/client` at the pinned snapshot |
29
+ | `src/app/[locale]/…` | the layout (site settings), pages, articles and the colophon |
30
+ | `src/components/…` | sections, articles and responsive images |
31
+ | `src/app/preview/` | draft preview with visual editing (`@shapio/visual`) |
32
+ | `src/app/api/revalidate/` | the webhook target: on-demand revalidation of changed pages |
33
+ | `src/lib/revalidation*.ts` | the snapshot diff and which pages a change touches |
34
+ | `shapio/`, `scripts/seed.ts` | the models and the seed |
35
+
36
+ Preview: the seed creates a deployment connection named **Preview** that opens drafts at
37
+ `http://localhost:3000/preview/` (set `SITE_URL` when seeding for another address). Add that origin to Shapio's
38
+ `CORS_ORIGINS`. In Shapio's preview pane, clicking the title, body or cover focuses that field and saves
39
+ re-render the draft ([visual editing](https://github.com/mybrokengnome/shapio/blob/main/documentation/visual-editing.md)). `NEXT_PUBLIC_SHAPIO_URL` sets the Shapio URL the browser
40
+ calls, when it differs from `SHAPIO_URL`.
41
+
42
+ ## Incremental rebuilds (on-demand revalidation)
43
+
44
+ With `npm run start`, a publish refreshes only the pages it changed, without a rebuild:
45
+
46
+ 1. `npm run seed` creates a Shapio webhook named **Site revalidation** that sends `entry.published`,
47
+ `entry.unpublished`, `entry.deleted`, `change_set.shipped`, `schema.activated` and `schema.deleted` to
48
+ `http://localhost:3000/api/revalidate/` (`SITE_URL` + `/api/revalidate/`, with the trailing slash: the site
49
+ uses `trailingSlash`, and Shapio does not follow the redirect), and writes its signing secret to
50
+ `.env` as `SHAPIO_WEBHOOK_SECRET` (every run rotates it). Shapio never calls loopback or private addresses
51
+ on its own: for a local site, start Shapio with `OUTBOUND_PRIVATE_NETWORK_ALLOWLIST=127.0.0.1/32,::1/128`
52
+ (the seed turns on "Allow private network" for a `localhost` URL; without the allowlist Shapio refuses the
53
+ webhook, and the seed says so and goes on without it).
54
+ 2. The route checks the signature (`verifyWebhookSignature` from `@shapio/client`), then asks Shapio what
55
+ changed between the snapshot the site shows and the current one (`/api/snapshots/changes`), and calls
56
+ `revalidatePath` for those pages: an article's page and its locale's list, a page by its slug (`home` is
57
+ `/en/`), the list and every article page for an author, every page for the `siteSettings` singleton or a
58
+ schema change. A changed slug refreshes the old and the new address.
59
+ 3. The site moves to the new snapshot: revalidated pages re-render at it, and the snapshot is kept in
60
+ `.next/shapio-revalidate.json` so a restart carries on from it. `next build` empties `.next/`, so a new build
61
+ starts again from its own snapshot.
62
+
63
+ Articles and pages published after the build render on their first request (`dynamicParams = true`); unknown
64
+ slugs are a 404. A build pinned with `SHAPIO_SNAPSHOT` keeps showing that snapshot: the route answers with the
65
+ reason and revalidates nothing. The snapshot is held per `next start` process, so run one process per copy of
66
+ the site, each with its own webhook. In Shapio, Publishing → Webhooks shows every delivery and can send a test.
67
+
68
+ Guide: [site starters](https://github.com/mybrokengnome/shapio/blob/main/documentation/starters.md). Inside the
69
+ Shapio repository, run the scripts with `pnpm --filter example-next <script>` after `pnpm build`.
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ .next/
3
+ next-env.d.ts
4
+ *.tsbuildinfo
5
+ .env
@@ -0,0 +1,69 @@
1
+ import { createClient } from '@shapio/client';
2
+ import type { NextConfig } from 'next';
3
+ import { PHASE_PRODUCTION_BUILD } from 'next/constants.js';
4
+
5
+ /**
6
+ * Pins one publication snapshot for the whole `next build`: Next renders pages in several worker processes, so
7
+ * the snapshot is read here, once, before any page renders, and handed to every worker as SHAPIO_SNAPSHOT
8
+ * (inlined into the build). A set SHAPIO_SNAPSHOT wins and pins the site for good; otherwise `next start` moves
9
+ * forward from this snapshot as Shapio's webhook calls /api/revalidate (src/lib/liveSnapshot.ts). `next dev` and
10
+ * `next start` do not read Shapio here.
11
+ */
12
+ const pinSnapshot = async (): Promise<string> => {
13
+ if (process.env.SHAPIO_SNAPSHOT) {
14
+ return process.env.SHAPIO_SNAPSHOT;
15
+ }
16
+ const token = process.env.SHAPIO_DELIVERY_TOKEN;
17
+ if (!token) {
18
+ throw new Error('Set SHAPIO_DELIVERY_TOKEN to build the site; `npm run seed` writes one to .env');
19
+ }
20
+ const client = createClient({
21
+ baseUrl: process.env.SHAPIO_URL || 'http://localhost:4300',
22
+ token,
23
+ site: process.env.SHAPIO_SITE || undefined,
24
+ });
25
+ const { snapshot } = await client.snapshots.current();
26
+ return String(snapshot);
27
+ };
28
+
29
+ const originOf = (url: string) => {
30
+ try {
31
+ return new URL(url).origin;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ };
36
+
37
+ /**
38
+ * Only this site and Shapio may frame its pages (Shapio's preview pane shows /preview/ beside the document).
39
+ * The origin comes from NEXT_PUBLIC_SHAPIO_URL, else SHAPIO_URL.
40
+ */
41
+ const frameAncestors = () => {
42
+ const shapio = originOf(
43
+ process.env.NEXT_PUBLIC_SHAPIO_URL || process.env.SHAPIO_URL || 'http://localhost:4300',
44
+ );
45
+ return `frame-ancestors 'self'${shapio ? ` ${shapio}` : ''}`;
46
+ };
47
+
48
+ const nextConfig = async (phase: string): Promise<NextConfig> => ({
49
+ trailingSlash: true,
50
+ headers: async () => [
51
+ { source: '/:path*', headers: [{ key: 'Content-Security-Policy', value: frameAncestors() }] },
52
+ ],
53
+ // The front page lives under its locale (`/en/`), as in the Astro and SvelteKit starters.
54
+ redirects: async () => [{ source: '/', destination: '/en/', permanent: false }],
55
+ env: {
56
+ // The preview page calls Shapio from the browser: NEXT_PUBLIC_SHAPIO_URL, else SHAPIO_URL.
57
+ NEXT_PUBLIC_SHAPIO_URL:
58
+ process.env.NEXT_PUBLIC_SHAPIO_URL || process.env.SHAPIO_URL || 'http://localhost:4300',
59
+ ...(phase === PHASE_PRODUCTION_BUILD
60
+ ? {
61
+ SHAPIO_SNAPSHOT: await pinSnapshot(),
62
+ // A snapshot set by hand stays the site's snapshot under `next start`: /api/revalidate does nothing.
63
+ SHAPIO_SNAPSHOT_PINNED: process.env.SHAPIO_SNAPSHOT ? 'true' : 'false',
64
+ }
65
+ : {}),
66
+ },
67
+ });
68
+
69
+ export default nextConfig;
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "example-next",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Next.js (App Router) starter: pages and articles from Shapio, statically generated at one pinned snapshot, EN + FR.",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "scripts": {
11
+ "dev": "NEXT_TELEMETRY_DISABLED=1 next dev",
12
+ "build": "NEXT_TELEMETRY_DISABLED=1 next build",
13
+ "start": "NEXT_TELEMETRY_DISABLED=1 next start",
14
+ "seed": "node --env-file-if-exists=.env --import tsx scripts/seed.ts --site-url http://localhost:3000 --revalidate-path /api/revalidate/",
15
+ "smoke": "node --env-file-if-exists=.env --import tsx scripts/smokeHttp.ts --url http://localhost:3000",
16
+ "typecheck": "tsc --noEmit -p tsconfig.json",
17
+ "test": "vitest run"
18
+ },
19
+ "dependencies": {
20
+ "@shapio/client": "^0.2.0",
21
+ "@shapio/visual": "^0.2.0",
22
+ "next": "16.3.8",
23
+ "react": "19.3.0",
24
+ "react-dom": "19.3.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "24.19.1",
28
+ "@types/react": "19.3.0",
29
+ "@types/react-dom": "19.3.0",
30
+ "tsx": "4.23.15",
31
+ "typescript": "6.0.3",
32
+ "vitest": "5.0.3"
33
+ }
34
+ }
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Shapio">
2
+ <title>Shapio</title>
3
+ <rect width="64" height="64" rx="14" fill="#2563EB"/>
4
+ <g transform="translate(32 32) scale(0.72) translate(-32 -32)">
5
+ <path fill="#FAF9F6" d="M43.39 8.16L51.31 12.73A8.25 8.25 0 0 1 55.43 19.88L55.43 21.08A4.72 4.72 0 0 1 48.36 25.17L33.51 16.59ZM20.61 55.84L12.69 51.27A8.25 8.25 0 0 1 8.57 44.12L8.57 42.92A4.72 4.72 0 0 1 15.64 38.83L30.49 47.41Z"/>
6
+ <path fill="#FAF9F6" d="M30.23 5.67A8.84 8.84 0 0 1 39.07 5.67L47.52 10.54L26.62 22.61A3.24 3.24 0 0 0 25 25.42L25 26.92A2.65 2.65 0 0 0 26.33 29.21L29.37 30.97A1.47 1.47 0 0 1 29.37 33.52L24.24 36.48A3.24 3.24 0 0 1 21 36.48L15.27 33.17A10.61 10.61 0 0 1 9.97 23.98L9.97 23.49A10.61 10.61 0 0 1 15.27 14.3ZM33.77 58.33A8.84 8.84 0 0 1 24.93 58.33L16.48 53.46L37.38 41.39A3.24 3.24 0 0 0 39 38.58L39 37.08A2.65 2.65 0 0 0 37.67 34.79L34.63 33.03A1.47 1.47 0 0 1 34.63 30.48L39.76 27.52A3.24 3.24 0 0 1 43 27.52L48.73 30.83A10.61 10.61 0 0 1 54.03 40.02L54.03 40.51A10.61 10.61 0 0 1 48.73 49.7Z"/>
7
+ <path fill="none" stroke="#2563EB" stroke-width="1.1" d="M35.28 17.61L47.52 10.54M28.72 46.39L16.48 53.46"/>
8
+ </g>
9
+ </svg>