create-shapio 0.3.0 → 0.4.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.
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/astro/.env.example +2 -0
- package/templates/astro/package.json +2 -2
- package/templates/astro/scripts/lib/content.ts +46 -5
- package/templates/astro/scripts/seed.ts +26 -4
- package/templates/astro/scripts/smoke.ts +13 -0
- package/templates/astro/scripts/smokeHttp.ts +11 -1
- package/templates/astro/shapio/components/seo.json +114 -0
- package/templates/astro/shapio/models/article.json +21 -0
- package/templates/astro/shapio/models/page.json +21 -0
- package/templates/astro/src/layouts/Base.astro +49 -7
- package/templates/astro/src/lib/preview.ts +1 -0
- package/templates/astro/src/lib/seo.ts +69 -0
- package/templates/astro/src/lib/shapio.ts +23 -4
- package/templates/astro/src/lib/types.ts +7 -0
- package/templates/astro/src/pages/[locale]/[...slug].astro +1 -1
- package/templates/astro/src/pages/[locale]/articles/[slug].astro +1 -1
- package/templates/next/.env.example +2 -0
- package/templates/next/package.json +2 -2
- package/templates/next/scripts/lib/content.ts +46 -5
- package/templates/next/scripts/seed.ts +26 -4
- package/templates/next/scripts/smokeHttp.ts +11 -1
- package/templates/next/shapio/components/seo.json +114 -0
- package/templates/next/shapio/models/article.json +21 -0
- package/templates/next/shapio/models/page.json +21 -0
- package/templates/next/src/app/[locale]/[[...slug]]/page.tsx +10 -2
- package/templates/next/src/app/[locale]/articles/[slug]/page.tsx +11 -2
- package/templates/next/src/app/[locale]/articles/page.tsx +6 -4
- package/templates/next/src/app/[locale]/colophon/page.tsx +6 -4
- package/templates/next/src/app/[locale]/layout.tsx +5 -1
- package/templates/next/src/app/api/revalidate/route.ts +6 -3
- package/templates/next/src/lib/config.ts +4 -0
- package/templates/next/src/lib/metadata.ts +68 -0
- package/templates/next/src/lib/preview.ts +1 -0
- package/templates/next/src/lib/revalidation.ts +20 -1
- package/templates/next/src/lib/revalidationTargets.test.ts +11 -1
- package/templates/next/src/lib/revalidationTargets.ts +5 -0
- package/templates/next/src/lib/seo.ts +69 -0
- package/templates/next/src/lib/shapio.ts +24 -3
- package/templates/next/src/lib/types.ts +7 -0
- package/templates/sveltekit/.env.example +2 -0
- package/templates/sveltekit/package.json +2 -2
- package/templates/sveltekit/scripts/lib/content.ts +46 -5
- package/templates/sveltekit/scripts/seed.ts +26 -4
- package/templates/sveltekit/scripts/smokeHttp.ts +11 -1
- package/templates/sveltekit/shapio/components/seo.json +114 -0
- package/templates/sveltekit/shapio/models/article.json +21 -0
- package/templates/sveltekit/shapio/models/page.json +21 -0
- package/templates/sveltekit/src/env.ts +1 -0
- package/templates/sveltekit/src/lib/components/Seo.svelte +73 -0
- package/templates/sveltekit/src/lib/preview.ts +1 -0
- package/templates/sveltekit/src/lib/seo.ts +69 -0
- package/templates/sveltekit/src/lib/server/config.ts +5 -0
- package/templates/sveltekit/src/lib/server/shapio.ts +24 -3
- package/templates/sveltekit/src/lib/types.ts +7 -0
- package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +8 -2
- package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.svelte +2 -6
- package/templates/sveltekit/src/routes/[locale]/articles/+page.svelte +2 -3
- package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.svelte +2 -6
- package/templates/sveltekit/src/routes/[locale]/colophon/+page.svelte +2 -3
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"apiKey": "seo",
|
|
3
|
+
"category": "Built-in",
|
|
4
|
+
"description": "Search and social metadata: title, description, image, canonical URL and indexing.",
|
|
5
|
+
"display": {
|
|
6
|
+
"titleFieldId": "3fe294a9-6af9-4019-8b32-43010822e45a"
|
|
7
|
+
},
|
|
8
|
+
"fields": [
|
|
9
|
+
{
|
|
10
|
+
"apiKey": "title",
|
|
11
|
+
"deprecated": false,
|
|
12
|
+
"editor": {
|
|
13
|
+
"id": "textInput",
|
|
14
|
+
"options": {}
|
|
15
|
+
},
|
|
16
|
+
"filterable": false,
|
|
17
|
+
"id": "3fe294a9-6af9-4019-8b32-43010822e45a",
|
|
18
|
+
"label": "Title",
|
|
19
|
+
"localized": false,
|
|
20
|
+
"public": true,
|
|
21
|
+
"required": false,
|
|
22
|
+
"settings": {},
|
|
23
|
+
"sortable": false,
|
|
24
|
+
"type": "string",
|
|
25
|
+
"unique": false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"apiKey": "description",
|
|
29
|
+
"deprecated": false,
|
|
30
|
+
"editor": {
|
|
31
|
+
"id": "textarea",
|
|
32
|
+
"options": {}
|
|
33
|
+
},
|
|
34
|
+
"filterable": false,
|
|
35
|
+
"id": "6d1917f3-94a2-4391-ba1f-a98d14577b93",
|
|
36
|
+
"label": "Description",
|
|
37
|
+
"localized": false,
|
|
38
|
+
"public": true,
|
|
39
|
+
"required": false,
|
|
40
|
+
"settings": {},
|
|
41
|
+
"sortable": false,
|
|
42
|
+
"type": "text",
|
|
43
|
+
"unique": false
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"apiKey": "image",
|
|
47
|
+
"deprecated": false,
|
|
48
|
+
"editor": {
|
|
49
|
+
"id": "mediaPicker",
|
|
50
|
+
"options": {}
|
|
51
|
+
},
|
|
52
|
+
"filterable": false,
|
|
53
|
+
"id": "d27c9354-4afc-4583-93b4-367214a7714a",
|
|
54
|
+
"label": "Social image",
|
|
55
|
+
"localized": false,
|
|
56
|
+
"public": true,
|
|
57
|
+
"required": false,
|
|
58
|
+
"settings": {
|
|
59
|
+
"allowedKinds": [
|
|
60
|
+
"image"
|
|
61
|
+
],
|
|
62
|
+
"multiple": false
|
|
63
|
+
},
|
|
64
|
+
"sortable": false,
|
|
65
|
+
"type": "media",
|
|
66
|
+
"unique": false
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"apiKey": "canonical",
|
|
70
|
+
"deprecated": false,
|
|
71
|
+
"editor": {
|
|
72
|
+
"id": "textInput",
|
|
73
|
+
"options": {}
|
|
74
|
+
},
|
|
75
|
+
"filterable": false,
|
|
76
|
+
"id": "17b374b6-cd85-444e-8403-20c7458ed29e",
|
|
77
|
+
"label": "Canonical URL",
|
|
78
|
+
"localized": false,
|
|
79
|
+
"public": true,
|
|
80
|
+
"required": false,
|
|
81
|
+
"settings": {
|
|
82
|
+
"protocols": [
|
|
83
|
+
"https",
|
|
84
|
+
"http"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
"sortable": false,
|
|
88
|
+
"type": "url",
|
|
89
|
+
"unique": false
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"apiKey": "noindex",
|
|
93
|
+
"deprecated": false,
|
|
94
|
+
"editor": {
|
|
95
|
+
"id": "toggle",
|
|
96
|
+
"options": {}
|
|
97
|
+
},
|
|
98
|
+
"filterable": false,
|
|
99
|
+
"id": "e8346f99-dc3c-4b5c-906f-4854e03c9a57",
|
|
100
|
+
"label": "Hide from search engines",
|
|
101
|
+
"localized": false,
|
|
102
|
+
"public": true,
|
|
103
|
+
"required": false,
|
|
104
|
+
"settings": {},
|
|
105
|
+
"sortable": false,
|
|
106
|
+
"type": "boolean",
|
|
107
|
+
"unique": false
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"icon": "search",
|
|
111
|
+
"id": "f3905a00-d2a3-4c62-899a-c894bfd6cb9b",
|
|
112
|
+
"kind": "component",
|
|
113
|
+
"label": "SEO"
|
|
114
|
+
}
|
|
@@ -140,6 +140,27 @@
|
|
|
140
140
|
"sortable": true,
|
|
141
141
|
"type": "date",
|
|
142
142
|
"unique": false
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"apiKey": "seo",
|
|
146
|
+
"deprecated": false,
|
|
147
|
+
"editor": {
|
|
148
|
+
"id": "seoEditor",
|
|
149
|
+
"options": {}
|
|
150
|
+
},
|
|
151
|
+
"filterable": false,
|
|
152
|
+
"id": "a3f1c0de-7b52-4e0a-9d1e-5c2b8e4f6a01",
|
|
153
|
+
"label": "SEO",
|
|
154
|
+
"localized": true,
|
|
155
|
+
"public": true,
|
|
156
|
+
"required": false,
|
|
157
|
+
"settings": {
|
|
158
|
+
"component": "f3905a00-d2a3-4c62-899a-c894bfd6cb9b",
|
|
159
|
+
"repeatable": false
|
|
160
|
+
},
|
|
161
|
+
"sortable": false,
|
|
162
|
+
"type": "component",
|
|
163
|
+
"unique": false
|
|
143
164
|
}
|
|
144
165
|
],
|
|
145
166
|
"id": "1679d89c-747f-4dfc-955f-7d47bd5e4f6b",
|
|
@@ -83,6 +83,27 @@
|
|
|
83
83
|
"sortable": false,
|
|
84
84
|
"type": "dynamiczone",
|
|
85
85
|
"unique": false
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"apiKey": "seo",
|
|
89
|
+
"deprecated": false,
|
|
90
|
+
"editor": {
|
|
91
|
+
"id": "seoEditor",
|
|
92
|
+
"options": {}
|
|
93
|
+
},
|
|
94
|
+
"filterable": false,
|
|
95
|
+
"id": "b7e2d1af-3c64-4f1b-8e2a-6d3c9f5a7b02",
|
|
96
|
+
"label": "SEO",
|
|
97
|
+
"localized": true,
|
|
98
|
+
"public": true,
|
|
99
|
+
"required": false,
|
|
100
|
+
"settings": {
|
|
101
|
+
"component": "f3905a00-d2a3-4c62-899a-c894bfd6cb9b",
|
|
102
|
+
"repeatable": false
|
|
103
|
+
},
|
|
104
|
+
"sortable": false,
|
|
105
|
+
"type": "component",
|
|
106
|
+
"unique": false
|
|
86
107
|
}
|
|
87
108
|
],
|
|
88
109
|
"id": "4c09af05-f302-4d8f-ba03-bb838d3afbd2",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { DeliverySite, SeoResolved } from '@shapio/client';
|
|
3
|
+
import { page } from '$app/state';
|
|
4
|
+
import { seoHead, type SeoHead } from '../seo';
|
|
5
|
+
import { DEFAULT_LOCALE } from '../site';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The page's <head>: title, description, Open Graph, Twitter card, canonical and robots, from the entry's SEO
|
|
9
|
+
* fields (read with `seo=resolved`) over the site's SEO defaults. Pages without an entry pass their title only.
|
|
10
|
+
*/
|
|
11
|
+
type Props = {
|
|
12
|
+
title: string;
|
|
13
|
+
seo?: SeoResolved | null;
|
|
14
|
+
type?: SeoHead['type'];
|
|
15
|
+
site: DeliverySite | null;
|
|
16
|
+
locale: string;
|
|
17
|
+
siteUrl: string | null;
|
|
18
|
+
noindex?: boolean;
|
|
19
|
+
};
|
|
20
|
+
let { title, seo = null, type = 'website', site, locale, siteUrl, noindex = false }: Props = $props();
|
|
21
|
+
const head = $derived(
|
|
22
|
+
seoHead({
|
|
23
|
+
seo,
|
|
24
|
+
site,
|
|
25
|
+
locale,
|
|
26
|
+
localeChain: [locale, DEFAULT_LOCALE],
|
|
27
|
+
title,
|
|
28
|
+
path: page.url.pathname,
|
|
29
|
+
siteUrl: siteUrl ?? undefined,
|
|
30
|
+
type,
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<svelte:head>
|
|
36
|
+
<title>{head.title}</title>
|
|
37
|
+
{#if head.description}
|
|
38
|
+
<meta name="description" content={head.description} />
|
|
39
|
+
<meta property="og:description" content={head.description} />
|
|
40
|
+
<meta name="twitter:description" content={head.description} />
|
|
41
|
+
{/if}
|
|
42
|
+
{#if noindex || head.noindex}
|
|
43
|
+
<meta name="robots" content="noindex" />
|
|
44
|
+
{/if}
|
|
45
|
+
{#if head.canonical}
|
|
46
|
+
<link rel="canonical" href={head.canonical} />
|
|
47
|
+
<meta property="og:url" content={head.canonical} />
|
|
48
|
+
{/if}
|
|
49
|
+
<meta property="og:type" content={head.type} />
|
|
50
|
+
<meta property="og:title" content={head.title} />
|
|
51
|
+
<meta property="og:locale" content={head.locale} />
|
|
52
|
+
{#if head.siteName}
|
|
53
|
+
<meta property="og:site_name" content={head.siteName} />
|
|
54
|
+
{/if}
|
|
55
|
+
{#if head.image}
|
|
56
|
+
<meta property="og:image" content={head.image.url} />
|
|
57
|
+
{#if head.image.width}
|
|
58
|
+
<meta property="og:image:width" content={String(head.image.width)} />
|
|
59
|
+
{/if}
|
|
60
|
+
{#if head.image.height}
|
|
61
|
+
<meta property="og:image:height" content={String(head.image.height)} />
|
|
62
|
+
{/if}
|
|
63
|
+
{#if head.image.alt}
|
|
64
|
+
<meta property="og:image:alt" content={head.image.alt} />
|
|
65
|
+
{/if}
|
|
66
|
+
<meta name="twitter:image" content={head.image.url} />
|
|
67
|
+
{/if}
|
|
68
|
+
<meta name="twitter:card" content={head.image ? 'summary_large_image' : 'summary'} />
|
|
69
|
+
{#if head.twitterHandle}
|
|
70
|
+
<meta name="twitter:site" content={head.twitterHandle} />
|
|
71
|
+
{/if}
|
|
72
|
+
<meta name="twitter:title" content={head.title} />
|
|
73
|
+
</svelte:head>
|
|
@@ -45,6 +45,7 @@ export const loadDraft = async (shapioUrl: string, request: PreviewRequest): Pro
|
|
|
45
45
|
const client = createClient({ baseUrl: shapioUrl, token: request.token });
|
|
46
46
|
const params = new URLSearchParams({
|
|
47
47
|
locale: request.locale,
|
|
48
|
+
richText: 'html',
|
|
48
49
|
...(request.modelKey === 'articles' ? { populate: 'author' } : {}),
|
|
49
50
|
});
|
|
50
51
|
const { data } = await client.request<DeliveryItem<Page | Article>>(
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyTitleTemplate,
|
|
3
|
+
seoDefaultsForLocale,
|
|
4
|
+
type DeliveryAsset,
|
|
5
|
+
type DeliverySite,
|
|
6
|
+
type SeoResolved,
|
|
7
|
+
} from '@shapio/client';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a page puts in its <head> (title, description, Open Graph, Twitter card, canonical, robots), from an
|
|
11
|
+
* entry's SEO fields read with `?seo=resolved` and the site's SEO defaults (`GET /api/site`). Pages without an
|
|
12
|
+
* entry (listings, the colophon) pass their own title: it goes through the site's title template here.
|
|
13
|
+
*/
|
|
14
|
+
export type SeoHead = {
|
|
15
|
+
title: string;
|
|
16
|
+
description: string | null;
|
|
17
|
+
image: { url: string; width: number | null; height: number | null; alt: string | null } | null;
|
|
18
|
+
/** The entry's canonical URL, else this page's URL on SITE_URL; null when neither is known. */
|
|
19
|
+
canonical: string | null;
|
|
20
|
+
noindex: boolean;
|
|
21
|
+
siteName: string | null;
|
|
22
|
+
twitterHandle: string | null;
|
|
23
|
+
/** `article` for articles, `website` for everything else (`og:type`). */
|
|
24
|
+
type: 'website' | 'article';
|
|
25
|
+
/** `og:locale`, e.g. `en` or `fr`. */
|
|
26
|
+
locale: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SeoHeadInput = {
|
|
30
|
+
/** The entry's resolved SEO fields; absent on pages without an entry. */
|
|
31
|
+
seo?: SeoResolved | null;
|
|
32
|
+
/** The site and its defaults; null when it could not be read (the page still renders a title). */
|
|
33
|
+
site: DeliverySite | null;
|
|
34
|
+
locale: string;
|
|
35
|
+
/** The locales whose defaults apply, in order (the page's locale, then the default one). */
|
|
36
|
+
localeChain: readonly string[];
|
|
37
|
+
/** The page's own title (entry pages: used only when the entry has no SEO fields). */
|
|
38
|
+
title: string;
|
|
39
|
+
/** This page's path, e.g. `/en/articles/x/`; with `siteUrl` it makes the canonical URL. */
|
|
40
|
+
path: string;
|
|
41
|
+
siteUrl?: string | undefined;
|
|
42
|
+
type?: SeoHead['type'];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const imageOf = (asset: DeliveryAsset | null | undefined): SeoHead['image'] =>
|
|
46
|
+
asset ? { url: asset.url, width: asset.width, height: asset.height, alt: asset.alt } : null;
|
|
47
|
+
|
|
48
|
+
const canonicalOf = (own: string | null | undefined, path: string, siteUrl: string | undefined) => {
|
|
49
|
+
if (own) {
|
|
50
|
+
return own;
|
|
51
|
+
}
|
|
52
|
+
return siteUrl ? new URL(path, siteUrl).href : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const seoHead = (input: SeoHeadInput): SeoHead => {
|
|
56
|
+
const defaults = seoDefaultsForLocale(input.site?.seo ?? { locales: {} }, input.localeChain);
|
|
57
|
+
const { seo } = input;
|
|
58
|
+
return {
|
|
59
|
+
title: seo?.title ?? applyTitleTemplate(input.title, defaults.titleTemplate),
|
|
60
|
+
description: seo?.description ?? defaults.description ?? null,
|
|
61
|
+
image: imageOf(seo?.image ?? input.site?.seo.image),
|
|
62
|
+
canonical: canonicalOf(seo?.canonical, input.path, input.siteUrl),
|
|
63
|
+
noindex: seo?.noindex ?? false,
|
|
64
|
+
siteName: defaults.siteName ?? null,
|
|
65
|
+
twitterHandle: input.site?.seo.twitterHandle ?? null,
|
|
66
|
+
type: input.type ?? 'website',
|
|
67
|
+
locale: input.locale,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
SHAPIO_SITE,
|
|
5
5
|
SHAPIO_SNAPSHOT,
|
|
6
6
|
SHAPIO_URL,
|
|
7
|
+
SITE_URL,
|
|
7
8
|
} from '$app/env/private';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
* snapshot when prerendering starts, so every page shows the same moment.
|
|
16
17
|
* - SHAPIO_SITE: optional; the site key on a multi-site Shapio (default: the token's site, else the primary).
|
|
17
18
|
* - PUBLIC_SHAPIO_URL: the Shapio URL the browser calls for previews (defaults to SHAPIO_URL).
|
|
19
|
+
* - SITE_URL: optional; this site's public origin, e.g. https://www.example.com. Pages then carry a canonical
|
|
20
|
+
* URL (and `og:url`) unless the entry sets its own.
|
|
18
21
|
*/
|
|
19
22
|
export const shapioUrl = () => SHAPIO_URL ?? 'http://localhost:4300';
|
|
20
23
|
|
|
@@ -44,3 +47,5 @@ export const configuredSnapshot = (): number | undefined => {
|
|
|
44
47
|
}
|
|
45
48
|
return snapshot;
|
|
46
49
|
};
|
|
50
|
+
|
|
51
|
+
export const siteUrl = () => SITE_URL;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createClient, ShapioApiError, type DeliveryListQuery } from '@shapio/client';
|
|
1
|
+
import { createClient, ShapioApiError, type DeliveryListQuery, type DeliverySite } from '@shapio/client';
|
|
2
2
|
import type { Locale } from '../site';
|
|
3
3
|
import type { Article, Page, SiteSettings } from '../types';
|
|
4
4
|
import { configuredSnapshot, deliveryToken, shapioUrl, siteKey } from './config';
|
|
@@ -16,8 +16,8 @@ const ARTICLES = 'articles';
|
|
|
16
16
|
const SITE_SETTINGS = 'siteSettings';
|
|
17
17
|
|
|
18
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'];
|
|
19
|
+
const PAGE_FIELDS = ['title', 'slug', 'description', 'sections', 'seo'];
|
|
20
|
+
const ARTICLE_FIELDS = ['title', 'slug', 'excerpt', 'body', 'cover', 'author', 'publishedOn', 'seo'];
|
|
21
21
|
const SITE_SETTINGS_FIELDS = ['siteName', 'tagline', 'footer', 'colophon'];
|
|
22
22
|
|
|
23
23
|
const createShapio = () => createClient({ baseUrl: shapioUrl(), token: deliveryToken(), site: siteKey() });
|
|
@@ -48,6 +48,10 @@ const listAll = async <T>(routeKey: string, query: DeliveryListQuery): Promise<T
|
|
|
48
48
|
for (let page = 1; ; page += 1) {
|
|
49
49
|
const result = await shapio().delivery.list<T>(routeKey, {
|
|
50
50
|
...query,
|
|
51
|
+
// The site renders the server's sanitized HTML, so it asks for that instead of the JSON document, and
|
|
52
|
+
// its SEO fields with the site's defaults filled in.
|
|
53
|
+
richText: 'html',
|
|
54
|
+
seo: 'resolved',
|
|
51
55
|
snapshot: await snapshot(),
|
|
52
56
|
page,
|
|
53
57
|
pageSize: PAGE_SIZE,
|
|
@@ -82,6 +86,7 @@ const readSiteSettings = async (locale: Locale): Promise<SiteSettings | null> =>
|
|
|
82
86
|
locale,
|
|
83
87
|
snapshot: await snapshot(),
|
|
84
88
|
fields: SITE_SETTINGS_FIELDS,
|
|
89
|
+
richText: 'html',
|
|
85
90
|
});
|
|
86
91
|
return data;
|
|
87
92
|
} catch (error) {
|
|
@@ -91,3 +96,19 @@ const readSiteSettings = async (locale: Locale): Promise<SiteSettings | null> =>
|
|
|
91
96
|
throw error;
|
|
92
97
|
}
|
|
93
98
|
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The site and its SEO defaults (name and title template per locale, default image, Twitter handle), once per
|
|
102
|
+
* build. Null when the Shapio predates them (the pages still render, with plain titles).
|
|
103
|
+
*/
|
|
104
|
+
export const getSite = (): Promise<DeliverySite | null> =>
|
|
105
|
+
once('site', async () => {
|
|
106
|
+
try {
|
|
107
|
+
return await shapio().site.get();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof ShapioApiError && error.status === 404) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SeoResolved } from '@shapio/client';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Content as the Shapio delivery API returns it for the starter's models (shapio/). Delivery
|
|
3
5
|
* entries are flat: system attributes next to the fields, keyed by API key. Media fields are asset views,
|
|
@@ -24,6 +26,7 @@ export type Media = {
|
|
|
24
26
|
variants: MediaVariant[];
|
|
25
27
|
};
|
|
26
28
|
|
|
29
|
+
/** Rich text as delivery returns it with `richText=html`: sanitized HTML rendered from the document. */
|
|
27
30
|
export type RichText = { format: 'shapio-richtext'; version: number; html: string };
|
|
28
31
|
|
|
29
32
|
type EntryBase = {
|
|
@@ -61,6 +64,8 @@ export type Page = EntryBase & {
|
|
|
61
64
|
slug: string;
|
|
62
65
|
description: string | null;
|
|
63
66
|
sections: Section[];
|
|
67
|
+
/** The built-in SEO fields, read with `seo=resolved` (the site's defaults filled in). */
|
|
68
|
+
seo: SeoResolved;
|
|
64
69
|
};
|
|
65
70
|
|
|
66
71
|
export type Author = EntryBase & { name: string; bio: string | null; avatar: Media | null };
|
|
@@ -74,6 +79,8 @@ export type Article = EntryBase & {
|
|
|
74
79
|
/** Populated (`populate=author`) or just the ID; null when the author is not published. */
|
|
75
80
|
author: Author | string | null;
|
|
76
81
|
publishedOn: string;
|
|
82
|
+
/** The built-in SEO fields, read with `seo=resolved` (the site's defaults filled in). */
|
|
83
|
+
seo: SeoResolved;
|
|
77
84
|
};
|
|
78
85
|
|
|
79
86
|
/** The `siteSettings` singleton: one entry per site, read by its API ID. */
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { error } from '@sveltejs/kit';
|
|
2
|
-
import {
|
|
2
|
+
import { siteUrl } from '#lib/server/config.ts';
|
|
3
|
+
import { getSite, getSiteSettings } from '#lib/server/shapio.ts';
|
|
3
4
|
import { isLocale, stringsFor } from '#lib/site.ts';
|
|
4
5
|
import type { LayoutServerLoad } from './$types';
|
|
5
6
|
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* The locale's site settings (the siteSettings singleton) and UI strings, for the header and footer; the site's
|
|
9
|
+
* SEO defaults and public URL, for every page's <head>.
|
|
10
|
+
*/
|
|
7
11
|
export const load: LayoutServerLoad = async ({ params }) => {
|
|
8
12
|
if (!isLocale(params.locale)) {
|
|
9
13
|
error(404, 'Not found');
|
|
@@ -12,5 +16,7 @@ export const load: LayoutServerLoad = async ({ params }) => {
|
|
|
12
16
|
locale: params.locale,
|
|
13
17
|
strings: stringsFor(params.locale),
|
|
14
18
|
settings: await getSiteSettings(params.locale),
|
|
19
|
+
site: await getSite(),
|
|
20
|
+
siteUrl: siteUrl() ?? null,
|
|
15
21
|
};
|
|
16
22
|
};
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import Seo from '#lib/components/Seo.svelte';
|
|
2
3
|
import Sections from '#lib/components/Sections.svelte';
|
|
3
4
|
import type { PageProps } from './$types';
|
|
4
5
|
|
|
5
6
|
let { data }: PageProps = $props();
|
|
6
7
|
</script>
|
|
7
8
|
|
|
8
|
-
<
|
|
9
|
-
<title>{data.page.title} · {data.settings?.siteName ?? data.strings.siteName}</title>
|
|
10
|
-
{#if data.page.description}
|
|
11
|
-
<meta name="description" content={data.page.description} />
|
|
12
|
-
{/if}
|
|
13
|
-
</svelte:head>
|
|
9
|
+
<Seo title={data.page.title} seo={data.page.seo} site={data.site} locale={data.locale} siteUrl={data.siteUrl} />
|
|
14
10
|
|
|
15
11
|
<Sections page={data.page} />
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import Seo from '#lib/components/Seo.svelte';
|
|
2
3
|
import ArticleCard from '#lib/components/ArticleCard.svelte';
|
|
3
4
|
import type { PageProps } from './$types';
|
|
4
5
|
|
|
5
6
|
let { data }: PageProps = $props();
|
|
6
7
|
</script>
|
|
7
8
|
|
|
8
|
-
<
|
|
9
|
-
<title>{data.strings.articles} · {data.settings?.siteName ?? data.strings.siteName}</title>
|
|
10
|
-
</svelte:head>
|
|
9
|
+
<Seo title={data.strings.articles} site={data.site} locale={data.locale} siteUrl={data.siteUrl} />
|
|
11
10
|
|
|
12
11
|
<h1 class="page-title">{data.strings.articles}</h1>
|
|
13
12
|
<ul class="cards">
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import Seo from '#lib/components/Seo.svelte';
|
|
2
3
|
import ArticleView from '#lib/components/ArticleView.svelte';
|
|
3
4
|
import type { PageProps } from './$types';
|
|
4
5
|
|
|
5
6
|
let { data }: PageProps = $props();
|
|
6
7
|
</script>
|
|
7
8
|
|
|
8
|
-
<
|
|
9
|
-
<title>{data.article.title} · {data.settings?.siteName ?? data.strings.siteName}</title>
|
|
10
|
-
{#if data.article.excerpt}
|
|
11
|
-
<meta name="description" content={data.article.excerpt} />
|
|
12
|
-
{/if}
|
|
13
|
-
</svelte:head>
|
|
9
|
+
<Seo title={data.article.title} seo={data.article.seo} type="article" site={data.site} locale={data.locale} siteUrl={data.siteUrl} />
|
|
14
10
|
|
|
15
11
|
<ArticleView article={data.article} locale={data.locale} strings={data.strings} />
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import Seo from '#lib/components/Seo.svelte';
|
|
2
3
|
import RichText from '#lib/components/RichText.svelte';
|
|
3
4
|
import type { PageProps } from './$types';
|
|
4
5
|
|
|
@@ -6,9 +7,7 @@
|
|
|
6
7
|
let { data }: PageProps = $props();
|
|
7
8
|
</script>
|
|
8
9
|
|
|
9
|
-
<
|
|
10
|
-
<title>{data.strings.colophon} · {data.settings?.siteName ?? data.strings.siteName}</title>
|
|
11
|
-
</svelte:head>
|
|
10
|
+
<Seo title={data.strings.colophon} site={data.site} locale={data.locale} siteUrl={data.siteUrl} />
|
|
12
11
|
|
|
13
12
|
<h1 class="page-title">{data.strings.colophon}</h1>
|
|
14
13
|
{#if data.settings?.tagline}
|