create-shapio 0.3.1 → 0.4.1
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/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
package/package.json
CHANGED
|
@@ -8,3 +8,5 @@ SHAPIO_DELIVERY_TOKEN=
|
|
|
8
8
|
# SHAPIO_SITE=marketing
|
|
9
9
|
# Optional: the origin the browser calls for draft previews (defaults to SHAPIO_URL).
|
|
10
10
|
# PUBLIC_SHAPIO_URL=https://cms.example.com
|
|
11
|
+
# Optional: this site's public origin; pages then carry a canonical URL and og:url.
|
|
12
|
+
# SITE_URL=https://www.example.com
|
|
@@ -448,15 +448,56 @@ export type EntrySpec = {
|
|
|
448
448
|
publish: boolean;
|
|
449
449
|
};
|
|
450
450
|
|
|
451
|
+
/**
|
|
452
|
+
* The SEO fields of one locale (the built-in `seo` component, per locale): a page describes itself with its
|
|
453
|
+
* description, an article with its excerpt and its cover (a shared field, so it comes from the English data).
|
|
454
|
+
* The title stays empty: `?seo=resolved` falls back to the entry's own title, through the site's template.
|
|
455
|
+
*/
|
|
456
|
+
const seoOf = (data: Record<string, unknown>, shared: Record<string, unknown>) => ({
|
|
457
|
+
description: data.excerpt ?? data.description ?? null,
|
|
458
|
+
image: shared.cover ?? null,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const withSeo = (content: Localized<Record<string, unknown>>): Localized<Record<string, unknown>> => ({
|
|
462
|
+
en: { ...content.en, seo: seoOf(content.en, content.en) },
|
|
463
|
+
fr: { ...content.fr, seo: seoOf(content.fr, content.en) },
|
|
464
|
+
});
|
|
465
|
+
|
|
451
466
|
export const entries = (media: MediaIds, authorId: string): EntrySpec[] => [
|
|
452
|
-
{ model: 'page', slug: 'home', content: home(media), publish: true },
|
|
453
|
-
{ model: 'page', slug: 'about', content: about(), publish: true },
|
|
467
|
+
{ model: 'page', slug: 'home', content: withSeo(home(media)), publish: true },
|
|
468
|
+
{ model: 'page', slug: 'about', content: withSeo(about()), publish: true },
|
|
454
469
|
{
|
|
455
470
|
model: 'article',
|
|
456
471
|
slug: 'modelling-without-a-deploy',
|
|
457
|
-
content: modelling(media, authorId),
|
|
472
|
+
content: withSeo(modelling(media, authorId)),
|
|
473
|
+
publish: true,
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
model: 'article',
|
|
477
|
+
slug: 'why-builds-pin-a-snapshot',
|
|
478
|
+
content: withSeo(snapshots(media, authorId)),
|
|
458
479
|
publish: true,
|
|
459
480
|
},
|
|
460
|
-
{ model: 'article', slug: '
|
|
461
|
-
{ model: 'article', slug: 'winter-projects', content: draft(media, authorId), publish: false },
|
|
481
|
+
{ model: 'article', slug: 'winter-projects', content: withSeo(draft(media, authorId)), publish: false },
|
|
462
482
|
];
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The site's SEO defaults (Settings → SEO; `PUT /api/admin/site/seo`): the name and template match the
|
|
486
|
+
* siteSettings singleton's site name, the description its tagline; the hero image is the default social image.
|
|
487
|
+
*/
|
|
488
|
+
export const seoDefaults = (media: MediaIds) => ({
|
|
489
|
+
locales: {
|
|
490
|
+
en: {
|
|
491
|
+
siteName: String(SITE_SETTINGS.en.siteName),
|
|
492
|
+
titleTemplate: `%s · ${String(SITE_SETTINGS.en.siteName)}`,
|
|
493
|
+
description: String(SITE_SETTINGS.en.tagline),
|
|
494
|
+
},
|
|
495
|
+
fr: {
|
|
496
|
+
siteName: String(SITE_SETTINGS.fr.siteName),
|
|
497
|
+
titleTemplate: `%s · ${String(SITE_SETTINGS.fr.siteName)}`,
|
|
498
|
+
description: String(SITE_SETTINGS.fr.tagline),
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
imageId: media.hero,
|
|
502
|
+
twitterHandle: '@northwindstudio',
|
|
503
|
+
});
|
|
@@ -4,6 +4,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import {
|
|
6
6
|
buildUploadForm,
|
|
7
|
+
SEO_COMPONENT_ID,
|
|
7
8
|
ShapioApiError,
|
|
8
9
|
type MediaAsset,
|
|
9
10
|
type SchemaApplyInput,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
AUTHOR,
|
|
16
17
|
entries,
|
|
17
18
|
IMAGES,
|
|
19
|
+
seoDefaults,
|
|
18
20
|
SITE_SETTINGS,
|
|
19
21
|
type EntrySpec,
|
|
20
22
|
type ImageSpec,
|
|
@@ -29,8 +31,9 @@ import { createPng } from './lib/png.js';
|
|
|
29
31
|
* 2. the models and components in shapio/ through the schema apply API (live, no restart; the same planner
|
|
30
32
|
* as `shapio schema apply` and the admin), as the site's own (SHAPIO_SITE, else the token's site, else the
|
|
31
33
|
* primary): a starter's schema belongs to the first site it is seeded on;
|
|
34
|
+
* The built-in SEO component (components/seo.json) is the exception: it is always shared with every site;
|
|
32
35
|
* 3. placeholder images, an author, the site settings, pages and articles in English and French, published
|
|
33
|
-
* per locale;
|
|
36
|
+
* per locale, and the site's SEO defaults (Settings → SEO);
|
|
34
37
|
* 4. a deployment connection named "Preview" whose preview URL opens this starter's /preview/ page (the
|
|
35
38
|
* entry document's Preview pane and visual editing; it triggers no builds). The site's URL is SITE_URL, else
|
|
36
39
|
* the starter's `--site-url` (its local dev server);
|
|
@@ -50,6 +53,8 @@ const PREVIEW_PATH = '/preview/?model={modelKey}&id={entryId}&locale={locale}#to
|
|
|
50
53
|
/** A placeholder build hook: the connection has no triggers, so nothing is ever sent to it. */
|
|
51
54
|
const PREVIEW_BUILD_HOOK = 'https://build.invalid/shapio-starter';
|
|
52
55
|
const REVALIDATE_WEBHOOK_NAME = 'Site revalidation';
|
|
56
|
+
/** The request's site's SEO defaults (`site.settings`). */
|
|
57
|
+
const SEO_DEFAULTS_PATH = '/api/admin/site/seo';
|
|
53
58
|
/** The events that change what a published site shows (the starter's route ignores any others). */
|
|
54
59
|
const REVALIDATE_EVENTS = [
|
|
55
60
|
'entry.published',
|
|
@@ -58,6 +63,8 @@ const REVALIDATE_EVENTS = [
|
|
|
58
63
|
'change_set.shipped',
|
|
59
64
|
'schema.activated',
|
|
60
65
|
'schema.deleted',
|
|
66
|
+
// The site's SEO defaults changed: every page's head shows them.
|
|
67
|
+
'site.updated',
|
|
61
68
|
];
|
|
62
69
|
/** A local site needs Shapio's OUTBOUND_PRIVATE_NETWORK_ALLOWLIST too; the webhook opts in for loopback only. */
|
|
63
70
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
@@ -129,8 +136,11 @@ const applySchema = async (client: ShapioClient) => {
|
|
|
129
136
|
try {
|
|
130
137
|
response = await client.admin.schema.apply({
|
|
131
138
|
definitions,
|
|
132
|
-
// Every definition is the site's own (sites/<key>/ in a pulled tree), never shared by default
|
|
133
|
-
|
|
139
|
+
// Every definition is the site's own (sites/<key>/ in a pulled tree), never shared by default, except
|
|
140
|
+
// the built-in SEO component: Shapio knows it by its fixed ID, so one shared copy serves every site.
|
|
141
|
+
scopes: definitions.map((definition) =>
|
|
142
|
+
(definition as { id?: unknown }).id === SEO_COMPONENT_ID ? ('network' as const) : ('site' as const),
|
|
143
|
+
),
|
|
134
144
|
base: FIRST_APPLY_BASE,
|
|
135
145
|
prune: false,
|
|
136
146
|
dryRun: false,
|
|
@@ -143,7 +153,8 @@ const applySchema = async (client: ShapioClient) => {
|
|
|
143
153
|
"This starter's schema already belongs to another site of this instance: a starter's schema belongs " +
|
|
144
154
|
'to the first site it is seeded on. To reuse it on this site, share it with all sites ' +
|
|
145
155
|
'with a network admin token (`shapio schema scope <apiKey> --shared --site <that site>` for each ' +
|
|
146
|
-
'component, then author, page, article and siteSettings) and seed again, or seed a separate instance.'
|
|
156
|
+
'component, then author, page, article and siteSettings) and seed again, or seed a separate instance. ' +
|
|
157
|
+
'The SEO component is always shared; a site that keeps its own copy of it blocks the others.',
|
|
147
158
|
{ cause: error },
|
|
148
159
|
);
|
|
149
160
|
}
|
|
@@ -259,6 +270,16 @@ const ensureSiteSettings = async (client: ShapioClient) => {
|
|
|
259
270
|
log('Published siteSettings (en, fr)');
|
|
260
271
|
};
|
|
261
272
|
|
|
273
|
+
/** The site's SEO defaults: the texts per locale, the default social image and the Twitter handle. */
|
|
274
|
+
const ensureSeoDefaults = async (client: ShapioClient, media: MediaIds) => {
|
|
275
|
+
const current = await client.request<{ version: number }>(SEO_DEFAULTS_PATH);
|
|
276
|
+
await client.request(SEO_DEFAULTS_PATH, {
|
|
277
|
+
method: 'PUT',
|
|
278
|
+
body: { expectedVersion: current.version, seo: seoDefaults(media) },
|
|
279
|
+
});
|
|
280
|
+
log('Set the SEO defaults (en, fr)');
|
|
281
|
+
};
|
|
282
|
+
|
|
262
283
|
const upsertEntry = async (client: ShapioClient, spec: EntrySpec) => {
|
|
263
284
|
const found = await client.admin.content.list(spec.model, {
|
|
264
285
|
filters: { slug: { $eq: spec.slug } },
|
|
@@ -413,6 +434,7 @@ const main = async () => {
|
|
|
413
434
|
await applySchema(admin.client);
|
|
414
435
|
const media = await ensureMedia(admin.client);
|
|
415
436
|
await ensureSiteSettings(admin.client);
|
|
437
|
+
await ensureSeoDefaults(admin.client, media);
|
|
416
438
|
const authorId = await ensureAuthor(admin.client, media.avatar);
|
|
417
439
|
for (const spec of entries(media, authorId)) {
|
|
418
440
|
await upsertEntry(admin.client, spec);
|
|
@@ -62,6 +62,19 @@ const checkBuild = async () => {
|
|
|
62
62
|
check(articleEn.includes('<table>') && articleEn.includes('With Shapio'), 'EN article: rich-text table');
|
|
63
63
|
check(articleEn.includes('Ada Moreau'), 'EN article: populated author');
|
|
64
64
|
check(articleFr.includes('Modéliser du contenu sans déploiement'), 'FR article: title');
|
|
65
|
+
check(
|
|
66
|
+
articleEn.includes('<title>Modelling content without a deploy · Northwind Studio</title>'),
|
|
67
|
+
'EN article: SEO title through the site template',
|
|
68
|
+
);
|
|
69
|
+
check(
|
|
70
|
+
articleEn.includes('property="og:image"') &&
|
|
71
|
+
articleEn.includes('name="twitter:site" content="@northwindstudio"'),
|
|
72
|
+
'EN article: Open Graph image and Twitter handle from the SEO fields and defaults',
|
|
73
|
+
);
|
|
74
|
+
check(
|
|
75
|
+
articleFr.includes('<title>Modéliser du contenu sans déploiement · Studio Northwind</title>'),
|
|
76
|
+
'FR article: SEO title through the French template',
|
|
77
|
+
);
|
|
65
78
|
check((await html('fr/about/index.html')).includes('À propos du studio'), 'FR about page');
|
|
66
79
|
check(
|
|
67
80
|
(await html('en/colophon/index.html')).includes('pinned to one publication snapshot per build'),
|
|
@@ -5,7 +5,8 @@ import { parseArgs } from 'node:util';
|
|
|
5
5
|
* `npm run smoke`, after `npm run seed`, `npm run build` and starting the built site (`npm run start` or
|
|
6
6
|
* `npm run preview`): fetches the site's pages over HTTP and checks the seeded content in English and French,
|
|
7
7
|
* the site settings singleton, rich text, media variants and the author, that the draft article is not
|
|
8
|
-
* served, that content carries the visual-editing attributes (@shapio/visual's `shapioAttr`),
|
|
8
|
+
* served, that content carries the visual-editing attributes (@shapio/visual's `shapioAttr`), that pages carry
|
|
9
|
+
* their SEO tags (the entry's SEO fields with the site's defaults: title template, Open Graph, Twitter), and that the
|
|
9
10
|
* preview page is served with a `frame-ancestors` policy. The same checks run against every starter, so all
|
|
10
11
|
* three render the same blog.
|
|
11
12
|
*
|
|
@@ -28,6 +29,9 @@ const PAGES: readonly PageCheck[] = [
|
|
|
28
29
|
['.webp', 'responsive image variants'],
|
|
29
30
|
['Northwind Studio', 'site name from the siteSettings singleton'],
|
|
30
31
|
['Built with Shapio', 'footer from the siteSettings singleton'],
|
|
32
|
+
['<title>Home · Northwind Studio</title>', 'title through the SEO title template'],
|
|
33
|
+
['property="og:image"', 'og:image (the default social image)'],
|
|
34
|
+
['name="twitter:site" content="@northwindstudio"', 'twitter:site from the SEO defaults'],
|
|
31
35
|
],
|
|
32
36
|
},
|
|
33
37
|
{
|
|
@@ -36,6 +40,7 @@ const PAGES: readonly PageCheck[] = [
|
|
|
36
40
|
['Un contenu qui évolue aussi vite que votre produit', 'hero heading'],
|
|
37
41
|
['lang="fr"', 'lang="fr"'],
|
|
38
42
|
['Studio Northwind', 'site name from the siteSettings singleton'],
|
|
43
|
+
['<title>Accueil · Studio Northwind</title>', 'title through the French SEO title template'],
|
|
39
44
|
],
|
|
40
45
|
},
|
|
41
46
|
{
|
|
@@ -43,6 +48,7 @@ const PAGES: readonly PageCheck[] = [
|
|
|
43
48
|
expect: [
|
|
44
49
|
['Modelling content without a deploy', 'article list'],
|
|
45
50
|
['Why our builds pin a snapshot', 'second article'],
|
|
51
|
+
['<title>Journal · Northwind Studio</title>', 'a page without an entry: title through the template'],
|
|
46
52
|
],
|
|
47
53
|
},
|
|
48
54
|
{
|
|
@@ -55,6 +61,10 @@ const PAGES: readonly PageCheck[] = [
|
|
|
55
61
|
['data-shapio-entry="', 'visual-editing entry attribute'],
|
|
56
62
|
['data-shapio-path="title"', 'visual-editing attribute on the title'],
|
|
57
63
|
['data-shapio-path="body"', 'visual-editing attribute on the body'],
|
|
64
|
+
['<title>Modelling content without a deploy · Northwind Studio</title>', 'SEO title (resolved)'],
|
|
65
|
+
['property="og:type" content="article"', 'og:type article'],
|
|
66
|
+
['Nothing restarted.', 'SEO description from the entry'],
|
|
67
|
+
['name="twitter:card" content="summary_large_image"', 'twitter:card with the cover image'],
|
|
58
68
|
],
|
|
59
69
|
},
|
|
60
70
|
{ path: `/fr/articles/${ARTICLE}/`, expect: [['Modéliser du contenu sans déploiement', 'title']] },
|
|
@@ -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",
|
|
@@ -1,23 +1,49 @@
|
|
|
1
1
|
---
|
|
2
2
|
import '../styles/site.css';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import type { SeoResolved } from '@shapio/client';
|
|
4
|
+
import { seoHead, type SeoHead } from '../lib/seo.js';
|
|
5
|
+
import { getSite, getSiteSettings } from '../lib/shapio.js';
|
|
6
|
+
import {
|
|
7
|
+
articlesPath,
|
|
8
|
+
colophonPath,
|
|
9
|
+
DEFAULT_LOCALE,
|
|
10
|
+
HOME_SLUG,
|
|
11
|
+
otherLocale,
|
|
12
|
+
pagePath,
|
|
13
|
+
stringsFor,
|
|
14
|
+
type Locale,
|
|
15
|
+
} from '../lib/site.js';
|
|
5
16
|
|
|
6
17
|
type Props = {
|
|
18
|
+
/** The page's own title; entry pages pass their SEO fields too, which win. */
|
|
7
19
|
title: string;
|
|
8
|
-
|
|
20
|
+
/** The entry's SEO fields, read with `seo=resolved`. */
|
|
21
|
+
seo?: SeoResolved | null;
|
|
22
|
+
type?: SeoHead['type'];
|
|
9
23
|
locale: Locale;
|
|
10
24
|
/** This page in the other locale. */
|
|
11
25
|
alternatePath?: string;
|
|
12
26
|
noindex?: boolean;
|
|
13
27
|
};
|
|
14
28
|
|
|
15
|
-
const { title,
|
|
29
|
+
const { title, seo, type, locale, alternatePath, noindex = false } = Astro.props;
|
|
16
30
|
const strings = stringsFor(locale);
|
|
17
31
|
const other = otherLocale(locale);
|
|
18
32
|
// The name, tagline and footer come from the siteSettings singleton (the built-in strings until it is published).
|
|
19
33
|
const settings = await getSiteSettings(locale);
|
|
20
34
|
const siteName = settings?.siteName ?? strings.siteName;
|
|
35
|
+
// Title, description, social tags and canonical: the entry's SEO fields over the site's SEO defaults.
|
|
36
|
+
const head = seoHead({
|
|
37
|
+
seo: seo ?? null,
|
|
38
|
+
site: await getSite(),
|
|
39
|
+
locale,
|
|
40
|
+
localeChain: [locale, DEFAULT_LOCALE],
|
|
41
|
+
title,
|
|
42
|
+
path: Astro.url.pathname,
|
|
43
|
+
siteUrl: Astro.site?.href,
|
|
44
|
+
...(type ? { type } : {}),
|
|
45
|
+
});
|
|
46
|
+
const robotsNoindex = noindex || head.noindex;
|
|
21
47
|
---
|
|
22
48
|
|
|
23
49
|
<!doctype html>
|
|
@@ -25,9 +51,25 @@ const siteName = settings?.siteName ?? strings.siteName;
|
|
|
25
51
|
<head>
|
|
26
52
|
<meta charset="utf-8" />
|
|
27
53
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28
|
-
<title>{title}
|
|
29
|
-
{description ? <meta name="description" content={description} /> : null}
|
|
30
|
-
{
|
|
54
|
+
<title>{head.title}</title>
|
|
55
|
+
{head.description ? <meta name="description" content={head.description} /> : null}
|
|
56
|
+
{robotsNoindex ? <meta name="robots" content="noindex" /> : null}
|
|
57
|
+
{head.canonical ? <link rel="canonical" href={head.canonical} /> : null}
|
|
58
|
+
<meta property="og:type" content={head.type} />
|
|
59
|
+
<meta property="og:title" content={head.title} />
|
|
60
|
+
<meta property="og:locale" content={head.locale} />
|
|
61
|
+
{head.siteName ? <meta property="og:site_name" content={head.siteName} /> : null}
|
|
62
|
+
{head.description ? <meta property="og:description" content={head.description} /> : null}
|
|
63
|
+
{head.canonical ? <meta property="og:url" content={head.canonical} /> : null}
|
|
64
|
+
{head.image ? <meta property="og:image" content={head.image.url} /> : null}
|
|
65
|
+
{head.image?.width ? <meta property="og:image:width" content={String(head.image.width)} /> : null}
|
|
66
|
+
{head.image?.height ? <meta property="og:image:height" content={String(head.image.height)} /> : null}
|
|
67
|
+
{head.image?.alt ? <meta property="og:image:alt" content={head.image.alt} /> : null}
|
|
68
|
+
<meta name="twitter:card" content={head.image ? 'summary_large_image' : 'summary'} />
|
|
69
|
+
{head.twitterHandle ? <meta name="twitter:site" content={head.twitterHandle} /> : null}
|
|
70
|
+
<meta name="twitter:title" content={head.title} />
|
|
71
|
+
{head.description ? <meta name="twitter:description" content={head.description} /> : null}
|
|
72
|
+
{head.image ? <meta name="twitter:image" content={head.image.url} /> : null}
|
|
31
73
|
{alternatePath ? <link rel="alternate" hreflang={other} href={alternatePath} /> : null}
|
|
32
74
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
33
75
|
</head>
|
|
@@ -44,6 +44,7 @@ export const loadPreview = async (
|
|
|
44
44
|
const client = createClient({ baseUrl: shapioUrl, token: request.token });
|
|
45
45
|
const params = new URLSearchParams({
|
|
46
46
|
locale: request.locale,
|
|
47
|
+
richText: 'html',
|
|
47
48
|
...(request.modelKey === 'articles' ? { populate: 'author' } : {}),
|
|
48
49
|
});
|
|
49
50
|
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
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createClient, ShapioApiError } from '@shapio/client';
|
|
1
|
+
import { createClient, ShapioApiError, type DeliverySite } from '@shapio/client';
|
|
2
2
|
import { configuredSnapshot, deliveryToken, shapioUrl, siteKey } from './config.js';
|
|
3
3
|
import type { Locale } from './site.js';
|
|
4
4
|
import type { Article, DeliveryList, Page, SiteSettings } from './types.js';
|
|
@@ -19,8 +19,8 @@ const ARTICLES = 'articles';
|
|
|
19
19
|
* The fields each listing renders, sent as `fields=`: the response carries only these, and Shapio's field
|
|
20
20
|
* usage shows exactly what the site reads (a whole-model read would count every field as used).
|
|
21
21
|
*/
|
|
22
|
-
const PAGE_FIELDS = 'title,slug,description,sections';
|
|
23
|
-
const ARTICLE_FIELDS = 'title,slug,excerpt,body,cover,author,publishedOn';
|
|
22
|
+
const PAGE_FIELDS = 'title,slug,description,sections,seo';
|
|
23
|
+
const ARTICLE_FIELDS = 'title,slug,excerpt,body,cover,author,publishedOn,seo';
|
|
24
24
|
|
|
25
25
|
/** The `siteSettings` singleton is read by its API ID (singletons have no plural). */
|
|
26
26
|
const SITE_SETTINGS = 'siteSettings';
|
|
@@ -58,7 +58,9 @@ const listAll = async <T>(
|
|
|
58
58
|
const entries: T[] = [];
|
|
59
59
|
for (let page = 1; ; page += 1) {
|
|
60
60
|
const result = await shapio().request<DeliveryList<T>>(
|
|
61
|
-
|
|
61
|
+
// The site renders the server's sanitized HTML, so it asks for that instead of the JSON document, and
|
|
62
|
+
// its SEO fields with the site's defaults filled in (`seo=resolved`).
|
|
63
|
+
`/api/content/${routeKey}?${query({ locale, snapshot, page, pageSize: PAGE_SIZE, richText: 'html', seo: 'resolved', ...extra })}`,
|
|
62
64
|
);
|
|
63
65
|
entries.push(...result.data);
|
|
64
66
|
if (page >= result.meta.pagination.pageCount) {
|
|
@@ -89,6 +91,7 @@ export const getSiteSettings = (locale: Locale): Promise<SiteSettings | null> =>
|
|
|
89
91
|
locale,
|
|
90
92
|
snapshot,
|
|
91
93
|
fields: SITE_SETTINGS_FIELDS,
|
|
94
|
+
richText: 'html',
|
|
92
95
|
});
|
|
93
96
|
return data;
|
|
94
97
|
} catch (error) {
|
|
@@ -102,3 +105,19 @@ export const getSiteSettings = (locale: Locale): Promise<SiteSettings | null> =>
|
|
|
102
105
|
}
|
|
103
106
|
return settings;
|
|
104
107
|
};
|
|
108
|
+
|
|
109
|
+
let site: Promise<DeliverySite | null> | undefined;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The site and its SEO defaults (name and title template per locale, default image, Twitter handle), once per
|
|
113
|
+
* build. Null when the Shapio predates them: the pages still render, with plain titles.
|
|
114
|
+
*/
|
|
115
|
+
export const getSite = (): Promise<DeliverySite | null> =>
|
|
116
|
+
(site ??= shapio()
|
|
117
|
+
.site.get()
|
|
118
|
+
.catch((error: unknown) => {
|
|
119
|
+
if (error instanceof ShapioApiError && error.status === 404) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}));
|