@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 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.50.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: "Name" },
10
- { key: "street", label: "Address" },
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: "Company details (legal name)" },
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. They are used in the website contact
72
- section, the footer and the structured data for search engines.
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 four are structural: the locale set and backoffice path are compiled into the build,
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
- <SectionHeader
159
- title="Company & contact details"
160
- overridden={overridden("company")}
161
- onReset={() => reset("company")}
162
- busy={busy === "company"}
163
- />
164
- <div className="mt-4 grid gap-3 md:grid-cols-3">
190
+ <h2 className="font-serif text-xl text-ink">Company &amp; contact details</h2>
191
+ <p className="mt-2 max-w-2xl text-sm text-ink/60">
192
+ These moved to <strong>Settings &rarr; 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
- <label key={key} className="block">
167
- <span className={labelClass}>{COMPANY_LABELS[key]}</span>
168
- <input
169
- className={inputClass}
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
- </div>
176
- <div className="mt-4">
177
- <SaveButton busy={busy === "company"} onClick={() => save("company", { company })} />
178
- </div>
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 { fns, siteConfig } = backofficeHost();
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
+ }