@sjolystinnovation/app-kit 0.11.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 CHANGED
@@ -28,7 +28,7 @@ const nextConfig: NextConfig = {
28
28
  };
29
29
  ```
30
30
 
31
- The components in `@sjolystinnovation/app-kit/ui` and `session/react` are styled with Tailwind CSS.
31
+ The components in `@sjolystinnovation/app-kit/ui`, `markdown` and `session/react` are styled with Tailwind CSS.
32
32
  Import the package's stylesheet after Tailwind:
33
33
 
34
34
  ```css
@@ -348,6 +348,118 @@ The pieces are exported too: `securityHeaders(options)`, `contentSecurityPolicy(
348
348
  `apiBaseUrl(apiUrl)`. Scripts allow `'unsafe-inline'`, since a nonce would make every statically
349
349
  rendered page dynamic.
350
350
 
351
+ ### `@sjolystinnovation/app-kit/markdown`
352
+
353
+ Renders Markdown from an editor or the API, with GitHub tables, task lists and footnotes. Raw HTML is
354
+ never rendered and the output is sanitized, so the text cannot inject scripts, styles, event
355
+ handlers or `javascript:` links. It works in a server component. It needs `react-markdown`,
356
+ `remark-gfm` and `rehype-sanitize`, which are optional peer dependencies:
357
+
358
+ ```sh
359
+ npm install react-markdown remark-gfm rehype-sanitize
360
+ ```
361
+
362
+ ```tsx
363
+ import { Markdown } from "@sjolystinnovation/app-kit/markdown";
364
+
365
+ <div className="space-y-4 text-sm text-gray-700">
366
+ <Markdown>{build.description}</Markdown>
367
+ </div>;
368
+ ```
369
+
370
+ | Prop | Type | Default | Description |
371
+ | --- | --- | --- | --- |
372
+ | `children` | `string \| null \| undefined` | | The Markdown. Empty, null or undefined renders nothing. |
373
+ | `components` | `Components` | | Replaces the element for a tag, such as `{ p: MyParagraph }`. Tags not named keep the default. It gets the props without `node`, so it can spread them onto an element. |
374
+ | `externalLinks` | `boolean` | `false` | Opens `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
375
+ | `className` | `string` | | Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. |
376
+
377
+ The text takes its colour and size from the parent. A level one heading becomes a level two, since
378
+ the page has its own `h1`. `markdownComponents` holds the default elements, for an app that builds
379
+ on them.
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
+
351
463
  ### `@sjolystinnovation/app-kit/session`
352
464
 
353
465
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
@@ -836,6 +948,11 @@ selector such as `.dark` for a scoped theme:
836
948
  | `--color-state-code` | gray 900, the code of a `NotFoundState` |
837
949
  | `--font-state-code` | `inherit`, the font of that code |
838
950
  | `--color-state-link`, `-hover` | gray 900, the home link |
951
+ | `--color-markdown-heading`, `-strong` | gray 900, headings and bold text in `Markdown` |
952
+ | `--color-markdown-link` | gray 900 |
953
+ | `--color-markdown-link-hover` | gray 600 |
954
+ | `--color-markdown-border` | gray 200, rules, quotes and table lines |
955
+ | `--color-markdown-code-bg`, `-code-text` | gray 100, gray 900 |
839
956
 
840
957
  A default that points at another variable is read once for the whole page. The focus ring and the
841
958
  primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.11.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",
@@ -26,6 +26,9 @@
26
26
  "./toast": "./src/toast/index.ts",
27
27
  "./validation": "./src/validation/index.ts",
28
28
  "./next-config": "./src/next-config/index.ts",
29
+ "./markdown": "./src/markdown/index.ts",
30
+ "./seo": "./src/seo/index.ts",
31
+ "./seo/og": "./src/seo/og/index.tsx",
29
32
  "./styles.css": "./src/styles.css"
30
33
  },
31
34
  "files": [
@@ -46,6 +49,9 @@
46
49
  "next-auth": "^4.24",
47
50
  "react": "^19",
48
51
  "react-dom": "^19",
52
+ "react-markdown": "^10",
53
+ "rehype-sanitize": "^6",
54
+ "remark-gfm": "^4",
49
55
  "sonner": "^2",
50
56
  "zod": "^4"
51
57
  },
@@ -55,6 +61,15 @@
55
61
  },
56
62
  "zod": {
57
63
  "optional": true
64
+ },
65
+ "react-markdown": {
66
+ "optional": true
67
+ },
68
+ "rehype-sanitize": {
69
+ "optional": true
70
+ },
71
+ "remark-gfm": {
72
+ "optional": true
58
73
  }
59
74
  },
60
75
  "devDependencies": {
@@ -71,6 +86,9 @@
71
86
  "next-auth": "^4.24.15",
72
87
  "react": "^19.3.0",
73
88
  "react-dom": "^19.3.0",
89
+ "react-markdown": "^10.1.0",
90
+ "rehype-sanitize": "^6.0.0",
91
+ "remark-gfm": "^4.0.1",
74
92
  "sonner": "^2.0.8",
75
93
  "typescript": "^6.0.3",
76
94
  "vitest": "^5.0.3",
@@ -85,7 +103,7 @@
85
103
  "tailwindcss"
86
104
  ],
87
105
  "dependencies": {
88
- "@types/jsonwebtoken": "^9.0.8",
89
- "jsonwebtoken": "^9.0.2"
106
+ "@types/jsonwebtoken": "^9.0.10",
107
+ "jsonwebtoken": "^9.0.3"
90
108
  }
91
109
  }
@@ -0,0 +1,89 @@
1
+ import type { ComponentPropsWithoutRef, ElementType } from "react";
2
+ import ReactMarkdown, { type Components, type ExtraProps } from "react-markdown";
3
+ import rehypeSanitize from "rehype-sanitize";
4
+ import remarkGfm from "remark-gfm";
5
+
6
+ type Tag = keyof React.JSX.IntrinsicElements;
7
+
8
+ /** The props without the `node` react-markdown passes, which is no DOM attribute. */
9
+ function domProps<P extends ExtraProps>(props: P): Omit<P, "node"> {
10
+ const rest = { ...props };
11
+ delete rest.node;
12
+ return rest;
13
+ }
14
+
15
+ /** An app's element, given the props without `node`, so it can spread them onto a DOM element. */
16
+ function withoutNode(Element: ElementType) {
17
+ const Wrapped = (props: ExtraProps) => <Element {...domProps(props)} />;
18
+ return Wrapped;
19
+ }
20
+
21
+ /** An element with fixed classes. */
22
+ function styled<T extends Tag>(Element: T, className: string) {
23
+ const Styled = (props: ComponentPropsWithoutRef<T> & ExtraProps) => {
24
+ const Component = Element as ElementType;
25
+ return <Component className={className} {...domProps(props)} />;
26
+ };
27
+ return Styled;
28
+ }
29
+
30
+ /** The default look. A heading level one becomes a level two, since the page has its own `h1`. */
31
+ export const markdownComponents: Components = {
32
+ h1: styled("h2", "text-lg font-semibold text-markdown-heading"),
33
+ h2: styled("h2", "text-base font-semibold text-markdown-heading pt-2"),
34
+ h3: styled("h3", "text-sm font-semibold text-markdown-heading"),
35
+ p: styled("p", "leading-relaxed"),
36
+ ul: styled("ul", "list-disc list-outside pl-5 space-y-1"),
37
+ ol: styled("ol", "list-decimal list-outside pl-5 space-y-1"),
38
+ li: styled("li", "leading-relaxed"),
39
+ a: styled("a", "font-medium text-markdown-link underline hover:text-markdown-link-hover"),
40
+ strong: styled("strong", "font-semibold text-markdown-strong"),
41
+ hr: styled("hr", "border-markdown-border"),
42
+ blockquote: styled("blockquote", "border-l-2 border-markdown-border pl-4 italic"),
43
+ code: styled("code", "rounded bg-markdown-code-bg px-1 py-0.5 text-xs font-mono text-markdown-code-text"),
44
+ table: props => (
45
+ <div className="overflow-x-auto">
46
+ <table className="w-full text-xs border-collapse" {...domProps(props)} />
47
+ </div>
48
+ ),
49
+ thead: styled("thead", "border-b border-markdown-border"),
50
+ tbody: styled("tbody", "divide-y divide-markdown-border"),
51
+ th: styled("th", "text-left py-2 pr-4 font-semibold text-markdown-heading"),
52
+ td: styled("td", "py-2 pr-4 align-top"),
53
+ };
54
+
55
+ const isExternal = (href: string | undefined) => href != null && /^https?:\/\//i.test(href);
56
+
57
+ export interface MarkdownProps {
58
+ /** Markdown text. Empty, null or undefined renders nothing. */
59
+ children: string | null | undefined;
60
+ /** Replaces the default element for a tag, such as `{ p: MyParagraph }`. It gets the props without `node`. */
61
+ components?: Components | undefined;
62
+ /** Opens links to other sites in a new tab, without passing the opener or referrer. */
63
+ 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
+ className?: string | undefined;
66
+ }
67
+
68
+ /**
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.
71
+ */
72
+ export function Markdown({ children, components, externalLinks = false, className }: MarkdownProps) {
73
+ if (!children) return null;
74
+
75
+ const own = Object.entries(components ?? {}).map(([tag, Element]) => [tag, withoutNode(Element as ElementType)]);
76
+ const merged: Components = { ...markdownComponents, ...Object.fromEntries(own) };
77
+ if (externalLinks) {
78
+ const Link = (merged.a ?? "a") as ElementType;
79
+ merged.a = props =>
80
+ isExternal(props.href) ? <Link {...props} target="_blank" rel="noopener noreferrer" /> : <Link {...props} />;
81
+ }
82
+
83
+ const output = (
84
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]} components={merged}>
85
+ {children}
86
+ </ReactMarkdown>
87
+ );
88
+ return className === undefined ? output : <div className={className}>{output}</div>;
89
+ }
@@ -0,0 +1 @@
1
+ export { Markdown, markdownComponents, type MarkdownProps } from "./Markdown";
@@ -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
+ }
package/src/styles.css CHANGED
@@ -52,6 +52,14 @@
52
52
  --color-state-link: var(--color-gray-900);
53
53
  --color-state-link-hover: var(--color-gray-900);
54
54
 
55
+ --color-markdown-heading: var(--color-gray-900);
56
+ --color-markdown-strong: var(--color-gray-900);
57
+ --color-markdown-link: var(--color-gray-900);
58
+ --color-markdown-link-hover: var(--color-gray-600);
59
+ --color-markdown-border: var(--color-gray-200);
60
+ --color-markdown-code-bg: var(--color-gray-100);
61
+ --color-markdown-code-text: var(--color-gray-900);
62
+
55
63
  --radius-button: var(--radius-lg);
56
64
  --color-button-primary-bg: var(--color-primary);
57
65
  --color-button-primary-text: var(--color-primary-foreground);