@bygghammar/cms-platform 0.50.0 → 0.51.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 +17 -0
- package/package.json +3 -2
- package/src/backoffice/ContactDetailsView.tsx +6 -5
- package/src/backoffice/SiteSettingsView.tsx +56 -22
- package/src/backoffice/routes/index.tsx +18 -1
- package/src/http/response-headers.ts +8 -0
- package/src/runtime/server-entry.ts +4 -10
- package/src/server/http/robots.ts +20 -0
- package/src/site/indexing.ts +55 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.51.0
|
|
4
|
+
|
|
5
|
+
- `site/indexing`: environment-derived search-engine policy (`SITE_INDEXING`,
|
|
6
|
+
falling back to `SITE_ENV`/`NODE_ENV`). Staging is blocked by default.
|
|
7
|
+
- `server/http/robots`: a blocked deployment serves `Disallow: /` with no
|
|
8
|
+
sitemap line and an `X-Robots-Tag` header.
|
|
9
|
+
- `http/response-headers`: adds `noindex, nofollow, noarchive, nosnippet,
|
|
10
|
+
noimageindex` to every response of a blocked deployment.
|
|
11
|
+
- `SiteSettingsView`: shows the read-only indexing status, and the company
|
|
12
|
+
fields are now a read-only summary that points at Settings -> Contact
|
|
13
|
+
details, which is the single editor for them.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## 0.50.1
|
|
17
|
+
|
|
18
|
+
- `runtime/server-entry`: the injection adapter is typed against `InjectionBundle`, matching the shared `injections-core` helpers exactly.
|
|
19
|
+
|
|
3
20
|
## 0.50.0
|
|
4
21
|
|
|
5
22
|
- New `runtime/server-entry`: `createServerEntry({ injections })` is the shared production fetch handler — framework entry, h3-swallowed-500 normalisation, backoffice code injection and edge caching/compression headers. A site's `src/server.ts` drops to one call.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.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": [
|
|
@@ -196,7 +196,8 @@
|
|
|
196
196
|
"./runtime/error-page": "./src/runtime/error-page.ts",
|
|
197
197
|
"./runtime/error-reporting": "./src/runtime/error-reporting.ts",
|
|
198
198
|
"./server/media-usage-host": "./src/server/media-usage-host.ts",
|
|
199
|
-
"./runtime/server-entry": "./src/runtime/server-entry.ts"
|
|
199
|
+
"./runtime/server-entry": "./src/runtime/server-entry.ts",
|
|
200
|
+
"./site/indexing": "./src/site/indexing.ts"
|
|
200
201
|
},
|
|
201
202
|
"dependencies": {
|
|
202
203
|
"fflate": "^0.8.3"
|
|
@@ -6,12 +6,12 @@ export type ContactField = { key: string; label: string; type?: string };
|
|
|
6
6
|
|
|
7
7
|
/** The canonical company detail fields every site shares. */
|
|
8
8
|
export const CONTACT_FIELDS: ContactField[] = [
|
|
9
|
-
{ key: "locationName", label: "
|
|
10
|
-
{ key: "street", label: "
|
|
9
|
+
{ key: "locationName", label: "Location name" },
|
|
10
|
+
{ key: "street", label: "Street" },
|
|
11
11
|
{ key: "postCode", label: "Post code" },
|
|
12
12
|
{ key: "city", label: "City" },
|
|
13
13
|
{ key: "country", label: "Country" },
|
|
14
|
-
{ key: "legalName", label: "
|
|
14
|
+
{ key: "legalName", label: "Legal name" },
|
|
15
15
|
{ key: "orgNumber", label: "Org. number" },
|
|
16
16
|
{ key: "email", label: "Email", type: "email" },
|
|
17
17
|
{ key: "phone", label: "Phone", type: "tel" },
|
|
@@ -68,8 +68,9 @@ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
|
|
|
68
68
|
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Settings</div>
|
|
69
69
|
<h2 className="mt-2 font-serif text-3xl text-ink">Contact details</h2>
|
|
70
70
|
<p className="mt-3 text-sm text-ink/65">
|
|
71
|
-
These company details are the single source of truth
|
|
72
|
-
section, the footer and the structured data
|
|
71
|
+
These company details are the single source of truth — site settings only shows them for
|
|
72
|
+
reference. They are used in the website contact section, the footer and the structured data
|
|
73
|
+
for search engines.
|
|
73
74
|
</p>
|
|
74
75
|
|
|
75
76
|
<div className="mt-10 grid gap-5 sm:grid-cols-2">
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type SiteFeatureFlags,
|
|
10
10
|
type SiteSettingOverrides,
|
|
11
11
|
} from "../config/site-config";
|
|
12
|
+
import type { IndexingPolicy } from "../site/indexing";
|
|
12
13
|
|
|
13
14
|
export type SiteSettingsDeps = {
|
|
14
15
|
/** Code config with any stored overrides already applied. */
|
|
@@ -23,6 +24,19 @@ export type SiteSettingsDeps = {
|
|
|
23
24
|
* set `SITE_URL` for this deployment; `"code"` means the compiled fallback.
|
|
24
25
|
*/
|
|
25
26
|
originSource?: "environment" | "code";
|
|
27
|
+
/**
|
|
28
|
+
* Indexing policy for the running deployment, resolved from the environment
|
|
29
|
+
* server-side (see `site/indexing`). Shown read-only: a staging host must not
|
|
30
|
+
* be able to open itself to search engines from the backoffice.
|
|
31
|
+
*/
|
|
32
|
+
indexing?: IndexingPolicy;
|
|
33
|
+
/**
|
|
34
|
+
* Live company values as stored by the contact details screen. Falls back to
|
|
35
|
+
* the config company when omitted.
|
|
36
|
+
*/
|
|
37
|
+
companyValues?: Record<string, string>;
|
|
38
|
+
/** Navigate to the contact details screen, where company fields are edited. */
|
|
39
|
+
onOpenContactDetails?: () => void;
|
|
26
40
|
};
|
|
27
41
|
|
|
28
42
|
const FEATURE_LABELS: Record<keyof SiteFeatureFlags, string> = {
|
|
@@ -58,7 +72,9 @@ const cardClass = "border border-ink/10 bg-white p-6";
|
|
|
58
72
|
* screen writes only the overridable subset into the database.
|
|
59
73
|
*/
|
|
60
74
|
export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
61
|
-
const { config, overrides, saveSettings, clearSetting, refresh } = deps;
|
|
75
|
+
const { config, overrides, saveSettings, clearSetting, refresh, onOpenContactDetails } = deps;
|
|
76
|
+
const indexing = deps.indexing;
|
|
77
|
+
const shownCompany = { ...EMPTY_COMPANY, ...config.company, ...(deps.companyValues ?? {}) };
|
|
62
78
|
const originSource = deps.originSource ?? "code";
|
|
63
79
|
const [name, setName] = useState(config.name);
|
|
64
80
|
const [company, setCompany] = useState({ ...EMPTY_COMPANY, ...config.company });
|
|
@@ -119,6 +135,17 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
|
119
135
|
originSource === "environment" ? "From SITE_URL" : "Code fallback \u2014 SITE_URL unset",
|
|
120
136
|
],
|
|
121
137
|
["Locales", config.locales.join(", "), "Set in code"],
|
|
138
|
+
[
|
|
139
|
+
"Search engines",
|
|
140
|
+
indexing ? (indexing.mode === "block" ? "Blocked \u2014 not indexed" : "Indexed") : "Unknown",
|
|
141
|
+
indexing
|
|
142
|
+
? indexing.source === "SITE_INDEXING"
|
|
143
|
+
? `From SITE_INDEXING=${indexing.value}`
|
|
144
|
+
: indexing.source === "SITE_ENV"
|
|
145
|
+
? `From SITE_ENV=${indexing.value}`
|
|
146
|
+
: "Safe default \u2014 SITE_INDEXING unset"
|
|
147
|
+
: "Set SITE_INDEXING per environment",
|
|
148
|
+
],
|
|
122
149
|
[
|
|
123
150
|
"Backoffice",
|
|
124
151
|
`${config.backofficePath} (${config.backofficeLocale})`,
|
|
@@ -133,9 +160,14 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
|
133
160
|
))}
|
|
134
161
|
</dl>
|
|
135
162
|
<p className="mt-3 max-w-2xl text-xs text-ink/50">
|
|
136
|
-
These
|
|
163
|
+
These are structural: the locale set and backoffice path are compiled into the build,
|
|
137
164
|
and the public origin differs per environment. They are read from code and environment on
|
|
138
|
-
purpose, so a deployment can never disagree with what the database says.
|
|
165
|
+
purpose, so a deployment can never disagree with what the database says. Indexing is the
|
|
166
|
+
same kind of fact: set <code>SITE_INDEXING=off</code> on staging and{" "}
|
|
167
|
+
<code>SITE_INDEXING=on</code> in production. A blocked deployment serves{" "}
|
|
168
|
+
<code>Disallow: /</code> in robots.txt and a <code>noindex, nofollow</code> header on every
|
|
169
|
+
response, so it can never be indexed even if a link leaks. For truly private staging, add
|
|
170
|
+
HTTP basic auth in Traefik on top — a password is the only thing that keeps humans out.
|
|
139
171
|
</p>
|
|
140
172
|
|
|
141
173
|
<section className={`mt-8 ${cardClass}`}>
|
|
@@ -155,27 +187,29 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
|
155
187
|
</section>
|
|
156
188
|
|
|
157
189
|
<section className={`mt-6 ${cardClass}`}>
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
<
|
|
190
|
+
<h2 className="font-serif text-xl text-ink">Company & contact details</h2>
|
|
191
|
+
<p className="mt-2 max-w-2xl text-sm text-ink/60">
|
|
192
|
+
These moved to <strong>Settings → Contact details</strong>, which is the single place
|
|
193
|
+
they are edited. The values there feed the website contact section, the footer and the
|
|
194
|
+
structured data for search engines.
|
|
195
|
+
</p>
|
|
196
|
+
<dl className="mt-4 grid gap-3 md:grid-cols-3">
|
|
165
197
|
{(Object.keys(COMPANY_LABELS) as (keyof typeof EMPTY_COMPANY)[]).map((key) => (
|
|
166
|
-
<
|
|
167
|
-
<
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
value={company[key] ?? ""}
|
|
171
|
-
onChange={(e) => setCompany({ ...company, [key]: e.target.value })}
|
|
172
|
-
/>
|
|
173
|
-
</label>
|
|
198
|
+
<div key={key}>
|
|
199
|
+
<dt className={labelClass}>{COMPANY_LABELS[key]}</dt>
|
|
200
|
+
<dd className="text-sm text-ink">{shownCompany[key] || <span className="text-ink/35">Not set</span>}</dd>
|
|
201
|
+
</div>
|
|
174
202
|
))}
|
|
175
|
-
</
|
|
176
|
-
|
|
177
|
-
<
|
|
178
|
-
|
|
203
|
+
</dl>
|
|
204
|
+
{onOpenContactDetails ? (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={onOpenContactDetails}
|
|
208
|
+
className="mt-5 bg-ink px-5 py-2.5 text-xs uppercase tracking-[0.22em] text-paper"
|
|
209
|
+
>
|
|
210
|
+
Edit contact details
|
|
211
|
+
</button>
|
|
212
|
+
) : null}
|
|
179
213
|
</section>
|
|
180
214
|
|
|
181
215
|
<section className={`mt-6 ${cardClass}`}>
|
|
@@ -128,11 +128,20 @@ export function VersionRoute() {
|
|
|
128
128
|
|
|
129
129
|
/** Site settings screen (code defaults plus database overrides). */
|
|
130
130
|
export function SiteSettingsRoute() {
|
|
131
|
-
const
|
|
131
|
+
const host = backofficeHost();
|
|
132
|
+
const { fns, siteConfig, company } = host;
|
|
132
133
|
const load = useServerFn(fns.getSiteSettings);
|
|
133
134
|
const save = useServerFn(fns.saveSiteSettings);
|
|
134
135
|
const clear = useServerFn(fns.clearSiteSetting);
|
|
136
|
+
const fetchOverrides = useServerFn(fns.getContentOverrides);
|
|
135
137
|
const q = useQuery({ queryKey: ["admin", "site-settings"], queryFn: () => load() });
|
|
138
|
+
// Company fields are owned by the contact details screen (a content
|
|
139
|
+
// override), so read them from the same place for the read-only summary.
|
|
140
|
+
const contactQ = useQuery({
|
|
141
|
+
queryKey: ["cms", "content-overrides"],
|
|
142
|
+
queryFn: () => fetchOverrides(),
|
|
143
|
+
});
|
|
144
|
+
const contactPath = backofficePaths(host).contact;
|
|
136
145
|
|
|
137
146
|
return (
|
|
138
147
|
<SiteSettingsView
|
|
@@ -143,6 +152,14 @@ export function SiteSettingsRoute() {
|
|
|
143
152
|
clearSetting: (input) => clear(input as never),
|
|
144
153
|
refresh: () => q.refetch(),
|
|
145
154
|
originSource: q.data?.originSource ?? "code",
|
|
155
|
+
indexing: q.data?.indexing,
|
|
156
|
+
companyValues: {
|
|
157
|
+
...company,
|
|
158
|
+
...((contactQ.data?.company as Record<string, string> | undefined) ?? {}),
|
|
159
|
+
},
|
|
160
|
+
onOpenContactDetails: () => {
|
|
161
|
+
window.location.href = contactPath;
|
|
162
|
+
},
|
|
146
163
|
}}
|
|
147
164
|
/>
|
|
148
165
|
);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* `Content-Encoding`) and keeps a bare `docker compose up` correct too.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { NOINDEX_HEADER, indexingBlocked } from "../site/indexing";
|
|
11
12
|
import { createMiddleware } from "@tanstack/react-start";
|
|
12
13
|
|
|
13
14
|
import { cachePolicyFor, expiresHeader } from "./cache-policy";
|
|
@@ -73,6 +74,13 @@ export async function applyEdgeHeaders(
|
|
|
73
74
|
}
|
|
74
75
|
dedupeVary(headers);
|
|
75
76
|
|
|
77
|
+
// 2b. Indexing policy. Staging deployments must never be indexed, and the
|
|
78
|
+
// header covers every response type (HTML, media, JSON) — robots.txt
|
|
79
|
+
// alone does not deindex a URL that was already crawled.
|
|
80
|
+
if (!headers.has("x-robots-tag") && indexingBlocked()) {
|
|
81
|
+
headers.set("x-robots-tag", NOINDEX_HEADER);
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
// 3. Compression.
|
|
77
85
|
if (
|
|
78
86
|
!compress ||
|
|
@@ -14,21 +14,15 @@ import "./error-capture";
|
|
|
14
14
|
import { consumeLastCapturedError } from "./error-capture";
|
|
15
15
|
import { renderErrorPage } from "./error-page";
|
|
16
16
|
import { applyEdgeHeaders } from "../http/response-headers";
|
|
17
|
+
import type { InjectionBundle } from "../tracking/injections";
|
|
17
18
|
|
|
18
19
|
export type SiteCodeInjections = {
|
|
19
20
|
/** Whether backoffice-managed code may be injected on this path. */
|
|
20
|
-
isInjectablePath: (pathname: string) => boolean;
|
|
21
|
+
isInjectablePath: (pathname: string, backofficePath?: string) => boolean;
|
|
21
22
|
/** Head / body-start / body-end snippets for the requesting host. */
|
|
22
|
-
resolve: (host: string) => Promise<
|
|
23
|
-
head?: string;
|
|
24
|
-
bodyStart?: string;
|
|
25
|
-
bodyEnd?: string;
|
|
26
|
-
}>;
|
|
23
|
+
resolve: (host: string) => Promise<InjectionBundle>;
|
|
27
24
|
/** Applies a resolved bundle to the rendered HTML. */
|
|
28
|
-
apply: (
|
|
29
|
-
html: string,
|
|
30
|
-
bundle: { head?: string; bodyStart?: string; bodyEnd?: string },
|
|
31
|
-
) => string;
|
|
25
|
+
apply: (html: string, bundle: InjectionBundle) => string;
|
|
32
26
|
};
|
|
33
27
|
|
|
34
28
|
export type ServerEntryOptions = {
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* own default domain — a static file in `public/` goes stale the moment the
|
|
6
6
|
* domain changes.
|
|
7
7
|
*/
|
|
8
|
+
|
|
9
|
+
import { resolveIndexingPolicy, type IndexingMode } from "../../site/indexing";
|
|
10
|
+
|
|
8
11
|
export type RobotsOptions = {
|
|
9
12
|
/** The incoming request URL, used as the host fallback. */
|
|
10
13
|
requestUrl: string;
|
|
@@ -16,6 +19,11 @@ export type RobotsOptions = {
|
|
|
16
19
|
allowedAgents?: string[];
|
|
17
20
|
/** `Cache-Control` for the response. */
|
|
18
21
|
cacheControl?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Override the environment-derived indexing policy. Leave unset so staging
|
|
24
|
+
* hosts (`SITE_INDEXING=off`) automatically serve a full disallow.
|
|
25
|
+
*/
|
|
26
|
+
indexing?: IndexingMode;
|
|
19
27
|
};
|
|
20
28
|
|
|
21
29
|
export const DEFAULT_ALLOWED_AGENTS = [
|
|
@@ -26,6 +34,18 @@ export const DEFAULT_ALLOWED_AGENTS = [
|
|
|
26
34
|
];
|
|
27
35
|
|
|
28
36
|
export async function robotsResponse(options: RobotsOptions): Promise<Response> {
|
|
37
|
+
const mode = options.indexing ?? resolveIndexingPolicy().mode;
|
|
38
|
+
if (mode === "block") {
|
|
39
|
+
// A blocked deployment advertises nothing: no sitemap, no per-agent allows.
|
|
40
|
+
return new Response(["User-agent: *", "Disallow: /", ""].join("\n"), {
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
43
|
+
"Cache-Control": "public, max-age=300",
|
|
44
|
+
"X-Robots-Tag": "noindex, nofollow",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
let host = new URL(options.requestUrl).host;
|
|
30
50
|
if (options.resolvePreferredHost) {
|
|
31
51
|
try {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search-engine indexing policy — an environment fact, never a database value.
|
|
3
|
+
*
|
|
4
|
+
* Staging and production run the same build, so whether a deployment may be
|
|
5
|
+
* indexed cannot live in content or in `site_settings`: a content restore or a
|
|
6
|
+
* database copy would then silently open staging to Google, or close
|
|
7
|
+
* production. It is read from `SITE_INDEXING` (or `SITE_ENV`) at request time.
|
|
8
|
+
*
|
|
9
|
+
* `SITE_INDEXING`:
|
|
10
|
+
* `off` | `block` | `noindex` | `0` | `false` → blocked
|
|
11
|
+
* `on` | `allow` | `index` | `1` | `true` → allowed
|
|
12
|
+
* Unset falls back to `SITE_ENV` / `NODE_ENV`: anything that is not
|
|
13
|
+
* `production` is blocked, which is the safe default for a fresh staging host.
|
|
14
|
+
*/
|
|
15
|
+
export type IndexingMode = "allow" | "block";
|
|
16
|
+
|
|
17
|
+
export type IndexingPolicy = {
|
|
18
|
+
mode: IndexingMode;
|
|
19
|
+
/** Where the decision came from, for display in the backoffice. */
|
|
20
|
+
source: "SITE_INDEXING" | "SITE_ENV" | "default";
|
|
21
|
+
/** The raw value behind the decision, e.g. `off` or `staging`. */
|
|
22
|
+
value: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** `X-Robots-Tag` sent on every response of a blocked deployment. */
|
|
26
|
+
export const NOINDEX_HEADER = "noindex, nofollow, noarchive, nosnippet, noimageindex";
|
|
27
|
+
|
|
28
|
+
const BLOCK = new Set(["off", "block", "blocked", "noindex", "no", "0", "false"]);
|
|
29
|
+
const ALLOW = new Set(["on", "allow", "allowed", "index", "yes", "1", "true"]);
|
|
30
|
+
|
|
31
|
+
type Env = Record<string, string | undefined>;
|
|
32
|
+
|
|
33
|
+
export function resolveIndexingPolicy(env: Env = process.env): IndexingPolicy {
|
|
34
|
+
const explicit = (env["SITE_INDEXING"] ?? "").trim().toLowerCase();
|
|
35
|
+
if (explicit) {
|
|
36
|
+
if (BLOCK.has(explicit)) return { mode: "block", source: "SITE_INDEXING", value: explicit };
|
|
37
|
+
if (ALLOW.has(explicit)) return { mode: "allow", source: "SITE_INDEXING", value: explicit };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const environment = (env["SITE_ENV"] ?? env["NODE_ENV"] ?? "").trim().toLowerCase();
|
|
41
|
+
if (environment) {
|
|
42
|
+
return {
|
|
43
|
+
mode: environment === "production" ? "allow" : "block",
|
|
44
|
+
source: "SITE_ENV",
|
|
45
|
+
value: environment,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { mode: "block", source: "default", value: "unset" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True when this deployment must not be indexed. */
|
|
53
|
+
export function indexingBlocked(env: Env = process.env): boolean {
|
|
54
|
+
return resolveIndexingPolicy(env).mode === "block";
|
|
55
|
+
}
|