@bygghammar/cms-platform 0.60.1 → 0.61.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/CHANGELOG.md +8 -0
- package/package.json +3 -1
- package/src/site/host-indexing-response.ts +83 -0
- package/src/site/host-indexing.ts +152 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.61.0
|
|
4
|
+
|
|
5
|
+
- New `site/host-indexing` and `site/host-indexing-response`: the hostname-based
|
|
6
|
+
indexing policy (live-hostname + configured-origin condition, robots directive,
|
|
7
|
+
three-state robots meta tags and the `X-Robots-Tag` response wrapper) now lives
|
|
8
|
+
in the platform. Sites supply only their live hostname via
|
|
9
|
+
`createHostIndexingPolicy()` / `createHostIndexingResponsePolicy()`.
|
|
10
|
+
|
|
3
11
|
## 0.60.1
|
|
4
12
|
|
|
5
13
|
- 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.
|
|
3
|
+
"version": "0.61.0",
|
|
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,83 @@
|
|
|
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
|
+
import { configuredPublicOrigin } from "./public-origin";
|
|
11
|
+
|
|
12
|
+
export { NOINDEX_VALUE };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The host the request arrived on, taken from the URL the server received and
|
|
16
|
+
* never from `X-Forwarded-Host` — that header is supplied by whoever is talking
|
|
17
|
+
* to us. An unparseable URL yields null, which fails to noindex.
|
|
18
|
+
*/
|
|
19
|
+
function requestHostname(request: Request): string | null {
|
|
20
|
+
try {
|
|
21
|
+
return new URL(request.url).hostname;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Adds `X-Robots-Tag` unless this deployment is the live site answering on the
|
|
29
|
+
* live hostname.
|
|
30
|
+
*
|
|
31
|
+
* Applied AFTER the shared header helper, because that helper sets its own
|
|
32
|
+
* headers and a value written before it could be replaced. The response is
|
|
33
|
+
* rebuilt with the same body, status and statusText, so streaming, redirects and
|
|
34
|
+
* error pages are unaffected — only the header set changes.
|
|
35
|
+
*
|
|
36
|
+
* `configuredOrigin` defaults to this deployment's own configuration; it is a
|
|
37
|
+
* parameter so tests can state a deployment's configuration directly instead of
|
|
38
|
+
* describing one.
|
|
39
|
+
*/
|
|
40
|
+
export function applyHostIndexingPolicy(
|
|
41
|
+
liveHostname: string,
|
|
42
|
+
request: Request,
|
|
43
|
+
response: Response,
|
|
44
|
+
configuredOrigin: string | null | undefined = configuredPublicOrigin(),
|
|
45
|
+
): Response {
|
|
46
|
+
// A 5xx is the server saying it failed. Whatever it returned is not this
|
|
47
|
+
// site's content, on any deployment — including the live one, where the
|
|
48
|
+
// deployment check correctly adds nothing to ordinary pages. Without this, a
|
|
49
|
+
// root loader that throws on the live host is served as indexable.
|
|
50
|
+
const directive =
|
|
51
|
+
response.status >= 500
|
|
52
|
+
? NOINDEX_VALUE
|
|
53
|
+
: robotsDirectiveForHost(liveHostname, configuredOrigin, requestHostname(request));
|
|
54
|
+
if (!directive) return response;
|
|
55
|
+
|
|
56
|
+
// The stricter policy wins.
|
|
57
|
+
//
|
|
58
|
+
// The platform applies its own environment indexing policy inside
|
|
59
|
+
// applyEdgeHeaders (SITE_INDEXING / SITE_ENV), which never looks at the
|
|
60
|
+
// request host. It runs first, and its value is stricter than this one, so an
|
|
61
|
+
// existing noindex is left exactly as it is rather than replaced by a shorter
|
|
62
|
+
// directive. A header that is NOT a noindex is replaced: a permissive value is
|
|
63
|
+
// not something to preserve.
|
|
64
|
+
const existing = response.headers.get("X-Robots-Tag");
|
|
65
|
+
if (existing && existing.toLowerCase().includes("noindex")) return response;
|
|
66
|
+
|
|
67
|
+
const headers = new Headers(response.headers);
|
|
68
|
+
headers.set("X-Robots-Tag", directive);
|
|
69
|
+
return new Response(response.body, {
|
|
70
|
+
status: response.status,
|
|
71
|
+
statusText: response.statusText,
|
|
72
|
+
headers,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The response helper bound to one site's live hostname. */
|
|
77
|
+
export function createHostIndexingResponsePolicy(liveHostname: string) {
|
|
78
|
+
return (
|
|
79
|
+
request: Request,
|
|
80
|
+
response: Response,
|
|
81
|
+
configuredOrigin: string | null | undefined = configuredPublicOrigin(),
|
|
82
|
+
) => applyHostIndexingPolicy(liveHostname, request, response, configuredOrigin);
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|