@sjolystinnovation/app-kit 0.12.0 → 0.13.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/README.md +82 -0
- package/package.json +3 -1
- package/src/seo/JsonLd.tsx +8 -0
- package/src/seo/environment.ts +10 -0
- package/src/seo/index.ts +18 -0
- package/src/seo/og/index.tsx +43 -0
- package/src/seo/schema.ts +37 -0
- package/src/seo/site.ts +281 -0
- package/src/seo/sitemap.ts +13 -0
package/README.md
CHANGED
|
@@ -378,6 +378,88 @@ The text takes its colour and size from the parent. A level one heading becomes
|
|
|
378
378
|
the page has its own `h1`. `markdownComponents` holds the default elements, for an app that builds
|
|
379
379
|
on them.
|
|
380
380
|
|
|
381
|
+
### `@sjolystinnovation/app-kit/seo`
|
|
382
|
+
|
|
383
|
+
Search engine helpers for one site: canonical URLs with hreflang, page metadata with Open Graph,
|
|
384
|
+
sitemap entries, robots.txt and schema.org structured data. It works in a server component.
|
|
385
|
+
`defineSite` binds them to the site's name, canonical origin and locales:
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
// src/lib/seo/site.ts
|
|
389
|
+
import { defineSite } from "@sjolystinnovation/app-kit/seo";
|
|
390
|
+
|
|
391
|
+
export const site = defineSite({
|
|
392
|
+
name: "Altinnendata",
|
|
393
|
+
url: "https://www.altinnendata.no",
|
|
394
|
+
titleSeparator: " | ",
|
|
395
|
+
locales: ["no", "en"],
|
|
396
|
+
defaultLocale: "no",
|
|
397
|
+
localeTags: { no: "no", en: "en" },
|
|
398
|
+
tagline: locale => getDictionary(locale).meta.tagline,
|
|
399
|
+
});
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
| Option | Type | Description |
|
|
403
|
+
| --- | --- | --- |
|
|
404
|
+
| `name` | `string` | The site's name, in titles, Open Graph and structured data. |
|
|
405
|
+
| `url` | `string` | The canonical origin, without a trailing slash. |
|
|
406
|
+
| `titleSeparator` | `string` | Between a page title and the site name. |
|
|
407
|
+
| `locales` | `string[]` | The locale segments every page is under. A site without them leaves it out, and its paths have no segment. |
|
|
408
|
+
| `defaultLocale` | `string` | The locale `x-default` points at. Defaults to the first. |
|
|
409
|
+
| `localeTags` | `Record<locale, string>` | The BCP 47 tag per locale. Defaults to the locale. |
|
|
410
|
+
| `tagline` | `(locale) => string` | The line after the name in the default title and the share image's alt text. |
|
|
411
|
+
| `shareImagePath` | `string` | The share image route below each locale. Defaults to `/og`. |
|
|
412
|
+
|
|
413
|
+
The site gives:
|
|
414
|
+
|
|
415
|
+
- `localePath(locale, path)`, `absoluteUrl(path)`, `absoluteLocaleUrl(locale, path)` and
|
|
416
|
+
`languageAlternates(path)`. Paths get a leading slash and no trailing slash.
|
|
417
|
+
- `siteMetadata({ locale, description, manifest })` for the locale root layout: the title
|
|
418
|
+
template, `metadataBase`, the index directive and the front page's canonical.
|
|
419
|
+
- `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
|
|
420
|
+
hreflang, and Open Graph with the locale's share image unless `images` is given.
|
|
421
|
+
- `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
|
|
422
|
+
listing the others. `lastModified` is left out when unknown.
|
|
423
|
+
- `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`. The private paths are
|
|
424
|
+
disallowed under every locale segment. A deployment that is not production disallows everything.
|
|
425
|
+
- `organizationNode(company, { type, country, image, logo })`, `webSiteNode(locale)`,
|
|
426
|
+
`breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)` and `ids`
|
|
427
|
+
for structured data.
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
// src/app/robots.ts
|
|
431
|
+
export const dynamic = "force-dynamic";
|
|
432
|
+
export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRootPaths: ["/api/"] });
|
|
433
|
+
|
|
434
|
+
// in a page
|
|
435
|
+
<JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
`isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production` and unset or blank count as
|
|
439
|
+
production, so a missing variable never deindexes the live site. `robots.ts` needs
|
|
440
|
+
`dynamic = "force-dynamic"`, so it reads the variable per request. The package also exports
|
|
441
|
+
`JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `SHARE_IMAGE_SIZE`, `normalizePath`, `latest` and
|
|
442
|
+
`parseTimestamp`.
|
|
443
|
+
|
|
444
|
+
### `@sjolystinnovation/app-kit/seo/og`
|
|
445
|
+
|
|
446
|
+
`shareImage({ name, tagline, colors })` renders the default share preview, a PNG at
|
|
447
|
+
`SHARE_IMAGE_SIZE`: the name, a bar in `colors.primary` and the tagline. The colours are literals,
|
|
448
|
+
since the image renders outside the browser. Return it from the route `pageMetadata` points at:
|
|
449
|
+
|
|
450
|
+
```tsx
|
|
451
|
+
// src/app/[locale]/og/route.tsx
|
|
452
|
+
import { shareImage } from "@sjolystinnovation/app-kit/seo/og";
|
|
453
|
+
|
|
454
|
+
export async function GET(_request: Request, { params }: { params: Promise<{ locale: string }> }) {
|
|
455
|
+
const { locale } = await params;
|
|
456
|
+
return shareImage({ name: site.name, tagline: getDictionary(locale).meta.tagline, colors: BRAND });
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
It is a route rather than the `opengraph-image` file convention, since a page's own `openGraph`
|
|
461
|
+
block replaces that image.
|
|
462
|
+
|
|
381
463
|
### `@sjolystinnovation/app-kit/session`
|
|
382
464
|
|
|
383
465
|
Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjolystinnovation/app-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Sjølyst Innovation AS",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"./validation": "./src/validation/index.ts",
|
|
28
28
|
"./next-config": "./src/next-config/index.ts",
|
|
29
29
|
"./markdown": "./src/markdown/index.ts",
|
|
30
|
+
"./seo": "./src/seo/index.ts",
|
|
31
|
+
"./seo/og": "./src/seo/og/index.tsx",
|
|
30
32
|
"./styles.css": "./src/styles.css"
|
|
31
33
|
},
|
|
32
34
|
"files": [
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { serializeGraph, type SchemaNode } from "./schema";
|
|
2
|
+
|
|
3
|
+
/** One JSON-LD block. Search engines merge every block on a page into one graph. */
|
|
4
|
+
export function JsonLd({ nodes }: { nodes: SchemaNode[] }) {
|
|
5
|
+
if (nodes.length === 0) return null;
|
|
6
|
+
|
|
7
|
+
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeGraph(nodes) }} />;
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Values of `SITE_ENV` that name the live site. `prod` is what the deploy workflows pass. */
|
|
2
|
+
const PRODUCTION = new Set(["production", "prod", ""]);
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether this deployment is the one search engines should index. An unset `SITE_ENV` counts as
|
|
6
|
+
* production, so a missing variable is never what deindexes the live site.
|
|
7
|
+
*/
|
|
8
|
+
export function isIndexableEnvironment(siteEnv: string | undefined = process.env.SITE_ENV): boolean {
|
|
9
|
+
return PRODUCTION.has(siteEnv?.trim().toLowerCase() ?? "");
|
|
10
|
+
}
|
package/src/seo/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { isIndexableEnvironment } from "./environment";
|
|
2
|
+
export { JsonLd } from "./JsonLd";
|
|
3
|
+
export { ref, serializeGraph, type OrganizationInfo, type SchemaNode, type SchemaRef } from "./schema";
|
|
4
|
+
export {
|
|
5
|
+
defineSite,
|
|
6
|
+
normalizePath,
|
|
7
|
+
NOINDEX,
|
|
8
|
+
SHARE_IMAGE_SIZE,
|
|
9
|
+
type Crumb,
|
|
10
|
+
type LocaleEntryOptions,
|
|
11
|
+
type OrganizationOptions,
|
|
12
|
+
type PageMetadataInput,
|
|
13
|
+
type RobotsInput,
|
|
14
|
+
type Site,
|
|
15
|
+
type SiteConfig,
|
|
16
|
+
type SiteMetadataInput,
|
|
17
|
+
} from "./site";
|
|
18
|
+
export { latest, parseTimestamp } from "./sitemap";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ImageResponse } from "next/og";
|
|
2
|
+
import { SHARE_IMAGE_SIZE } from "../site";
|
|
3
|
+
|
|
4
|
+
export { SHARE_IMAGE_SIZE };
|
|
5
|
+
|
|
6
|
+
export interface ShareImageInput {
|
|
7
|
+
name: string;
|
|
8
|
+
tagline: string;
|
|
9
|
+
/**
|
|
10
|
+
* Colours as literals. The image renders outside the browser, so it cannot read CSS custom
|
|
11
|
+
* properties. `primary` is the bar above the name.
|
|
12
|
+
*/
|
|
13
|
+
colors: { background: string; foreground: string; primary: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The default share preview: the name, a bar in the brand colour and the tagline. Return it from a
|
|
18
|
+
* route such as `[locale]/og/route.tsx`, which `pageMetadata` points at. A route, not the
|
|
19
|
+
* `opengraph-image` file convention, since a page's own `openGraph` block replaces that image.
|
|
20
|
+
*/
|
|
21
|
+
export function shareImage({ name, tagline, colors }: ShareImageInput): ImageResponse {
|
|
22
|
+
return new ImageResponse(
|
|
23
|
+
(
|
|
24
|
+
<div
|
|
25
|
+
style={{
|
|
26
|
+
width: "100%",
|
|
27
|
+
height: "100%",
|
|
28
|
+
display: "flex",
|
|
29
|
+
flexDirection: "column",
|
|
30
|
+
justifyContent: "center",
|
|
31
|
+
padding: "80px",
|
|
32
|
+
background: colors.background,
|
|
33
|
+
color: colors.foreground,
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<div style={{ display: "flex", width: 120, height: 12, background: colors.primary }} />
|
|
37
|
+
<div style={{ marginTop: 48, fontSize: 96, fontWeight: 800, letterSpacing: "-0.03em" }}>{name}</div>
|
|
38
|
+
<div style={{ marginTop: 24, fontSize: 34, lineHeight: 1.3, color: "rgba(255,255,255,0.75)" }}>{tagline}</div>
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
SHARE_IMAGE_SIZE,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** One schema.org node. Nodes share a `@graph` and refer to each other by `@id`. */
|
|
2
|
+
export interface SchemaNode {
|
|
3
|
+
"@type": string | string[];
|
|
4
|
+
"@id"?: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** A reference to another node in the same graph. */
|
|
9
|
+
export interface SchemaRef {
|
|
10
|
+
"@id": string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ref = (id: string): SchemaRef => ({ "@id": id });
|
|
14
|
+
|
|
15
|
+
/** The JSON-LD document for one page. `<` is escaped, so the payload cannot close the script tag. */
|
|
16
|
+
export function serializeGraph(nodes: SchemaNode[]): string {
|
|
17
|
+
return JSON.stringify({
|
|
18
|
+
"@context": "https://schema.org",
|
|
19
|
+
"@graph": nodes,
|
|
20
|
+
}).replace(/</g, "\\u003c");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The business as the API describes it. Blank fields are left out of the structured data. */
|
|
24
|
+
export interface OrganizationInfo {
|
|
25
|
+
name: string;
|
|
26
|
+
legalName?: string | undefined;
|
|
27
|
+
orgNumber?: string | undefined;
|
|
28
|
+
vatRegistered?: boolean | undefined;
|
|
29
|
+
/** The address on one line. Used for the street when `streetAddress` is blank. */
|
|
30
|
+
address?: string | undefined;
|
|
31
|
+
streetAddress?: string | undefined;
|
|
32
|
+
postalCode?: string | undefined;
|
|
33
|
+
addressLocality?: string | undefined;
|
|
34
|
+
addressRegion?: string | undefined;
|
|
35
|
+
email?: string | undefined;
|
|
36
|
+
phone?: string | undefined;
|
|
37
|
+
}
|
package/src/seo/site.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { Metadata, MetadataRoute } from "next";
|
|
2
|
+
import { isIndexableEnvironment } from "./environment";
|
|
3
|
+
import { ref, type OrganizationInfo, type SchemaNode } from "./schema";
|
|
4
|
+
|
|
5
|
+
/** The size of the share image. */
|
|
6
|
+
export const SHARE_IMAGE_SIZE = { width: 1200, height: 630 } as const;
|
|
7
|
+
|
|
8
|
+
/** The robots directive for pages that exist for signed-in users only. */
|
|
9
|
+
export const NOINDEX: Metadata["robots"] = { index: false, follow: false };
|
|
10
|
+
|
|
11
|
+
export interface SiteConfig<L extends string> {
|
|
12
|
+
/** The site's name, as in titles, Open Graph and structured data. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** The canonical origin, such as `https://www.example.no`, without a trailing slash. */
|
|
15
|
+
url: string;
|
|
16
|
+
/** Between a page title and the site name, such as `" | "`. */
|
|
17
|
+
titleSeparator: string;
|
|
18
|
+
/** The locale segments every page is under, such as `["no", "en"]`. A site without them omits this. */
|
|
19
|
+
locales?: readonly L[] | undefined;
|
|
20
|
+
/** The locale `x-default` points at. Defaults to the first locale. */
|
|
21
|
+
defaultLocale?: L | undefined;
|
|
22
|
+
/** The BCP 47 tag for each locale, for hreflang and Open Graph. Defaults to the locale itself. */
|
|
23
|
+
localeTags?: Partial<Record<L, string>> | undefined;
|
|
24
|
+
/** The short line under the name, per locale. Used in the default title and the share image's alt text. */
|
|
25
|
+
tagline?: ((locale: L) => string) | undefined;
|
|
26
|
+
/** The share image route below each locale. Defaults to `/og`. */
|
|
27
|
+
shareImagePath?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PageMetadataInput<L extends string> {
|
|
31
|
+
locale: L;
|
|
32
|
+
/** The path below the locale segment, such as `/builds/my-pc`. Omit for the front page. */
|
|
33
|
+
path?: string | undefined;
|
|
34
|
+
/** The page title, without the site name. The root layout's template adds it. */
|
|
35
|
+
title?: string | undefined;
|
|
36
|
+
description?: string | undefined;
|
|
37
|
+
/** Image URLs for the share preview. Defaults to the share image for the locale. */
|
|
38
|
+
images?: string[] | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SiteMetadataInput<L extends string> {
|
|
42
|
+
locale: L;
|
|
43
|
+
description?: string | undefined;
|
|
44
|
+
/** The web app manifest's path, such as `/manifest.json`. */
|
|
45
|
+
manifest?: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Crumb {
|
|
49
|
+
name: string;
|
|
50
|
+
/** A site-relative path, such as `/no/builds`. The last crumb is the current page and has none. */
|
|
51
|
+
path?: string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LocaleEntryOptions<L extends string> {
|
|
55
|
+
/** The locales the page exists in. Defaults to all of them. */
|
|
56
|
+
locales?: readonly L[] | undefined;
|
|
57
|
+
/** When the page last changed, per locale. Left out when unknown. */
|
|
58
|
+
lastModified?: ((locale: L) => Date | undefined) | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RobotsInput {
|
|
62
|
+
/** Paths below each locale segment that must not be crawled, such as `/admin`. */
|
|
63
|
+
privatePaths?: readonly string[] | undefined;
|
|
64
|
+
/** Paths outside the locale segments that must not be crawled, such as `/api/`. */
|
|
65
|
+
privateRootPaths?: readonly string[] | undefined;
|
|
66
|
+
/** Whether search engines may index this deployment. Defaults to `isIndexableEnvironment()`. */
|
|
67
|
+
indexable?: boolean | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface OrganizationOptions {
|
|
71
|
+
/** The schema.org type, such as `Organization` or `ComputerStore`. Defaults to `Organization`. */
|
|
72
|
+
type?: string | undefined;
|
|
73
|
+
/** The ISO country code for the address and the area served. Defaults to `NO`. */
|
|
74
|
+
country?: string | undefined;
|
|
75
|
+
/** Site-relative paths of the square image and the logo. Default to `/icon.png` and `/logo.png`. */
|
|
76
|
+
image?: string | undefined;
|
|
77
|
+
logo?: string | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One leading slash, no trailing slash. `""` and `"/"` both mean the front page. Tabs and line
|
|
82
|
+
* breaks are removed, as a URL parser removes them, and leading slashes and backslashes collapse
|
|
83
|
+
* to one, so a path always stays on the site's own host.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizePath(path: string): string {
|
|
86
|
+
const clean = path.replace(/[\t\n\r]/g, "");
|
|
87
|
+
if (clean === "") return "";
|
|
88
|
+
const withSlash = `/${clean.replace(/^[/\\]+/, "")}`;
|
|
89
|
+
return withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The URL, metadata, sitemap, robots and structured data helpers for one site, bound to its name,
|
|
94
|
+
* canonical origin and locales.
|
|
95
|
+
*/
|
|
96
|
+
export function defineSite<L extends string = string>(config: SiteConfig<L>) {
|
|
97
|
+
const locales = config.locales ?? [];
|
|
98
|
+
const defaultLocale = config.defaultLocale ?? locales[0];
|
|
99
|
+
const tag = (locale: L) => config.localeTags?.[locale] ?? locale;
|
|
100
|
+
const shareImagePath = config.shareImagePath ?? "/og";
|
|
101
|
+
const origin = new URL(config.url).origin;
|
|
102
|
+
|
|
103
|
+
/** A site-relative URL for a page in one locale, such as `/no/builds`. A site without locales has no segment. */
|
|
104
|
+
const localePath = (locale: L | undefined, path = ""): string =>
|
|
105
|
+
locale === undefined || locales.length === 0 ? normalizePath(path) || "/" : `/${locale}${normalizePath(path)}`;
|
|
106
|
+
|
|
107
|
+
/** An absolute URL on the canonical origin, without a trailing slash. */
|
|
108
|
+
const absoluteUrl = (path = ""): string => {
|
|
109
|
+
const url = new URL(normalizePath(path) || "/", config.url);
|
|
110
|
+
// A path that would leave the site's origin gives the front page instead.
|
|
111
|
+
if (url.origin !== origin) return config.url;
|
|
112
|
+
return url.toString().replace(/\/$/, "") || config.url;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const absoluteLocaleUrl = (locale: L, path = ""): string => absoluteUrl(localePath(locale, path));
|
|
116
|
+
|
|
117
|
+
/** Language alternates by BCP 47 tag. `x-default` points at the default locale. */
|
|
118
|
+
const languageAlternates = (path: string): Record<string, string> => ({
|
|
119
|
+
...Object.fromEntries(locales.map(l => [tag(l), localePath(l, path)])),
|
|
120
|
+
...(defaultLocale !== undefined ? { "x-default": localePath(defaultLocale, path) } : {}),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const fullTitle = (locale: L) => {
|
|
124
|
+
const tagline = config.tagline?.(locale);
|
|
125
|
+
return tagline ? `${config.name}${config.titleSeparator}${tagline}` : config.name;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const defaultShareImage = (locale: L) => ({
|
|
129
|
+
url: localePath(locale, shareImagePath),
|
|
130
|
+
...SHARE_IMAGE_SIZE,
|
|
131
|
+
type: "image/png",
|
|
132
|
+
alt: fullTitle(locale),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** The canonical URL, hreflang set and Open Graph for one public page. */
|
|
136
|
+
const pageMetadata = ({ locale, path = "", title, description, images }: PageMetadataInput<L>): Metadata => {
|
|
137
|
+
const canonical = localePath(locale, path);
|
|
138
|
+
return {
|
|
139
|
+
...(title ? { title } : {}),
|
|
140
|
+
...(description ? { description } : {}),
|
|
141
|
+
alternates: {
|
|
142
|
+
canonical,
|
|
143
|
+
...(locales.length > 0 ? { languages: languageAlternates(path) } : {}),
|
|
144
|
+
},
|
|
145
|
+
openGraph: {
|
|
146
|
+
type: "website",
|
|
147
|
+
siteName: config.name,
|
|
148
|
+
locale: tag(locale),
|
|
149
|
+
url: canonical,
|
|
150
|
+
...(title ? { title } : {}),
|
|
151
|
+
...(description ? { description } : {}),
|
|
152
|
+
images: images ?? [defaultShareImage(locale)],
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** The metadata for the locale root layout: the title template, the index directive and the front page's canonical. */
|
|
158
|
+
const siteMetadata = ({ locale, description, manifest }: SiteMetadataInput<L>): Metadata => {
|
|
159
|
+
const title = fullTitle(locale);
|
|
160
|
+
return {
|
|
161
|
+
...pageMetadata({ locale, title, description }),
|
|
162
|
+
metadataBase: new URL(config.url),
|
|
163
|
+
title: { default: title, template: `%s${config.titleSeparator}${config.name}` },
|
|
164
|
+
...(manifest ? { manifest } : {}),
|
|
165
|
+
robots: { index: true, follow: true },
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** One sitemap entry per locale a page is served in, each listing the others as alternates. */
|
|
170
|
+
const localeEntries = (path: string, options: LocaleEntryOptions<L> = {}): MetadataRoute.Sitemap => {
|
|
171
|
+
const served = options.locales ?? locales;
|
|
172
|
+
|
|
173
|
+
const languages: Record<string, string> = Object.fromEntries(served.map(l => [tag(l), absoluteLocaleUrl(l, path)]));
|
|
174
|
+
if (defaultLocale !== undefined && served.includes(defaultLocale)) {
|
|
175
|
+
languages["x-default"] = absoluteLocaleUrl(defaultLocale, path);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return served.map(locale => {
|
|
179
|
+
const lastModified = options.lastModified?.(locale);
|
|
180
|
+
return { url: absoluteLocaleUrl(locale, path), ...(lastModified ? { lastModified } : {}), alternates: { languages } };
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* robots.txt. A deployment that is not production disallows everything. Otherwise the private
|
|
186
|
+
* paths are disallowed under every locale segment, since a bare `/admin` matches nothing there.
|
|
187
|
+
*/
|
|
188
|
+
const robots = ({ privatePaths = [], privateRootPaths = [], indexable = isIndexableEnvironment() }: RobotsInput = {}): MetadataRoute.Robots => {
|
|
189
|
+
if (!indexable) return { rules: { userAgent: "*", disallow: "/" } };
|
|
190
|
+
|
|
191
|
+
const below = locales.length > 0 ? locales.flatMap(l => privatePaths.map(p => localePath(l, p))) : [...privatePaths];
|
|
192
|
+
return {
|
|
193
|
+
rules: { userAgent: "*", allow: "/", disallow: [...below, ...privateRootPaths] },
|
|
194
|
+
sitemap: absoluteUrl("/sitemap.xml"),
|
|
195
|
+
host: config.url,
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** An `@id` for an entity that is the same on every page. Page-scoped nodes use the page URL. */
|
|
200
|
+
const siteId = (fragment: string): string => `${config.url}/#${fragment}`;
|
|
201
|
+
const ids = { organization: siteId("organization"), website: siteId("website") } as const;
|
|
202
|
+
|
|
203
|
+
/** The business, under a stable `@id` that page-scoped nodes point at. */
|
|
204
|
+
const organizationNode = (company: OrganizationInfo, options: OrganizationOptions = {}): SchemaNode => {
|
|
205
|
+
const country = options.country ?? "NO";
|
|
206
|
+
return {
|
|
207
|
+
"@type": options.type ?? "Organization",
|
|
208
|
+
"@id": ids.organization,
|
|
209
|
+
name: company.name,
|
|
210
|
+
...(company.legalName && company.legalName !== company.name ? { legalName: company.legalName } : {}),
|
|
211
|
+
url: absoluteUrl(),
|
|
212
|
+
image: absoluteUrl(options.image ?? "/icon.png"),
|
|
213
|
+
logo: absoluteUrl(options.logo ?? "/logo.png"),
|
|
214
|
+
telephone: company.phone,
|
|
215
|
+
email: company.email,
|
|
216
|
+
address: {
|
|
217
|
+
"@type": "PostalAddress",
|
|
218
|
+
streetAddress: company.streetAddress || company.address,
|
|
219
|
+
...(company.postalCode ? { postalCode: company.postalCode } : {}),
|
|
220
|
+
...(company.addressLocality ? { addressLocality: company.addressLocality } : {}),
|
|
221
|
+
...(company.addressRegion ? { addressRegion: company.addressRegion } : {}),
|
|
222
|
+
addressCountry: country,
|
|
223
|
+
},
|
|
224
|
+
areaServed: country,
|
|
225
|
+
...(company.vatRegistered && company.orgNumber ? { vatID: `NO${company.orgNumber.replace(/\s/g, "")}MVA` } : {}),
|
|
226
|
+
...(company.orgNumber ? { taxID: company.orgNumber } : {}),
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** The site as an entity, so search engines attribute its pages to it and to the business. */
|
|
231
|
+
const webSiteNode = (locale: L): SchemaNode => ({
|
|
232
|
+
"@type": "WebSite",
|
|
233
|
+
"@id": ids.website,
|
|
234
|
+
url: absoluteUrl(),
|
|
235
|
+
name: config.name,
|
|
236
|
+
inLanguage: tag(locale),
|
|
237
|
+
publisher: ref(ids.organization),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/** The trail search results show in place of the raw URL. The last crumb has no `item`. */
|
|
241
|
+
const breadcrumbNode = (crumbs: Crumb[], pageUrl: string): SchemaNode => ({
|
|
242
|
+
"@type": "BreadcrumbList",
|
|
243
|
+
"@id": `${pageUrl}#breadcrumb`,
|
|
244
|
+
itemListElement: crumbs.map((crumb, index) => ({
|
|
245
|
+
"@type": "ListItem",
|
|
246
|
+
position: index + 1,
|
|
247
|
+
name: crumb.name,
|
|
248
|
+
...(crumb.path ? { item: absoluteUrl(crumb.path) } : {}),
|
|
249
|
+
})),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/** Marks a listing page as an ordered set of pages. */
|
|
253
|
+
const itemListNode = (paths: string[], pageUrl: string): SchemaNode => ({
|
|
254
|
+
"@type": "ItemList",
|
|
255
|
+
"@id": `${pageUrl}#itemlist`,
|
|
256
|
+
numberOfItems: paths.length,
|
|
257
|
+
itemListElement: paths.map((path, index) => ({ "@type": "ListItem", position: index + 1, url: absoluteUrl(path) })),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
name: config.name,
|
|
262
|
+
url: config.url,
|
|
263
|
+
normalizePath,
|
|
264
|
+
localePath,
|
|
265
|
+
absoluteUrl,
|
|
266
|
+
absoluteLocaleUrl,
|
|
267
|
+
languageAlternates,
|
|
268
|
+
pageMetadata,
|
|
269
|
+
siteMetadata,
|
|
270
|
+
localeEntries,
|
|
271
|
+
robots,
|
|
272
|
+
siteId,
|
|
273
|
+
ids,
|
|
274
|
+
organizationNode,
|
|
275
|
+
webSiteNode,
|
|
276
|
+
breadcrumbNode,
|
|
277
|
+
itemListNode,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export type Site<L extends string = string> = ReturnType<typeof defineSite<L>>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The latest of a set of timestamps, or undefined when there are none. */
|
|
2
|
+
export function latest(dates: readonly (Date | undefined)[]): Date | undefined {
|
|
3
|
+
const known = dates.filter((date): date is Date => date != null);
|
|
4
|
+
if (known.length === 0) return undefined;
|
|
5
|
+
return known.reduce((newest, date) => (date > newest ? date : newest));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Parses an API timestamp. A missing or malformed value gives undefined. */
|
|
9
|
+
export function parseTimestamp(value: string | null | undefined): Date | undefined {
|
|
10
|
+
if (!value) return undefined;
|
|
11
|
+
const date = new Date(value);
|
|
12
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
13
|
+
}
|