@bygghammar/cms-platform 0.60.1 → 0.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.61.1
4
+
5
+ - `site/host-indexing-response` takes the configured origin from the site instead
6
+ of importing a non-existent default, so the module type-checks standalone.
7
+
8
+ ## 0.61.0
9
+
10
+ - New `site/host-indexing` and `site/host-indexing-response`: the hostname-based
11
+ indexing policy (live-hostname + configured-origin condition, robots directive,
12
+ three-state robots meta tags and the `X-Robots-Tag` response wrapper) now lives
13
+ in the platform. Sites supply only their live hostname via
14
+ `createHostIndexingPolicy()` / `createHostIndexingResponsePolicy()`.
15
+
3
16
  ## 0.60.1
4
17
 
5
18
  - Drift manifest no longer lists site backoffice route files: sites reach the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.60.1",
3
+ "version": "0.61.1",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -208,6 +208,8 @@
208
208
  "./server/media-usage-host": "./src/server/media-usage-host.ts",
209
209
  "./runtime/server-entry": "./src/runtime/server-entry.ts",
210
210
  "./site/indexing": "./src/site/indexing.ts",
211
+ "./site/host-indexing": "./src/site/host-indexing.ts",
212
+ "./site/host-indexing-response": "./src/site/host-indexing-response.ts",
211
213
  "./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx",
212
214
  "./util/use-hash-scroll": "./src/util/use-hash-scroll.ts",
213
215
  "./site/site-url": "./src/site/site-url.ts",
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Marking a response that must not be indexed.
3
+ *
4
+ * This lives in the platform so that the function a site's server entry calls is
5
+ * the function its tests import. A test that reimplements the server's logic
6
+ * passes whether or not the server still calls it, which makes it evidence about
7
+ * the test file and nothing else.
8
+ */
9
+ import { NOINDEX_VALUE, robotsDirectiveForHost } from "./host-indexing";
10
+
11
+ export { NOINDEX_VALUE };
12
+
13
+ /**
14
+ * The host the request arrived on, taken from the URL the server received and
15
+ * never from `X-Forwarded-Host` — that header is supplied by whoever is talking
16
+ * to us. An unparseable URL yields null, which fails to noindex.
17
+ */
18
+ function requestHostname(request: Request): string | null {
19
+ try {
20
+ return new URL(request.url).hostname;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Adds `X-Robots-Tag` unless this deployment is the live site answering on the
28
+ * live hostname.
29
+ *
30
+ * Applied AFTER the shared header helper, because that helper sets its own
31
+ * headers and a value written before it could be replaced. The response is
32
+ * rebuilt with the same body, status and statusText, so streaming, redirects and
33
+ * error pages are unaffected — only the header set changes.
34
+ *
35
+ * `configuredOrigin` is the deployment's own configuration, passed in by the
36
+ * site: the platform never guesses it, so a test can state a deployment's
37
+ * configuration directly instead of describing one.
38
+ */
39
+ export function applyHostIndexingPolicy(
40
+ liveHostname: string,
41
+ request: Request,
42
+ response: Response,
43
+ configuredOrigin: string | null | undefined,
44
+ ): Response {
45
+ // A 5xx is the server saying it failed. Whatever it returned is not this
46
+ // site's content, on any deployment — including the live one, where the
47
+ // deployment check correctly adds nothing to ordinary pages. Without this, a
48
+ // root loader that throws on the live host is served as indexable.
49
+ const directive =
50
+ response.status >= 500
51
+ ? NOINDEX_VALUE
52
+ : robotsDirectiveForHost(liveHostname, configuredOrigin, requestHostname(request));
53
+ if (!directive) return response;
54
+
55
+ // The stricter policy wins.
56
+ //
57
+ // The platform applies its own environment indexing policy inside
58
+ // applyEdgeHeaders (SITE_INDEXING / SITE_ENV), which never looks at the
59
+ // request host. It runs first, and its value is stricter than this one, so an
60
+ // existing noindex is left exactly as it is rather than replaced by a shorter
61
+ // directive. A header that is NOT a noindex is replaced: a permissive value is
62
+ // not something to preserve.
63
+ const existing = response.headers.get("X-Robots-Tag");
64
+ if (existing && existing.toLowerCase().includes("noindex")) return response;
65
+
66
+ const headers = new Headers(response.headers);
67
+ headers.set("X-Robots-Tag", directive);
68
+ return new Response(response.body, {
69
+ status: response.status,
70
+ statusText: response.statusText,
71
+ headers,
72
+ });
73
+ }
74
+
75
+ /** The response helper bound to one site's live hostname. */
76
+ export function createHostIndexingResponsePolicy(liveHostname: string) {
77
+ return (request: Request, response: Response, configuredOrigin: string | null | undefined) =>
78
+ applyHostIndexingPolicy(liveHostname, request, response, configuredOrigin);
79
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Hostname-based indexing policy.
3
+ *
4
+ * A deployment serves the same application on more hostnames than the live one:
5
+ * a temporary production-preview host, staging, and sandbox previews. Each is
6
+ * exactly as indexable as the live site unless the application says otherwise,
7
+ * so duplicate content on `site.prod.example.com` competes with the real domain
8
+ * for the same pages.
9
+ *
10
+ * The condition is positive and has two halves, both required:
11
+ *
12
+ * 1. this deployment is CONFIGURED as the live origin, and
13
+ * 2. the request actually arrived on the live hostname.
14
+ *
15
+ * Either half alone is not enough. A staging deployment misconfigured with the
16
+ * live origin still answers on the staging hostname; a request carrying a forged
17
+ * `Host` still reaches a deployment configured as something else.
18
+ *
19
+ * Everything else — a temporary host, staging, a preview sandbox, an unknown
20
+ * host, a missing or malformed configuration — is noindex. Deciding from a list
21
+ * of hosts to EXCLUDE means every host nobody thought of is indexable, which is
22
+ * how a preview domain ends up in search results.
23
+ *
24
+ * This module imports nothing on purpose: build configs load it directly, before
25
+ * any alias or browser global exists.
26
+ */
27
+
28
+ /** What a response that must not be indexed says. */
29
+ export const NOINDEX_VALUE = "noindex, nofollow, noarchive";
30
+
31
+ /**
32
+ * A hostname reduced to what can be compared: lowercased, port removed, one
33
+ * trailing dot removed.
34
+ *
35
+ * `SITE.SE`, `site.se:443` and `site.se.` are the same host. A suffix such as
36
+ * `evilsite.se` is not, and neither is the subdomain `site.se.example.com` —
37
+ * which is why this compares whole strings and never uses `endsWith`.
38
+ */
39
+ export function normalizeHostname(value: string | null | undefined): string | null {
40
+ if (typeof value !== "string") return null;
41
+ const trimmed = value.trim();
42
+ if (!trimmed) return null;
43
+ // A Host header may carry a port; IPv6 literals carry brackets.
44
+ const withoutPort = trimmed.startsWith("[")
45
+ ? trimmed.slice(0, trimmed.indexOf("]") + 1)
46
+ : trimmed.split(":")[0];
47
+ if (!withoutPort) return null;
48
+ const lowered = withoutPort.toLowerCase();
49
+ const withoutRootDot = lowered.endsWith(".") ? lowered.slice(0, -1) : lowered;
50
+ return withoutRootDot || null;
51
+ }
52
+
53
+ /**
54
+ * The hostname a configured origin names, but only when that origin is exactly
55
+ * an `https://` URL with nothing else in it.
56
+ *
57
+ * Credentials, a port, a path, a query or a fragment all mean the value is not
58
+ * simply "this deployment's origin", and a value that cannot be parsed is not
59
+ * one to draw conclusions from. Each returns null, which fails to noindex.
60
+ */
61
+ export function liveHostnameFromOrigin(origin: string | null | undefined): string | null {
62
+ if (typeof origin !== "string" || !origin.trim()) return null;
63
+ let url: URL;
64
+ try {
65
+ url = new URL(origin.trim());
66
+ } catch {
67
+ return null;
68
+ }
69
+ if (url.protocol !== "https:") return null;
70
+ if (url.username || url.password) return null;
71
+ if (url.port) return null;
72
+ if (url.pathname !== "/" || url.search || url.hash) return null;
73
+ return normalizeHostname(url.hostname);
74
+ }
75
+
76
+ /**
77
+ * The two-part condition.
78
+ *
79
+ * `configuredOrigin` is the deployment's own configuration; `requestHostname` is
80
+ * the host the request arrived on. Both must resolve to `liveHostname`.
81
+ */
82
+ export function isIndexableRequestForHost(
83
+ liveHostname: string,
84
+ configuredOrigin: string | null | undefined,
85
+ requestHostname: string | null | undefined,
86
+ ): boolean {
87
+ const live = normalizeHostname(liveHostname);
88
+ if (!live) return false;
89
+ if (liveHostnameFromOrigin(configuredOrigin) !== live) return false;
90
+ return normalizeHostname(requestHostname) === live;
91
+ }
92
+
93
+ /** The robots value this request should carry, whichever way it is expressed. */
94
+ export function robotsDirectiveForHost(
95
+ liveHostname: string,
96
+ configuredOrigin: string | null | undefined,
97
+ requestHostname: string | null | undefined,
98
+ ): string | null {
99
+ return isIndexableRequestForHost(liveHostname, configuredOrigin, requestHostname)
100
+ ? null
101
+ : NOINDEX_VALUE;
102
+ }
103
+
104
+ /**
105
+ * The robots meta tags a rendered page should carry.
106
+ *
107
+ * Three states, not two. `robots: null` is the loader having run and found the
108
+ * request indexable, which means no tag at all. Loader data missing entirely is
109
+ * the root loader having thrown — an error render, on a deployment that could
110
+ * not be identified — and that is a noindex for both reasons. Reading the missing
111
+ * case as "indexable" is how an error page reaches a search index.
112
+ */
113
+ export function robotsMetaTags(
114
+ loaderData: { robots?: string | null } | undefined,
115
+ ): Array<{ name: string; content: string }> {
116
+ if (!loaderData) return [{ name: "robots", content: NOINDEX_VALUE }];
117
+ return loaderData.robots ? [{ name: "robots", content: loaderData.robots }] : [];
118
+ }
119
+
120
+ export type HostIndexingPolicy = {
121
+ LIVE_HOSTNAME: string;
122
+ NOINDEX_VALUE: string;
123
+ normalizeHostname: typeof normalizeHostname;
124
+ liveHostnameFromOrigin: typeof liveHostnameFromOrigin;
125
+ isIndexableRequest: (
126
+ configuredOrigin: string | null | undefined,
127
+ requestHostname: string | null | undefined,
128
+ ) => boolean;
129
+ robotsDirective: (
130
+ configuredOrigin: string | null | undefined,
131
+ requestHostname: string | null | undefined,
132
+ ) => string | null;
133
+ robotsMetaTags: typeof robotsMetaTags;
134
+ };
135
+
136
+ /**
137
+ * The whole policy bound to one site's live hostname — the only site-specific
138
+ * fact in it.
139
+ */
140
+ export function createHostIndexingPolicy(liveHostname: string): HostIndexingPolicy {
141
+ return {
142
+ LIVE_HOSTNAME: liveHostname,
143
+ NOINDEX_VALUE,
144
+ normalizeHostname,
145
+ liveHostnameFromOrigin,
146
+ isIndexableRequest: (configuredOrigin, requestHostname) =>
147
+ isIndexableRequestForHost(liveHostname, configuredOrigin, requestHostname),
148
+ robotsDirective: (configuredOrigin, requestHostname) =>
149
+ robotsDirectiveForHost(liveHostname, configuredOrigin, requestHostname),
150
+ robotsMetaTags,
151
+ };
152
+ }