@sjolystinnovation/app-kit 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +315 -253
  2. package/package.json +13 -1
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +9 -3
  5. package/src/admin/CompanySettingsForm.tsx +123 -0
  6. package/src/admin/LegalPagesEditor.tsx +132 -0
  7. package/src/admin/LocaleTabs.tsx +36 -0
  8. package/src/admin/StatHistoryChart.tsx +100 -0
  9. package/src/admin/adminUsersPanelStrings.ts +3 -3
  10. package/src/admin/companySettingsFormStrings.ts +50 -0
  11. package/src/admin/index.ts +14 -0
  12. package/src/admin/legalPagesEditorStrings.ts +32 -0
  13. package/src/admin/statHistoryChartStrings.ts +17 -0
  14. package/src/api/apiFieldErrors.ts +4 -4
  15. package/src/api/company.ts +47 -0
  16. package/src/api/createApiClient.ts +3 -2
  17. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  18. package/src/api/index.ts +6 -1
  19. package/src/api/request.ts +2 -2
  20. package/src/api/revalidateTargets.ts +42 -0
  21. package/src/api/services.ts +14 -13
  22. package/src/auth/createAuthOptions.ts +7 -5
  23. package/src/branding/BrandingManager.tsx +10 -5
  24. package/src/branding/BrandingProvider.tsx +5 -3
  25. package/src/branding/index.ts +1 -2
  26. package/src/branding/server.ts +10 -9
  27. package/src/branding/types.ts +3 -3
  28. package/src/config.ts +10 -10
  29. package/src/forms/useFormDraft.ts +9 -7
  30. package/src/index.ts +1 -3
  31. package/src/legal/LegalPageView.tsx +62 -0
  32. package/src/legal/index.ts +1 -0
  33. package/src/markdown/Markdown.tsx +12 -10
  34. package/src/markdown/index.ts +1 -1
  35. package/src/next-auth.d.ts +11 -9
  36. package/src/next-config/headers.ts +96 -0
  37. package/src/next-config/index.ts +18 -107
  38. package/src/payments/PaymentReturn.tsx +9 -7
  39. package/src/payments/formatNok.ts +1 -1
  40. package/src/payments/usePollUntilFinal.ts +7 -5
  41. package/src/seo/JsonLd.tsx +1 -1
  42. package/src/seo/environment.ts +3 -3
  43. package/src/seo/index.ts +2 -1
  44. package/src/seo/og/index.tsx +5 -4
  45. package/src/seo/schema.ts +6 -2
  46. package/src/seo/site.ts +90 -30
  47. package/src/seo/sitemap.ts +2 -2
  48. package/src/server/publicApi.ts +13 -9
  49. package/src/server/revalidateRoute.ts +10 -8
  50. package/src/session/expiry.ts +8 -5
  51. package/src/session/react/AppSessionProvider.tsx +3 -2
  52. package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
  53. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  54. package/src/session/react/ProtectedGate.tsx +3 -2
  55. package/src/session/react/RoleGate.tsx +4 -3
  56. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  57. package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
  58. package/src/session/react/index.ts +8 -2
  59. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  60. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  61. package/src/session/react/useSessionGate.ts +3 -3
  62. package/src/styles.css +2 -0
  63. package/src/testing/index.ts +113 -0
  64. package/src/ui/Alert.tsx +6 -4
  65. package/src/ui/ContentImage.tsx +12 -11
  66. package/src/ui/ErrorState.tsx +5 -3
  67. package/src/ui/NotFoundState.tsx +9 -7
  68. package/src/ui/PasswordInput.tsx +6 -5
  69. package/src/ui/ResetPasswordFlow.tsx +10 -8
  70. package/src/ui/TextArea.tsx +6 -4
  71. package/src/ui/TextInput.tsx +4 -2
  72. package/src/ui/Toggle.tsx +3 -3
  73. package/src/ui/field.tsx +5 -3
  74. package/src/ui/index.ts +2 -10
  75. package/src/ui/resetPasswordFlowStrings.ts +7 -7
  76. package/src/validation/fieldErrors.ts +4 -3
  77. package/src/validation/password.ts +5 -5
  78. package/src/validation/passwordRules.ts +2 -2
@@ -0,0 +1,62 @@
1
+ import Link from "next/link";
2
+ import type { LegalPage } from "../api/services";
3
+ import { Markdown } from "../markdown";
4
+ import { parseTimestamp } from "../seo/sitemap";
5
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
6
+
7
+ /** The legal page's text around the content. Pass the app's own language. The defaults are English. */
8
+ export interface LegalPageViewStrings {
9
+ /** The text before the date the page last changed. */
10
+ updated: string;
11
+ /** The text shown in place of the content when nobody has written the page yet. */
12
+ missing: string;
13
+ /** The text of the link back to the front page. */
14
+ toFrontPage: string;
15
+ }
16
+
17
+ export const defaultLegalPageViewStrings: LegalPageViewStrings = {
18
+ updated: "Last updated",
19
+ missing: "This content has not been written yet.",
20
+ toFrontPage: "Back to the front page",
21
+ };
22
+
23
+ /** Props for `LegalPageView`. */
24
+ export interface LegalPageViewProps {
25
+ /** The page from the API, or null when nobody has written it yet. */
26
+ page: LegalPage | null;
27
+ /** The heading while there is no page. */
28
+ title: string;
29
+ /** The front page's path, for the link at the bottom. */
30
+ homeHref: string;
31
+ /** The BCP 47 tag the date is written in, such as `nb-NO`. */
32
+ dateLocale: string;
33
+ strings?: TextOverrides<LegalPageViewStrings> | undefined;
34
+ }
35
+
36
+ /** Renders a terms, privacy, or cookies page as an admin wrote it: the title, when it last changed, and the Markdown body. */
37
+ export function LegalPageView({ page, title, homeHref, dateLocale, strings }: LegalPageViewProps) {
38
+ const text = withDefaults(defaultLegalPageViewStrings, strings);
39
+ // A malformed timestamp leaves the date out rather than failing the page.
40
+ const updatedAt = parseTimestamp(page?.updatedAt);
41
+
42
+ return (
43
+ <div className="max-w-2xl mx-auto px-4 sm:px-6 py-12 space-y-6">
44
+ <h1 className="text-3xl font-black text-gray-900">{page?.title ?? title}</h1>
45
+ {updatedAt && (
46
+ <p className="text-xs text-gray-500">
47
+ {text.updated}: {new Intl.DateTimeFormat(dateLocale, { dateStyle: "long" }).format(updatedAt)}
48
+ </p>
49
+ )}
50
+
51
+ <div className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm space-y-4 text-sm text-gray-600">
52
+ {page ? <Markdown>{page.bodyMarkdown}</Markdown> : <p>{text.missing}</p>}
53
+ </div>
54
+
55
+ <p className="text-xs text-gray-500">
56
+ <Link href={homeHref} className="hover:text-gray-900 transition-colors">
57
+ ← {text.toFrontPage}
58
+ </Link>
59
+ </p>
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1 @@
1
+ export { LegalPageView, defaultLegalPageViewStrings, type LegalPageViewProps, type LegalPageViewStrings } from "./LegalPageView";
@@ -5,20 +5,20 @@ import remarkGfm from "remark-gfm";
5
5
 
6
6
  type Tag = keyof React.JSX.IntrinsicElements;
7
7
 
8
- /** The props without the `node` react-markdown passes, which is no DOM attribute. */
8
+ /** Copies the props without the `node` that react-markdown passes, since `node` is no DOM attribute. */
9
9
  function domProps<P extends ExtraProps>(props: P): Omit<P, "node"> {
10
10
  const rest = { ...props };
11
11
  delete rest.node;
12
12
  return rest;
13
13
  }
14
14
 
15
- /** An app's element, given the props without `node`, so it can spread them onto a DOM element. */
15
+ /** Wraps an app's element so it gets the props without `node` and can spread them onto a DOM element. */
16
16
  function withoutNode(Element: ElementType) {
17
17
  const Wrapped = (props: ExtraProps) => <Element {...domProps(props)} />;
18
18
  return Wrapped;
19
19
  }
20
20
 
21
- /** An element with fixed classes. */
21
+ /** Creates an element with fixed classes. */
22
22
  function styled<T extends Tag>(Element: T, className: string) {
23
23
  const Styled = (props: ComponentPropsWithoutRef<T> & ExtraProps) => {
24
24
  const Component = Element as ElementType;
@@ -27,7 +27,7 @@ function styled<T extends Tag>(Element: T, className: string) {
27
27
  return Styled;
28
28
  }
29
29
 
30
- /** The default look. A heading level one becomes a level two, since the page has its own `h1`. */
30
+ /** The default elements. A level one heading becomes a level two heading, since the page has its own `h1`. */
31
31
  export const markdownComponents: Components = {
32
32
  h1: styled("h2", "text-lg font-semibold text-markdown-heading"),
33
33
  h2: styled("h2", "text-base font-semibold text-markdown-heading pt-2"),
@@ -54,20 +54,22 @@ export const markdownComponents: Components = {
54
54
 
55
55
  const isExternal = (href: string | undefined) => href != null && /^https?:\/\//i.test(href);
56
56
 
57
+ /** Props for `Markdown`. */
57
58
  export interface MarkdownProps {
58
- /** Markdown text. Empty, null or undefined renders nothing. */
59
+ /** The Markdown text. An empty string, null, or undefined renders nothing. */
59
60
  children: string | null | undefined;
60
- /** Replaces the default element for a tag, such as `{ p: MyParagraph }`. It gets the props without `node`. */
61
+ /** The elements that replace the default element for a tag, such as `{ p: MyParagraph }`. Each gets the props without `node`. */
61
62
  components?: Components | undefined;
62
- /** Opens links to other sites in a new tab, without passing the opener or referrer. */
63
+ /** Whether links to other sites open in a new tab, without passing the opener or referrer. Defaults to `false`. */
63
64
  externalLinks?: boolean | undefined;
64
- /** Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. */
65
+ /** The classes for a `div` around the output. Without it, the elements render bare, so a parent's `space-y-*` spaces them. */
65
66
  className?: string | undefined;
66
67
  }
67
68
 
68
69
  /**
69
- * Markdown from an editor or the API, with GitHub tables, lists and links. Raw HTML is never
70
- * rendered, and the output is sanitized, so the text cannot inject scripts, styles or event handlers.
70
+ * Renders Markdown from an editor or the API, with GitHub tables, lists, and links. Raw HTML is
71
+ * never rendered, and the output is sanitized, so the text cannot inject scripts, styles, or event
72
+ * handlers.
71
73
  */
72
74
  export function Markdown({ children, components, externalLinks = false, className }: MarkdownProps) {
73
75
  if (!children) return null;
@@ -1 +1 @@
1
- export { Markdown, markdownComponents, type MarkdownProps } from "./Markdown";
1
+ export { Markdown, type MarkdownProps } from "./Markdown";
@@ -2,23 +2,25 @@
2
2
  // them with `import "@sjolystinnovation/app-kit/next-auth";` in one of its own .d.ts files.
3
3
  import "next-auth";
4
4
  import "next-auth/jwt";
5
+ import type { SessionUser } from "./auth/createAuthOptions";
6
+ import type { SessionError } from "./session/expiry";
5
7
 
6
8
  declare module "next-auth" {
7
9
  interface Session {
8
- user: { id: string; name: string; email: string; roles: string[] };
10
+ user: SessionUser;
9
11
  accessToken: string;
10
- error?: string | undefined;
11
- absoluteExpiresAt?: number;
12
+ error?: SessionError | undefined;
13
+ absoluteExpiresAt?: number | undefined;
12
14
  }
13
15
  }
14
16
 
15
17
  declare module "next-auth/jwt" {
16
18
  interface JWT {
17
- accessToken?: string;
18
- refreshToken?: string;
19
- refreshAt?: number;
20
- loginAt?: number;
21
- user?: { id: string; name: string; email: string; roles: string[] };
22
- error?: string | undefined;
19
+ accessToken?: string | undefined;
20
+ refreshToken?: string | undefined;
21
+ refreshAt?: number | undefined;
22
+ loginAt?: number | undefined;
23
+ user?: SessionUser | undefined;
24
+ error?: SessionError | undefined;
23
25
  }
24
26
  }
@@ -0,0 +1,96 @@
1
+ /** A response header, as `next.config.ts` lists them. */
2
+ export interface Header {
3
+ key: string;
4
+ value: string;
5
+ }
6
+
7
+ /** Gets the API's origin, such as `https://api.example.com`. Returns `""` when the URL is missing or invalid. */
8
+ export function apiOrigin(apiUrl: string | undefined): string {
9
+ if (!apiUrl) return "";
10
+ try {
11
+ return new URL(apiUrl).origin;
12
+ } catch {
13
+ return "";
14
+ }
15
+ }
16
+
17
+ /** Gets the API's base URL with its path, such as `https://api.example.com/api`, without a trailing slash. Returns `""` when the URL is missing or invalid. */
18
+ export function apiBaseUrl(apiUrl: string | undefined): string {
19
+ if (!apiUrl) return "";
20
+ try {
21
+ return new URL(apiUrl).toString().replace(/\/$/, "");
22
+ } catch {
23
+ return "";
24
+ }
25
+ }
26
+
27
+ /** The Content-Security-Policy options for `defineAppConfig`. */
28
+ export interface CspOptions {
29
+ /** The app's API URL, usually `process.env.NEXT_PUBLIC_API_URL`. The policy allows fetches to its origin. */
30
+ apiUrl: string | undefined;
31
+ /** Whether this is a development build. A development build also allows `'unsafe-eval'`, which hot reload needs. */
32
+ dev: boolean;
33
+ /** Whether to also allow `ws:` and `wss:` to the API, for a WebSocket such as SignalR. */
34
+ websocket?: boolean | undefined;
35
+ /** Whether to also allow images from the API origin. You do not need it when images go through the content images rewrite. */
36
+ imgFromApi?: boolean | undefined;
37
+ /** The extra sources for `connect-src`. */
38
+ connectSrc?: readonly string[] | undefined;
39
+ /** The extra sources for `img-src`. */
40
+ imgSrc?: readonly string[] | undefined;
41
+ /** The sources for `frame-src`. Defaults to `'none'`, since no page embeds a frame. */
42
+ frameSrc?: readonly string[] | undefined;
43
+ /** The sources for `object-src`. Defaults to `'none'`, since no page embeds an object. */
44
+ objectSrc?: readonly string[] | undefined;
45
+ }
46
+
47
+ const sources = (list: readonly (string | false | undefined)[]) => list.filter(Boolean).join(" ");
48
+
49
+ /**
50
+ * Builds the Content-Security-Policy for an app's pages. Scripts allow `'unsafe-inline'`, because a
51
+ * nonce would make every statically rendered page dynamic.
52
+ */
53
+ export function contentSecurityPolicy(o: CspOptions): string {
54
+ const origin = apiOrigin(o.apiUrl);
55
+ const wsOrigin = o.websocket ? origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:") : "";
56
+ return [
57
+ "default-src 'self'",
58
+ o.dev ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" : "script-src 'self' 'unsafe-inline'",
59
+ "style-src 'self' 'unsafe-inline'",
60
+ `img-src ${sources(["'self'", o.imgFromApi && origin, "data:", "blob:", ...(o.imgSrc ?? [])])}`,
61
+ `connect-src ${sources(["'self'", origin, wsOrigin, ...(o.connectSrc ?? [])])}`,
62
+ `frame-src ${sources(o.frameSrc ?? ["'none'"])}`,
63
+ `object-src ${sources(o.objectSrc ?? ["'none'"])}`,
64
+ "font-src 'self'",
65
+ "base-uri 'self'",
66
+ "form-action 'self'",
67
+ "frame-ancestors 'none'",
68
+ "upgrade-insecure-requests",
69
+ ].join("; ");
70
+ }
71
+
72
+ /** Builds the security headers for an app's pages, including the Content-Security-Policy. */
73
+ export function securityHeaders(o: CspOptions & { permissionsPolicy?: string | undefined }): Header[] {
74
+ return [
75
+ { key: "X-Content-Type-Options", value: "nosniff" },
76
+ { key: "X-Frame-Options", value: "DENY" },
77
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
78
+ { key: "X-DNS-Prefetch-Control", value: "on" },
79
+ { key: "Permissions-Policy", value: o.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()" },
80
+ { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
81
+ { key: "Content-Security-Policy", value: contentSecurityPolicy(o) },
82
+ ];
83
+ }
84
+
85
+ /**
86
+ * Builds the rewrite that proxies content images, so the browser never contacts the API host. It is
87
+ * a rewrite rather than a route handler, so the query string and the Accept header, which webp
88
+ * needs, pass through. Returns no rewrite when the API URL is missing or invalid.
89
+ */
90
+ export function contentImagesRewrite(apiUrl: string | undefined, o: { source?: string | undefined; upstreamPath?: string | undefined } = {}) {
91
+ const base = apiBaseUrl(apiUrl);
92
+ if (!base) return [];
93
+ const source = o.source ?? "/content-images";
94
+ const upstream = o.upstreamPath ?? "/content-images";
95
+ return [{ source: `${source}/:path*`, destination: `${base}${upstream}/:path*` }];
96
+ }
@@ -1,128 +1,39 @@
1
1
  import type { NextConfig } from "next";
2
+ import { contentImagesRewrite, securityHeaders, type CspOptions, type Header } from "./headers";
2
3
 
3
- /** A response header, as `next.config.ts` lists them. */
4
- export interface Header {
5
- key: string;
6
- value: string;
7
- }
8
-
9
- /** The API's origin, such as `https://api.example.com`. Empty when the URL is missing or invalid. */
10
- export function apiOrigin(apiUrl: string | undefined): string {
11
- if (!apiUrl) return "";
12
- try {
13
- return new URL(apiUrl).origin;
14
- } catch {
15
- return "";
16
- }
17
- }
18
-
19
- /** The API's base URL with its path, such as `https://api.example.com/api`, without a trailing slash. Empty when missing or invalid. */
20
- export function apiBaseUrl(apiUrl: string | undefined): string {
21
- if (!apiUrl) return "";
22
- try {
23
- return new URL(apiUrl).toString().replace(/\/$/, "");
24
- } catch {
25
- return "";
26
- }
27
- }
28
-
29
- export interface CspOptions {
30
- /** The app's API URL, usually `process.env.NEXT_PUBLIC_API_URL`. Its origin may be fetched from. */
31
- apiUrl: string | undefined;
32
- /** Development builds also allow `'unsafe-eval'`, which hot reload needs. */
33
- dev: boolean;
34
- /** Also allow `ws:` and `wss:` to the API, for a WebSocket such as SignalR. */
35
- websocket?: boolean | undefined;
36
- /** Also allow images from the API origin. Not needed when images go through the content images rewrite. */
37
- imgFromApi?: boolean | undefined;
38
- /** Extra sources for `connect-src`. */
39
- connectSrc?: readonly string[] | undefined;
40
- /** Extra sources for `img-src`. */
41
- imgSrc?: readonly string[] | undefined;
42
- /** Sources for `frame-src`. Defaults to `'none'`, since no page embeds a frame. */
43
- frameSrc?: readonly string[] | undefined;
44
- /** Sources for `object-src`. Defaults to `'none'`, since no page embeds an object. */
45
- objectSrc?: readonly string[] | undefined;
46
- }
47
-
48
- const sources = (list: readonly (string | false | undefined)[]) => list.filter(Boolean).join(" ");
49
-
50
- /**
51
- * The Content-Security-Policy for an app's pages. Scripts allow `'unsafe-inline'`, because a nonce
52
- * would make every statically rendered page dynamic.
53
- */
54
- export function contentSecurityPolicy(o: CspOptions): string {
55
- const origin = apiOrigin(o.apiUrl);
56
- const wsOrigin = o.websocket ? origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:") : "";
57
- return [
58
- "default-src 'self'",
59
- o.dev ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" : "script-src 'self' 'unsafe-inline'",
60
- "style-src 'self' 'unsafe-inline'",
61
- `img-src ${sources(["'self'", o.imgFromApi && origin, "data:", "blob:", ...(o.imgSrc ?? [])])}`,
62
- `connect-src ${sources(["'self'", origin, wsOrigin, ...(o.connectSrc ?? [])])}`,
63
- `frame-src ${sources(o.frameSrc ?? ["'none'"])}`,
64
- `object-src ${sources(o.objectSrc ?? ["'none'"])}`,
65
- "font-src 'self'",
66
- "base-uri 'self'",
67
- "form-action 'self'",
68
- "frame-ancestors 'none'",
69
- "upgrade-insecure-requests",
70
- ].join("; ");
71
- }
72
-
73
- /** The security headers for an app's pages, including the Content-Security-Policy. */
74
- export function securityHeaders(o: CspOptions & { permissionsPolicy?: string | undefined }): Header[] {
75
- return [
76
- { key: "X-Content-Type-Options", value: "nosniff" },
77
- { key: "X-Frame-Options", value: "DENY" },
78
- { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
79
- { key: "X-DNS-Prefetch-Control", value: "on" },
80
- { key: "Permissions-Policy", value: o.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()" },
81
- { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
82
- { key: "Content-Security-Policy", value: contentSecurityPolicy(o) },
83
- ];
84
- }
85
-
86
- /**
87
- * Content images, proxied so the browser never contacts the API host. A rewrite rather than a route
88
- * handler, so the query string and the Accept header, which webp needs, pass through. No rewrite
89
- * when the API URL is missing or invalid.
90
- */
91
- export function contentImagesRewrite(apiUrl: string | undefined, o: { source?: string | undefined; upstreamPath?: string | undefined } = {}) {
92
- const base = apiBaseUrl(apiUrl);
93
- if (!base) return [];
94
- const source = o.source ?? "/content-images";
95
- const upstream = o.upstreamPath ?? "/content-images";
96
- return [{ source: `${source}/:path*`, destination: `${base}${upstream}/:path*` }];
97
- }
4
+ export type { CspOptions } from "./headers";
98
5
 
6
+ /** Options for `defineAppConfig`. */
99
7
  export interface AppConfigOptions extends CspOptions {
100
- /** Proxy `/content-images/*` to the API. See `contentImagesRewrite`. */
8
+ /**
9
+ * Whether to proxy `/content-images/*` to the API. The rewrite keeps the browser off the API host
10
+ * and passes the query string and the Accept header through.
11
+ */
101
12
  contentImages?: boolean | undefined;
102
13
  /**
103
- * Headers for particular paths. Every path gets the page headers, and these come after them, so
104
- * they replace a page header of the same name.
14
+ * The headers for particular paths. Every path gets the page headers, and these come after them,
15
+ * so they replace a page header of the same name.
105
16
  */
106
17
  pathHeaders?: readonly { source: string; headers: Header[] }[] | undefined;
107
- /** Replaces the default Permissions-Policy. */
18
+ /** A Permissions-Policy that replaces the default one. */
108
19
  permissionsPolicy?: string | undefined;
109
20
  /**
110
- * Seconds a cache may go on serving a page after it goes stale. Next's default is a year, long
111
- * enough for a browser to hand back an old page and fetch the current one behind it. Defaults to
112
- * 300, matching the client router cache's stale time.
21
+ * The number of seconds a cache may go on serving a page after it goes stale. Next's default is a
22
+ * year, long enough for a browser to hand back an old page and fetch the current one behind it.
23
+ * Defaults to `300`, matching the client router cache's stale time.
113
24
  */
114
25
  expireTime?: number | undefined;
115
26
  /**
116
- * Write revalidated pages to disk. Off by default: with a read-only root filesystem Next cannot
117
- * write to `.next/server/app`, so the incremental cache stays in memory.
27
+ * Whether to write revalidated pages to disk. Defaults to `false`. With a read-only root
28
+ * filesystem, Next cannot write to `.next/server/app`, so the incremental cache stays in memory.
118
29
  */
119
30
  isrFlushToDisk?: boolean | undefined;
120
31
  }
121
32
 
122
33
  /**
123
- * The shared Next config for an app: the standalone build, app-kit transpiled, no `X-Powered-By`,
124
- * the security headers on every page, and optionally the content images rewrite. `extra` is merged
125
- * over it, with `transpilePackages` and `images` combined.
34
+ * Builds the shared Next config for an app: the standalone build, app-kit transpiled, no
35
+ * `X-Powered-By`, the security headers on every page, and optionally the content images rewrite.
36
+ * `extra` is merged over it, with `transpilePackages` and `images` combined.
126
37
  */
127
38
  export function defineAppConfig(o: AppConfigOptions, extra: NextConfig = {}): NextConfig {
128
39
  const pageHeaders = securityHeaders(o);
@@ -5,32 +5,34 @@ import Link from "next/link";
5
5
  import type { ReactNode } from "react";
6
6
  import { cx } from "../ui/cx";
7
7
 
8
+ /** The outcome of a payment, as `PaymentReturn` shows it. */
8
9
  export type PaymentReturnState = "success" | "pending" | "failed";
9
10
 
10
- /** The text and the way on for one state. */
11
+ /** The text and the next step for one state. */
11
12
  export interface PaymentReturnContent {
12
13
  title: string;
13
14
  body: string;
14
- /** Where the button goes. */
15
+ /** The button's link target. */
15
16
  href: string;
16
17
  /** The button's text. */
17
18
  cta: string;
18
19
  /** A smaller line under the body. */
19
20
  note?: string | undefined;
20
- /** Anything more under the body, such as a receipt link. */
21
+ /** Any more content under the body, such as a receipt link. */
21
22
  extra?: ReactNode;
22
23
  }
23
24
 
25
+ /** Props for `PaymentReturn`. */
24
26
  export interface PaymentReturnProps {
25
27
  state: PaymentReturnState;
26
28
  success: PaymentReturnContent;
27
29
  pending: PaymentReturnContent;
28
30
  failed: PaymentReturnContent;
29
- /** Checks the status again. Shows a button for it while pending. */
31
+ /** A function that checks the status again. While the state is pending, a button runs it. */
30
32
  onRefresh?: (() => void) | undefined;
31
33
  /** The refresh button's text. Defaults to `Check again`. */
32
34
  refreshLabel?: string | undefined;
33
- /** Classes for placing it, on the outer element. */
35
+ /** The classes for placing the component, on its outer element. */
34
36
  className?: string | undefined;
35
37
  }
36
38
 
@@ -42,8 +44,8 @@ const refresh = "px-4 py-2.5 rounded-payment-button bg-button-primary-bg/80 text
42
44
  const secondary = "py-2.5 rounded-payment-button bg-payment-secondary-bg hover:bg-payment-secondary-hover-bg text-payment-secondary-text text-sm transition-colors";
43
45
 
44
46
  /**
45
- * What a user sees on coming back from a payment provider: confirmed, still pending, or failed.
46
- * The app decides the state, such as with `usePollUntilFinal`.
47
+ * Renders what a user sees on coming back from a payment provider: confirmed, still pending, or
48
+ * failed. The app decides the state, such as with `usePollUntilFinal`.
47
49
  */
48
50
  export function PaymentReturn({ state, success, pending, failed, onRefresh, refreshLabel = "Check again", className }: PaymentReturnProps) {
49
51
  const content = { success, pending, failed }[state];
@@ -1,4 +1,4 @@
1
- /** An amount in øre as Norwegian kroner, such as `NOK 373,75`. */
1
+ /** Formats an amount in øre as Norwegian kroner, such as `NOK 373,75`. */
2
2
  export function formatNok(ore: number): string {
3
3
  return `NOK ${(ore / 100).toFixed(2).replace(".", ",")}`;
4
4
  }
@@ -2,26 +2,28 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
 
5
+ /** What `usePollUntilFinal` returns. */
5
6
  export interface PollResult<T> {
6
7
  /** The latest value, or null before the first answer. */
7
8
  data: T | null;
8
- /** True until a final value, the last retry or a failed fetch. */
9
+ /** True until a final value, the last retry, or a failed fetch. */
9
10
  loading: boolean;
10
11
  /** Polls again from the start, with the spinner showing. */
11
12
  refresh: () => void;
12
13
  }
13
14
 
15
+ /** Options for `usePollUntilFinal`. */
14
16
  export interface PollOptions {
15
- /** How many more times to fetch after the first. Defaults to 8. */
17
+ /** The number of fetches after the first. Defaults to `8`. */
16
18
  retries?: number | undefined;
17
- /** Milliseconds between fetches. Defaults to 4000. */
19
+ /** The milliseconds between fetches. Defaults to `4000`. */
18
20
  delayMs?: number | undefined;
19
21
  }
20
22
 
21
23
  /**
22
24
  * Fetches until the value is final, such as an order a payment provider has not confirmed yet when
23
- * the user comes back from it. Stops after the retries, on a failed fetch and on unmount. Each fetch
24
- * uses the latest `fetcher` and `isFinal` given.
25
+ * the user comes back from it. Stops after the retries, on a failed fetch, and on unmount. Each
26
+ * fetch uses the latest `fetcher` and `isFinal` given.
25
27
  */
26
28
  export function usePollUntilFinal<T>(
27
29
  fetcher: () => Promise<T | null>,
@@ -1,6 +1,6 @@
1
1
  import { serializeGraph, type SchemaNode } from "./schema";
2
2
 
3
- /** One JSON-LD block. Search engines merge every block on a page into one graph. */
3
+ /** Renders one JSON-LD block. Search engines merge every block on a page into one graph. */
4
4
  export function JsonLd({ nodes }: { nodes: SchemaNode[] }) {
5
5
  if (nodes.length === 0) return null;
6
6
 
@@ -1,9 +1,9 @@
1
- /** Values of `SITE_ENV` that name the live site. `prod` is what the deploy workflows pass. */
1
+ /** The values of `SITE_ENV` that name the live site. The deploy workflows pass `prod`. */
2
2
  const PRODUCTION = new Set(["production", "prod", ""]);
3
3
 
4
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.
5
+ * Checks whether this deployment is the one search engines should index. An unset `SITE_ENV` counts
6
+ * as production, so a missing variable is never what deindexes the live site.
7
7
  */
8
8
  export function isIndexableEnvironment(siteEnv: string | undefined = process.env.SITE_ENV): boolean {
9
9
  return PRODUCTION.has(siteEnv?.trim().toLowerCase() ?? "");
package/src/seo/index.ts CHANGED
@@ -3,10 +3,11 @@ export { JsonLd } from "./JsonLd";
3
3
  export { ref, serializeGraph, type OrganizationInfo, type SchemaNode, type SchemaRef } from "./schema";
4
4
  export {
5
5
  defineSite,
6
- normalizePath,
6
+ LEGAL_PAGE_KEYS,
7
7
  NOINDEX,
8
8
  SHARE_IMAGE_SIZE,
9
9
  type Crumb,
10
+ type LegalEntriesInput,
10
11
  type LocaleEntryOptions,
11
12
  type OrganizationOptions,
12
13
  type PageMetadataInput,
@@ -3,20 +3,21 @@ import { SHARE_IMAGE_SIZE } from "../site";
3
3
 
4
4
  export { SHARE_IMAGE_SIZE };
5
5
 
6
+ /** Options for `shareImage`. */
6
7
  export interface ShareImageInput {
7
8
  name: string;
8
9
  tagline: string;
9
10
  /**
10
- * Colours as literals. The image renders outside the browser, so it cannot read CSS custom
11
+ * The colors as literals. The image renders outside the browser, so it cannot read CSS custom
11
12
  * properties. `primary` is the bar above the name.
12
13
  */
13
14
  colors: { background: string; foreground: string; primary: string };
14
15
  }
15
16
 
16
17
  /**
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.
18
+ * Renders the default share preview: the name, a bar in the brand color, and the tagline. Return it
19
+ * from a route such as `[locale]/og/route.tsx`, which `pageMetadata` points at. It is a route, not
20
+ * the `opengraph-image` file convention, since a page's own `openGraph` block replaces that image.
20
21
  */
21
22
  export function shareImage({ name, tagline, colors }: ShareImageInput): ImageResponse {
22
23
  return new ImageResponse(
package/src/seo/schema.ts CHANGED
@@ -10,9 +10,10 @@ export interface SchemaRef {
10
10
  "@id": string;
11
11
  }
12
12
 
13
+ /** Creates a reference to the node with this `@id`. */
13
14
  export const ref = (id: string): SchemaRef => ({ "@id": id });
14
15
 
15
- /** The JSON-LD document for one page. `<` is escaped, so the payload cannot close the script tag. */
16
+ /** Serializes the JSON-LD document for one page. `<` is escaped, so the payload cannot close the script tag. */
16
17
  export function serializeGraph(nodes: SchemaNode[]): string {
17
18
  return JSON.stringify({
18
19
  "@context": "https://schema.org",
@@ -20,7 +21,10 @@ export function serializeGraph(nodes: SchemaNode[]): string {
20
21
  }).replace(/</g, "\\u003c");
21
22
  }
22
23
 
23
- /** The business as the API describes it. Blank fields are left out of the structured data. */
24
+ /**
25
+ * The business as the API describes it. The structured data leaves out blank fields, except the
26
+ * street address, which falls back to the one-line address.
27
+ */
24
28
  export interface OrganizationInfo {
25
29
  name: string;
26
30
  legalName?: string | undefined;