@sjolystinnovation/app-kit 0.12.0 → 0.14.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 CHANGED
@@ -28,7 +28,7 @@ const nextConfig: NextConfig = {
28
28
  };
29
29
  ```
30
30
 
31
- The components in `@sjolystinnovation/app-kit/ui`, `markdown` and `session/react` are styled with Tailwind CSS.
31
+ The components in `@sjolystinnovation/app-kit/ui`, `markdown`, `branding` and `session/react` are styled with Tailwind CSS.
32
32
  Import the package's stylesheet after Tailwind:
33
33
 
34
34
  ```css
@@ -378,6 +378,151 @@ 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
+
463
+ ### `@sjolystinnovation/app-kit/branding`
464
+
465
+ The logo and favicon an admin uploads, as the API's `/branding` endpoint returns them.
466
+
467
+ `BrandingProvider` holds them for the components below it. It takes the branding the server
468
+ rendered with, loads it on mount when there is none, and loads it again every five minutes, so an
469
+ open tab picks up a change. A failed load keeps what it has. `useBranding()` gives `branding`,
470
+ `logoUrl` as a `data:` URL or null, and `refresh()`.
471
+
472
+ ```tsx
473
+ "use client";
474
+ import { BrandingProvider, useBranding } from "@sjolystinnovation/app-kit/branding";
475
+
476
+ <BrandingProvider load={BrandingService.get} initial={initialBranding}>{children}</BrandingProvider>;
477
+
478
+ const { logoUrl } = useBranding();
479
+ <img src={logoUrl ?? "/logo.png"} alt="" />;
480
+ ```
481
+
482
+ `BrandingManager` is the admin form for uploading or removing them. It needs a `BrandingProvider`
483
+ above it, and refreshes it after a save.
484
+
485
+ ```tsx
486
+ <BrandingManager save={form => BrandingService.update(form)} />
487
+ ```
488
+
489
+ | Prop | Type | Description |
490
+ | --- | --- | --- |
491
+ | `save` | `(form: FormData) => Promise<unknown>` | Sends `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw to show the error's message. |
492
+ | `strings` | `TextOverrides<BrandingManagerStrings>` | The form's text. |
493
+
494
+ `toDataUrl(data, contentType)` turns base64 bytes into a `data:` URL.
495
+
496
+ ### `@sjolystinnovation/app-kit/branding/server`
497
+
498
+ `brandingIcon({ load, fallbackPath, fallbackType })` is the response for an `app/icon.ts` route:
499
+ the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF or ICO, or the bundled file
500
+ otherwise, since an SVG or HTML upload could run script on the site's origin. Both carry `nosniff`
501
+ and a sandbox CSP, which a CSP header from the app's `next.config` replaces. When the branding cannot be
502
+ loaded it answers 503 with `no-store`, since a browser would cache the bundled icon in place of the
503
+ one set in settings.
504
+
505
+ ```ts
506
+ // src/app/icon.ts
507
+ import { join } from "node:path";
508
+ import { brandingIcon } from "@sjolystinnovation/app-kit/branding/server";
509
+
510
+ export const dynamic = "force-dynamic";
511
+
512
+ export default function Icon() {
513
+ return brandingIcon({
514
+ load: () => publicGet<Branding>("/branding", { tags: ["branding"] }),
515
+ fallbackPath: join(process.cwd(), "public", "icon.png"),
516
+ fallbackType: "image/png",
517
+ });
518
+ }
519
+ ```
520
+
521
+ The route needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment. Tag the
522
+ fetch and revalidate the tag after a save, so a new icon shows at once. `iconVersion(data)` is a
523
+ short digest of the icon bytes, for a manifest icon URL such as `/icon?v=<digest>`, since Android
524
+ refreshes a home screen icon only when the manifest changes.
525
+
381
526
  ### `@sjolystinnovation/app-kit/session`
382
527
 
383
528
  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.12.0",
3
+ "version": "0.14.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,10 @@
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",
32
+ "./branding": "./src/branding/index.ts",
33
+ "./branding/server": "./src/branding/server.ts",
30
34
  "./styles.css": "./src/styles.css"
31
35
  },
32
36
  "files": [
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import { ArrowUpTrayIcon, TrashIcon } from "@heroicons/react/24/outline";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { toast } from "../toast";
6
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
7
+ import { useBranding } from "./BrandingProvider";
8
+ import { toDataUrl } from "./types";
9
+
10
+ export interface BrandingManagerStrings {
11
+ title: string;
12
+ logo: string;
13
+ logoHint: string;
14
+ icon: string;
15
+ iconHint: string;
16
+ none: string;
17
+ choose: string;
18
+ remove: string;
19
+ save: string;
20
+ saving: string;
21
+ saved: string;
22
+ saveFailed: string;
23
+ }
24
+
25
+ export const defaultBrandingManagerStrings: BrandingManagerStrings = {
26
+ title: "Branding",
27
+ logo: "Logo",
28
+ logoHint: "Shown in the navbar and footer. PNG/SVG, max 2 MB.",
29
+ icon: "Icon (favicon)",
30
+ iconHint: "Browser tab icon. Square PNG/ICO, max 2 MB.",
31
+ none: "none",
32
+ choose: "Choose",
33
+ remove: "Remove",
34
+ save: "Save branding",
35
+ saving: "Saving…",
36
+ saved: "Branding saved",
37
+ saveFailed: "Failed to save branding",
38
+ };
39
+
40
+ export interface BrandingManagerProps {
41
+ /**
42
+ * Sends the form: `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw
43
+ * to show the error's message, such as one from `formatApiError`.
44
+ */
45
+ save: (form: FormData) => Promise<unknown>;
46
+ strings?: TextOverrides<BrandingManagerStrings> | undefined;
47
+ }
48
+
49
+ /** A preview is an object URL for the chosen file or a data URL of a raster image. Anything else shows none. */
50
+ const PREVIEW_URL = /^(blob:|data:image\/(png|jpeg|webp|gif|x-icon|vnd\.microsoft\.icon);base64,)/i;
51
+ const previewUrl = (url: string | null): string | null => (url !== null && PREVIEW_URL.test(url) ? url : null);
52
+
53
+ interface SlotProps {
54
+ label: string;
55
+ hint: string;
56
+ currentUrl: string | null;
57
+ file: File | null;
58
+ onPick: (file: File | null) => void;
59
+ onClear: () => void;
60
+ text: BrandingManagerStrings;
61
+ }
62
+
63
+ function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProps) {
64
+ const inputRef = useRef<HTMLInputElement>(null);
65
+ const [objectUrl, setObjectUrl] = useState<string | null>(null);
66
+
67
+ // An object URL is an outside resource with a lifetime, which is what an effect is for. Creating
68
+ // it during render instead leaks the second URL that StrictMode builds when it renders twice.
69
+ /* eslint-disable react-hooks/set-state-in-effect */
70
+ useEffect(() => {
71
+ if (!file) {
72
+ setObjectUrl(null);
73
+ return;
74
+ }
75
+ const url = URL.createObjectURL(file);
76
+ setObjectUrl(url);
77
+ return () => URL.revokeObjectURL(url);
78
+ }, [file]);
79
+ /* eslint-enable react-hooks/set-state-in-effect */
80
+
81
+ const preview = previewUrl(objectUrl ?? currentUrl);
82
+ return (
83
+ <div>
84
+ <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
85
+ <div className="flex items-center gap-3">
86
+ <div className="h-14 w-14 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center overflow-hidden">
87
+ {preview ? (
88
+ <img src={preview} alt={label} className="max-h-full max-w-full object-contain" />
89
+ ) : (
90
+ <span className="text-xs text-gray-400">{text.none}</span>
91
+ )}
92
+ </div>
93
+ <input
94
+ ref={inputRef}
95
+ type="file"
96
+ accept="image/*"
97
+ aria-label={label}
98
+ className="hidden"
99
+ onChange={e => onPick(e.target.files?.[0] ?? null)}
100
+ />
101
+ <button
102
+ type="button"
103
+ onClick={() => inputRef.current?.click()}
104
+ className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 text-gray-700 font-medium px-3 py-2 text-sm hover:bg-gray-200"
105
+ >
106
+ <ArrowUpTrayIcon className="h-4 w-4" /> {text.choose}
107
+ </button>
108
+ {(currentUrl || file) && (
109
+ <button type="button" onClick={onClear} className="p-2 rounded-lg text-red-500 hover:bg-red-50" title={text.remove} aria-label={`${text.remove} ${label}`}>
110
+ <TrashIcon className="h-4 w-4" />
111
+ </button>
112
+ )}
113
+ </div>
114
+ <p className="mt-1 text-xs text-gray-500">{hint}</p>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ /** The admin form for uploading or removing the logo and the favicon. Needs a `BrandingProvider` above it. */
120
+ export function BrandingManager({ save, strings }: BrandingManagerProps) {
121
+ const text = withDefaults(defaultBrandingManagerStrings, strings);
122
+ const { branding, refresh } = useBranding();
123
+ const [logo, setLogo] = useState<File | null>(null);
124
+ const [icon, setIcon] = useState<File | null>(null);
125
+ const [removeLogo, setRemoveLogo] = useState(false);
126
+ const [removeIcon, setRemoveIcon] = useState(false);
127
+ const [saving, setSaving] = useState(false);
128
+
129
+ const currentLogo = removeLogo ? null : toDataUrl(branding.logoData, branding.logoContentType);
130
+ const currentIcon = removeIcon ? null : toDataUrl(branding.iconData, branding.iconContentType);
131
+
132
+ const submit = async () => {
133
+ setSaving(true);
134
+ try {
135
+ const form = new FormData();
136
+ if (logo) form.append("logo", logo);
137
+ if (icon) form.append("icon", icon);
138
+ if (removeLogo) form.append("removeLogo", "true");
139
+ if (removeIcon) form.append("removeIcon", "true");
140
+ await save(form);
141
+ setLogo(null);
142
+ setIcon(null);
143
+ setRemoveLogo(false);
144
+ setRemoveIcon(false);
145
+ refresh();
146
+ toast.success(text.saved);
147
+ } catch (err) {
148
+ toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
149
+ } finally {
150
+ setSaving(false);
151
+ }
152
+ };
153
+
154
+ return (
155
+ <div className="max-w-md space-y-5 border-t border-gray-200 pt-6">
156
+ <h2 className="font-bold text-gray-900">{text.title}</h2>
157
+ <Slot
158
+ label={text.logo}
159
+ hint={text.logoHint}
160
+ currentUrl={currentLogo}
161
+ file={logo}
162
+ onPick={f => { setLogo(f); setRemoveLogo(false); }}
163
+ onClear={() => { setLogo(null); setRemoveLogo(true); }}
164
+ text={text}
165
+ />
166
+ <Slot
167
+ label={text.icon}
168
+ hint={text.iconHint}
169
+ currentUrl={currentIcon}
170
+ file={icon}
171
+ onPick={f => { setIcon(f); setRemoveIcon(false); }}
172
+ onClear={() => { setIcon(null); setRemoveIcon(true); }}
173
+ text={text}
174
+ />
175
+ <button
176
+ type="button"
177
+ onClick={submit}
178
+ disabled={saving || (!logo && !icon && !removeLogo && !removeIcon)}
179
+ className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-5 py-2.5 hover:brightness-95 disabled:opacity-60 transition"
180
+ >
181
+ {saving ? text.saving : text.save}
182
+ </button>
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { toDataUrl, type Branding } from "./types";
5
+
6
+ export interface BrandingContextValue {
7
+ branding: Branding;
8
+ /** The logo as a `data:` URL, or null when none is set. */
9
+ logoUrl: string | null;
10
+ /** Loads the branding again, such as after saving it. */
11
+ refresh: () => void;
12
+ }
13
+
14
+ const BrandingContext = createContext<BrandingContextValue>({ branding: {}, logoUrl: null, refresh: () => {} });
15
+
16
+ /** The branding from the nearest `BrandingProvider`. Empty outside one. */
17
+ export const useBranding = () => useContext(BrandingContext);
18
+
19
+ /** How often the provider loads the branding again, so an open tab picks up a change. */
20
+ export const BRANDING_REFRESH_MS = 5 * 60 * 1000;
21
+
22
+ export interface BrandingProviderProps {
23
+ children: ReactNode;
24
+ /** Loads the branding, such as a GET of `/branding`. A failure keeps the branding it has. */
25
+ load: () => Promise<Branding>;
26
+ /** The branding the server rendered with. Without it, the provider loads it on mount. */
27
+ initial?: Branding | undefined;
28
+ }
29
+
30
+ /** Holds the logo and favicon for the components below it, and loads them again every five minutes. */
31
+ export function BrandingProvider({ children, load, initial }: BrandingProviderProps) {
32
+ const [branding, setBranding] = useState<Branding>(initial ?? {});
33
+ // The latest load, so an inline function does not restart the refresh timer on every render.
34
+ const loadRef = useRef(load);
35
+ useEffect(() => {
36
+ loadRef.current = load;
37
+ }, [load]);
38
+ const hasInitial = initial !== undefined;
39
+
40
+ const refresh = useCallback(() => {
41
+ loadRef.current().then(setBranding).catch(() => {});
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ if (!hasInitial) refresh();
46
+ const id = setInterval(refresh, BRANDING_REFRESH_MS);
47
+ return () => clearInterval(id);
48
+ }, [refresh, hasInitial]);
49
+
50
+ const logoUrl = toDataUrl(branding.logoData, branding.logoContentType);
51
+
52
+ return <BrandingContext.Provider value={{ branding, logoUrl, refresh }}>{children}</BrandingContext.Provider>;
53
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ BrandingManager,
3
+ defaultBrandingManagerStrings,
4
+ type BrandingManagerProps,
5
+ type BrandingManagerStrings,
6
+ } from "./BrandingManager";
7
+ export {
8
+ BRANDING_REFRESH_MS,
9
+ BrandingProvider,
10
+ useBranding,
11
+ type BrandingContextValue,
12
+ type BrandingProviderProps,
13
+ } from "./BrandingProvider";
14
+ export { toDataUrl, type Branding } from "./types";
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import type { Branding } from "./types";
4
+
5
+ export interface BrandingIconOptions {
6
+ /** Loads the branding, such as a cached GET of `/branding`. Throws when the API cannot be reached. */
7
+ load: () => Promise<Branding | null | undefined>;
8
+ /** The bundled icon for when none is set, such as `public/icon.png`. */
9
+ fallbackPath: string;
10
+ /** Its content type, such as `image/png` or `image/svg+xml`. */
11
+ fallbackType: string;
12
+ }
13
+
14
+ /**
15
+ * The response for an `app/icon.ts` route: the favicon set in admin settings when it is a PNG,
16
+ * JPEG, WebP, GIF or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox CSP
17
+ * that a CSP header from the app's `next.config` replaces.
18
+ * When the branding cannot be loaded it answers 503 with `no-store`, not the
19
+ * bundled icon, since a browser would cache that in place of the one set in settings. The route
20
+ * needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment.
21
+ */
22
+ export async function brandingIcon({ load, fallbackPath, fallbackType }: BrandingIconOptions): Promise<Response> {
23
+ let branding: Branding | null | undefined;
24
+ try {
25
+ branding = await load();
26
+ } catch {
27
+ return new Response(null, { status: 503, headers: { "Cache-Control": "no-store" } });
28
+ }
29
+
30
+ const type = branding?.iconContentType?.trim().toLowerCase();
31
+ if (branding?.iconData && type && RASTER_TYPES.has(type)) {
32
+ return new Response(new Uint8Array(Buffer.from(branding.iconData, "base64")), {
33
+ headers: { "Content-Type": type, ...HARDENING },
34
+ });
35
+ }
36
+
37
+ return new Response(new Uint8Array(await readFile(fallbackPath)), { headers: { "Content-Type": fallbackType, ...HARDENING } });
38
+ }
39
+
40
+ /**
41
+ * The uploaded types the icon route serves. An SVG or HTML upload could carry script and would run
42
+ * on the site's origin, so anything else gives the bundled icon instead.
43
+ */
44
+ const RASTER_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/x-icon", "image/vnd.microsoft.icon"]);
45
+
46
+ /** Keeps a browser from sniffing the bytes as another type, and from running anything in them where the app sets no CSP. */
47
+ const HARDENING = { "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'; sandbox" };
48
+
49
+ /**
50
+ * A short digest of icon bytes, for a URL that changes when the icon does, such as
51
+ * `/icon?v=<digest>` in the web app manifest. Android refreshes a home screen icon only when the
52
+ * manifest changes.
53
+ */
54
+ export const iconVersion = (data: string | Buffer): string => createHash("sha256").update(data).digest("hex").slice(0, 12);
@@ -0,0 +1,13 @@
1
+ /** The logo and favicon set in admin settings, as the API's `/branding` endpoint returns them. */
2
+ export interface Branding {
3
+ /** Base64 image bytes. */
4
+ logoData?: string | null | undefined;
5
+ logoContentType?: string | null | undefined;
6
+ /** Base64 image bytes. */
7
+ iconData?: string | null | undefined;
8
+ iconContentType?: string | null | undefined;
9
+ }
10
+
11
+ /** A `data:` URL for base64 image bytes, or null when either part is missing. */
12
+ export const toDataUrl = (data?: string | null, contentType?: string | null): string | null =>
13
+ data && contentType ? `data:${contentType};base64,${data}` : null;
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }