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
+ import type { Metadata } from 'next';
2
+ import { ArticleCard } from '../../../components/ArticleCard';
3
+ import { listArticles } from '../../../lib/shapio';
4
+ import { stringsFor, toLocale } from '../../../lib/site';
5
+
6
+ type ArticlesParams = { params: Promise<{ locale: string }> };
7
+
8
+ export const generateMetadata = async ({ params }: ArticlesParams): Promise<Metadata> => ({
9
+ title: stringsFor(toLocale((await params).locale)).articles,
10
+ });
11
+
12
+ const ArticlesPage = async ({ params }: ArticlesParams) => {
13
+ const locale = toLocale((await params).locale);
14
+ const strings = stringsFor(locale);
15
+ const articles = await listArticles(locale);
16
+ return (
17
+ <>
18
+ <h1 className="page-title">{strings.articles}</h1>
19
+ <ul className="cards">
20
+ {articles.map((article) => (
21
+ <ArticleCard key={article.id} article={article} locale={locale} strings={strings} />
22
+ ))}
23
+ </ul>
24
+ </>
25
+ );
26
+ };
27
+
28
+ export default ArticlesPage;
@@ -0,0 +1,25 @@
1
+ import type { Metadata } from 'next';
2
+ import { RichText } from '../../../components/RichText';
3
+ import { getSiteSettings } from '../../../lib/shapio';
4
+ import { stringsFor, toLocale } from '../../../lib/site';
5
+
6
+ type ColophonParams = { params: Promise<{ locale: string }> };
7
+
8
+ export const generateMetadata = async ({ params }: ColophonParams): Promise<Metadata> => ({
9
+ title: stringsFor(toLocale((await params).locale)).colophon,
10
+ });
11
+
12
+ /** The singleton page: the siteSettings entry's colophon, one per locale. */
13
+ const ColophonPage = async ({ params }: ColophonParams) => {
14
+ const locale = toLocale((await params).locale);
15
+ const settings = await getSiteSettings(locale);
16
+ return (
17
+ <>
18
+ <h1 className="page-title">{stringsFor(locale).colophon}</h1>
19
+ {settings?.tagline ? <p className="lead">{settings.tagline}</p> : null}
20
+ <RichText value={settings?.colophon ?? null} />
21
+ </>
22
+ );
23
+ };
24
+
25
+ export default ColophonPage;
@@ -0,0 +1,77 @@
1
+ import type { Metadata } from 'next';
2
+ import Link from 'next/link';
3
+ import { notFound } from 'next/navigation';
4
+ import type { ReactNode } from 'react';
5
+ import { LocaleSwitch } from '../../components/LocaleSwitch';
6
+ import { getSiteSettings } from '../../lib/shapio';
7
+ import {
8
+ articlesPath,
9
+ colophonPath,
10
+ HOME_SLUG,
11
+ isLocale,
12
+ LOCALES,
13
+ otherLocale,
14
+ pagePath,
15
+ stringsFor,
16
+ toLocale,
17
+ } from '../../lib/site';
18
+ import '../site.css';
19
+
20
+ /**
21
+ * Only the site's locales exist; any other first segment is a 404 (notFound below). Not `dynamicParams = false`:
22
+ * Next answers a page that /api/revalidate marked stale with a 404 when its route allows no fallback, so every
23
+ * route here renders on demand and rejects unknown values itself.
24
+ */
25
+ export const dynamicParams = true;
26
+ export const generateStaticParams = () => LOCALES.map((locale) => ({ locale }));
27
+
28
+ type LocaleParams = { params: Promise<{ locale: string }> };
29
+
30
+ export const generateMetadata = async ({ params }: LocaleParams): Promise<Metadata> => {
31
+ const locale = toLocale((await params).locale);
32
+ const siteName = (await getSiteSettings(locale))?.siteName ?? stringsFor(locale).siteName;
33
+ return { title: { template: `%s · ${siteName}`, default: siteName }, icons: '/favicon.svg' };
34
+ };
35
+
36
+ type LocaleLayoutProps = LocaleParams & { children: ReactNode };
37
+
38
+ /** The root layout: every page lives under its locale (`/en/…`, `/fr/…`). */
39
+ const LocaleLayout = async ({ children, params }: LocaleLayoutProps) => {
40
+ const segment = (await params).locale;
41
+ if (!isLocale(segment)) {
42
+ notFound();
43
+ }
44
+ const locale = toLocale(segment);
45
+ const strings = stringsFor(locale);
46
+ // The name, tagline and footer come from the siteSettings singleton (built-in strings until it is published).
47
+ const settings = await getSiteSettings(locale);
48
+ const other = otherLocale(locale);
49
+ return (
50
+ <html lang={locale}>
51
+ <body>
52
+ <a className="skip" href="#content">
53
+ {strings.skipToContent}
54
+ </a>
55
+ <header className="site-header">
56
+ <Link className="brand" href={pagePath(locale, HOME_SLUG)}>
57
+ {settings?.siteName ?? strings.siteName}
58
+ </Link>
59
+ {settings?.tagline ? <p className="tagline">{settings.tagline}</p> : null}
60
+ <nav aria-label="Main">
61
+ <Link href={articlesPath(locale)}>{strings.articles}</Link>
62
+ <Link href={colophonPath(locale)}>{strings.colophon}</Link>
63
+ <LocaleSwitch to={other} label={strings.switchTo} />
64
+ </nav>
65
+ </header>
66
+ <main id="content">{children}</main>
67
+ <footer className="site-footer">
68
+ <p>
69
+ {settings?.siteName ?? strings.siteName} · {settings?.footer ?? strings.footer} · Next.js
70
+ </p>
71
+ </footer>
72
+ </body>
73
+ </html>
74
+ );
75
+ };
76
+
77
+ export default LocaleLayout;
@@ -0,0 +1,31 @@
1
+ import { webhookSecret } from '../../../lib/config';
2
+ import { log } from '../../../lib/log';
3
+ import { revalidateChanges } from '../../../lib/revalidation';
4
+ import { REVALIDATING_EVENTS } from '../../../lib/revalidationTargets';
5
+ import { readWebhookEvent } from '../../../lib/webhookEvent';
6
+
7
+ /**
8
+ * POST /api/revalidate/ (with the slash: `trailingSlash` redirects the other form, and Shapio's webhooks do not
9
+ * follow redirects): the target of the Shapio webhook the seed creates (publish, unpublish, delete,
10
+ * change set shipped, schema change). A signed delivery refreshes the pages whose content changed since the
11
+ * snapshot the site shows (src/lib/revalidation.ts). A failure answers 500, so Shapio retries the delivery.
12
+ */
13
+ export const POST = async (request: Request) => {
14
+ const read = await readWebhookEvent(request, webhookSecret());
15
+ if (!read.ok) {
16
+ log(`Refused a webhook delivery: ${read.reason}`);
17
+ return Response.json({ error: read.reason }, { status: read.status });
18
+ }
19
+ const { event } = read;
20
+ if (!REVALIDATING_EVENTS.includes(event.type)) {
21
+ return Response.json({ event: event.type, ignored: true });
22
+ }
23
+ try {
24
+ return Response.json({ event: event.type, ...(await revalidateChanges()) });
25
+ } catch (error) {
26
+ log(
27
+ `Revalidation for ${event.type} ${event.id} failed: ${error instanceof Error ? error.message : String(error)}`,
28
+ );
29
+ return Response.json({ error: 'revalidation failed' }, { status: 500 });
30
+ }
31
+ };
@@ -0,0 +1,20 @@
1
+ import type { Metadata } from 'next';
2
+ import type { ReactNode } from 'react';
3
+ import '../site.css';
4
+
5
+ /** Drafts are never indexed, and the token in the URL is never sent on as a Referer. */
6
+ export const metadata: Metadata = {
7
+ title: 'Preview',
8
+ robots: { index: false, follow: false },
9
+ referrer: 'no-referrer',
10
+ icons: '/favicon.svg',
11
+ };
12
+
13
+ /** The preview's own root layout (outside `[locale]`): the draft renders client-side with the token. */
14
+ const PreviewLayout = ({ children }: { children: ReactNode }) => (
15
+ <html lang="en">
16
+ <body>{children}</body>
17
+ </html>
18
+ );
19
+
20
+ export default PreviewLayout;
@@ -0,0 +1,6 @@
1
+ import { Preview } from '../../components/Preview';
2
+
3
+ /** `/preview/`: the draft Shapio's preview URL points at (see src/lib/preview.ts). */
4
+ const PreviewPage = () => <Preview />;
5
+
6
+ export default PreviewPage;
@@ -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,19 @@
1
+ import { shapioAttr } from '@shapio/visual';
2
+ import Link from 'next/link';
3
+ import { articlePath, type Strings } from '../../lib/site';
4
+ import type { Article } from '../../lib/types';
5
+ import { Byline } from '../Byline';
6
+ import { ResponsiveImage } from '../ResponsiveImage';
7
+
8
+ type ArticleCardProps = { article: Article; locale: string; strings: Strings };
9
+
10
+ export const ArticleCard = ({ article, locale, strings }: ArticleCardProps) => (
11
+ <li className="card">
12
+ {article.cover ? <ResponsiveImage media={article.cover} sizes="(min-width: 48rem) 33vw, 100vw" /> : null}
13
+ <h2 {...shapioAttr(article, 'title')}>
14
+ <Link href={articlePath(locale, article.slug)}>{article.title}</Link>
15
+ </h2>
16
+ <Byline article={article} locale={locale} strings={strings} />
17
+ {article.excerpt ? <p>{article.excerpt}</p> : null}
18
+ </li>
19
+ );
@@ -0,0 +1,40 @@
1
+ import { shapioAttr } from '@shapio/visual';
2
+ import { authorOf } from '../../lib/articles';
3
+ import type { Strings } from '../../lib/site';
4
+ import type { Article } from '../../lib/types';
5
+ import { Byline } from '../Byline';
6
+ import { ResponsiveImage } from '../ResponsiveImage';
7
+ import { RichText } from '../RichText';
8
+
9
+ type ArticleViewProps = { article: Article; locale: string; strings: Strings };
10
+
11
+ export const ArticleView = ({ article, locale, strings }: ArticleViewProps) => {
12
+ const author = authorOf(article);
13
+ return (
14
+ <article className="article" lang={article.locale}>
15
+ <header>
16
+ <h1 {...shapioAttr(article, 'title')}>{article.title}</h1>
17
+ <Byline article={article} locale={locale} strings={strings} />
18
+ </header>
19
+ {article.cover ? (
20
+ <ResponsiveImage
21
+ media={article.cover}
22
+ sizes="(min-width: 48rem) 48rem, 100vw"
23
+ eager
24
+ className="cover"
25
+ visual={shapioAttr(article, 'cover')}
26
+ />
27
+ ) : null}
28
+ <RichText value={article.body} visual={shapioAttr(article, 'body')} />
29
+ {author ? (
30
+ <aside className="author">
31
+ {author.avatar ? <ResponsiveImage media={author.avatar} sizes="4rem" className="avatar" /> : null}
32
+ <div>
33
+ <p className="author-name">{author.name}</p>
34
+ {author.bio ? <p>{author.bio}</p> : null}
35
+ </div>
36
+ </aside>
37
+ ) : null}
38
+ </article>
39
+ );
40
+ };
@@ -0,0 +1,15 @@
1
+ import { authorOf } from '../../lib/articles';
2
+ import { formatDate, type Strings } from '../../lib/site';
3
+ import type { Article } from '../../lib/types';
4
+
5
+ type BylineProps = { article: Article; locale: string; strings: Strings };
6
+
7
+ export const Byline = ({ article, locale, strings }: BylineProps) => {
8
+ const author = authorOf(article);
9
+ return (
10
+ <p className="byline">
11
+ {author ? `${strings.by} ${author.name} · ` : null}
12
+ <time dateTime={article.publishedOn}>{formatDate(article.publishedOn, locale)}</time>
13
+ </p>
14
+ );
15
+ };
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import { switchLocale, type Locale } from '../../lib/site';
5
+
6
+ type LocaleSwitchProps = { to: Locale; label: string };
7
+
8
+ /** The current page in the other locale (slugs are shared across locales). */
9
+ export const LocaleSwitch = ({ to, label }: LocaleSwitchProps) => {
10
+ const pathname = usePathname();
11
+ return (
12
+ <a href={switchLocale(pathname, to)} hrefLang={to} lang={to}>
13
+ {label}
14
+ </a>
15
+ );
16
+ };
@@ -0,0 +1,53 @@
1
+ import { initVisualEditing } from '@shapio/visual';
2
+ import { useEffect, useState } from 'react';
3
+ import { publicShapioUrl } from '../../../lib/config';
4
+ import { loadDraft, parsePreviewUrl, type PreviewDraft } from '../../../lib/preview';
5
+ import { DEFAULT_LOCALE, type Locale } from '../../../lib/site';
6
+
7
+ export type PreviewState =
8
+ | { status: 'loading'; locale: Locale }
9
+ | { status: 'incomplete'; locale: Locale }
10
+ | { status: 'failed'; locale: Locale; message: string }
11
+ | { status: 'ready'; locale: Locale; draft: PreviewDraft };
12
+
13
+ /**
14
+ * Reads the preview request from the URL once, drops the token from the address bar, and loads the draft.
15
+ * Inside Shapio's preview pane, @shapio/visual turns on visual editing and re-loads the draft after each save
16
+ * (the token stays in memory: the address bar no longer has it).
17
+ */
18
+ export const usePreviewDraft = (): PreviewState => {
19
+ const [state, setState] = useState<PreviewState>({ status: 'loading', locale: DEFAULT_LOCALE });
20
+ useEffect(() => {
21
+ const request = parsePreviewUrl(new URL(window.location.href));
22
+ // The token must not linger in the address bar, history or a shared screenshot.
23
+ window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`);
24
+ if (!request) {
25
+ setState({ status: 'incomplete', locale: DEFAULT_LOCALE });
26
+ return undefined;
27
+ }
28
+ let active = true;
29
+ const load = async () => {
30
+ try {
31
+ const draft = await loadDraft(publicShapioUrl(), request);
32
+ if (active) {
33
+ setState({ status: 'ready', locale: request.locale, draft });
34
+ }
35
+ } catch (error) {
36
+ if (active) {
37
+ setState({
38
+ status: 'failed',
39
+ locale: request.locale,
40
+ message: error instanceof Error ? error.message : String(error),
41
+ });
42
+ }
43
+ }
44
+ };
45
+ void load();
46
+ const stop = initVisualEditing({ origin: publicShapioUrl(), onRefresh: load });
47
+ return () => {
48
+ active = false;
49
+ stop();
50
+ };
51
+ }, []);
52
+ return state;
53
+ };
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+ import { stringsFor } from '../../lib/site';
3
+ import { ArticleView } from '../ArticleView';
4
+ import { Sections } from '../Sections';
5
+ import { usePreviewDraft } from './hooks/usePreviewDraft';
6
+
7
+ /** A draft page or article, rendered in the browser with the published pages' components. */
8
+ export const Preview = () => {
9
+ const state = usePreviewDraft();
10
+ const strings = stringsFor(state.locale);
11
+ return (
12
+ <>
13
+ <div className="preview-banner" role="status">
14
+ {strings.previewBanner}
15
+ </div>
16
+ <main id="content" lang={state.locale}>
17
+ {state.status === 'loading' ? <p>{strings.previewLoading}</p> : null}
18
+ {state.status === 'incomplete' ? <p>{strings.previewIncomplete}</p> : null}
19
+ {state.status === 'failed' ? (
20
+ <p>
21
+ {strings.previewFailed}: {state.message}
22
+ </p>
23
+ ) : null}
24
+ {state.status === 'ready' && state.draft.modelKey === 'pages' ? (
25
+ <Sections page={state.draft.entry} />
26
+ ) : null}
27
+ {state.status === 'ready' && state.draft.modelKey === 'articles' ? (
28
+ <ArticleView article={state.draft.entry} locale={state.locale} strings={strings} />
29
+ ) : null}
30
+ </main>
31
+ </>
32
+ );
33
+ };
@@ -0,0 +1,36 @@
1
+ import type { ShapioAttributes } from '@shapio/visual';
2
+ import type { Media } from '../../lib/types';
3
+
4
+ type ResponsiveImageProps = {
5
+ media: Media;
6
+ sizes: string;
7
+ eager?: boolean;
8
+ className?: string;
9
+ /** The field the image shows (`shapioAttr`), for visual editing. */
10
+ visual?: ShapioAttributes;
11
+ };
12
+
13
+ /** WebP variants in `srcSet`, intrinsic size set (no layout shift), lazy unless eager. */
14
+ export const ResponsiveImage = ({ media, sizes, eager = false, className, visual }: ResponsiveImageProps) => {
15
+ const widths = media.variants
16
+ .filter((variant) => variant.name.startsWith('w') && variant.width !== null)
17
+ .sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
18
+ const largest = widths.at(-1);
19
+ return (
20
+ <img
21
+ src={largest?.url ?? media.url}
22
+ srcSet={
23
+ widths.length > 0 ? widths.map((variant) => `${variant.url} ${variant.width}w`).join(', ') : undefined
24
+ }
25
+ sizes={widths.length > 0 ? sizes : undefined}
26
+ width={media.width ?? undefined}
27
+ height={media.height ?? undefined}
28
+ alt={media.alt}
29
+ loading={eager ? 'eager' : 'lazy'}
30
+ fetchPriority={eager ? 'high' : undefined}
31
+ decoding="async"
32
+ className={className}
33
+ {...visual}
34
+ />
35
+ );
36
+ };
@@ -0,0 +1,13 @@
1
+ import type { ShapioAttributes } from '@shapio/visual';
2
+ import type { RichText as RichTextValue } from '../../lib/types';
3
+
4
+ type RichTextProps = {
5
+ value: RichTextValue | null;
6
+ className?: string;
7
+ /** The field it shows (`shapioAttr`), for visual editing. */
8
+ visual?: ShapioAttributes;
9
+ };
10
+
11
+ /** Shapio's rich text arrives with a sanitised `html` rendering next to the JSON document. */
12
+ export const RichText = ({ value, className = 'prose', visual }: RichTextProps) =>
13
+ value ? <div className={className} {...visual} dangerouslySetInnerHTML={{ __html: value.html }} /> : null;
@@ -0,0 +1,14 @@
1
+ import { safeHref } from '../../../lib/site';
2
+ import type { CallToActionSection } from '../../../lib/types';
3
+
4
+ type CallToActionProps = { section: CallToActionSection };
5
+
6
+ export const CallToAction = ({ section }: CallToActionProps) => (
7
+ <section className="cta">
8
+ <h2>{section.heading}</h2>
9
+ {section.text ? <p>{section.text}</p> : null}
10
+ <a className="button" href={safeHref(section.buttonUrl)}>
11
+ {section.buttonLabel}
12
+ </a>
13
+ </section>
14
+ );
@@ -0,0 +1,17 @@
1
+ import type { FeatureGridSection } from '../../../lib/types';
2
+
3
+ type FeatureGridProps = { section: FeatureGridSection };
4
+
5
+ export const FeatureGrid = ({ section }: FeatureGridProps) => (
6
+ <section className="features">
7
+ {section.heading ? <h2>{section.heading}</h2> : null}
8
+ <ul className="feature-grid">
9
+ {section.features.map((feature) => (
10
+ <li key={feature.title}>
11
+ <h3>{feature.title}</h3>
12
+ {feature.description ? <p>{feature.description}</p> : null}
13
+ </li>
14
+ ))}
15
+ </ul>
16
+ </section>
17
+ );
@@ -0,0 +1,20 @@
1
+ import type { GallerySection } from '../../../lib/types';
2
+ import { ResponsiveImage } from '../../ResponsiveImage';
3
+
4
+ type GalleryProps = { section: GallerySection };
5
+
6
+ export const Gallery = ({ section }: GalleryProps) => (
7
+ <section className="gallery">
8
+ {section.heading ? <h2>{section.heading}</h2> : null}
9
+ <ul className="gallery-grid">
10
+ {section.images.map((image) => (
11
+ <li key={image.id}>
12
+ <figure>
13
+ <ResponsiveImage media={image} sizes="(min-width: 48rem) 33vw, 100vw" />
14
+ {image.caption ? <figcaption>{image.caption}</figcaption> : null}
15
+ </figure>
16
+ </li>
17
+ ))}
18
+ </ul>
19
+ </section>
20
+ );