@bygghammar/cms-platform 0.50.1 → 0.52.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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.52.0
4
+
5
+ - `IndexingStatusCard`: read-only search-engine indexing status (from
6
+ `site/indexing`), shown on the SEO list and SEO detail views via a new
7
+ optional `indexing` dep — the same box as Admin → Site settings.
8
+
9
+ ## 0.51.0
10
+
11
+ - `site/indexing`: environment-derived search-engine policy (`SITE_INDEXING`,
12
+ falling back to `SITE_ENV`/`NODE_ENV`). Staging is blocked by default.
13
+ - `server/http/robots`: a blocked deployment serves `Disallow: /` with no
14
+ sitemap line and an `X-Robots-Tag` header.
15
+ - `http/response-headers`: adds `noindex, nofollow, noarchive, nosnippet,
16
+ noimageindex` to every response of a blocked deployment.
17
+ - `SiteSettingsView`: shows the read-only indexing status, and the company
18
+ fields are now a read-only summary that points at Settings -> Contact
19
+ details, which is the single editor for them.
20
+
21
+
3
22
  ## 0.50.1
4
23
 
5
24
  - `runtime/server-entry`: the injection adapter is typed against `InjectionBundle`, matching the shared `injections-core` helpers exactly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.50.1",
3
+ "version": "0.52.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,9 @@
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",
201
+ "./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx"
200
202
  },
201
203
  "dependencies": {
202
204
  "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">
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { IndexingPolicy } from "../site/indexing";
4
+
5
+ /**
6
+ * Read-only search-engine indexing status, resolved server-side from the
7
+ * environment (`SITE_INDEXING` / `SITE_ENV` — see `site/indexing`). Shown on
8
+ * Site settings and the SEO screens so a blocked staging deployment is
9
+ * visible wherever metadata is edited. Never a database toggle: indexing is
10
+ * an environment fact so a content restore cannot flip it.
11
+ */
12
+ export function IndexingStatusCard({
13
+ indexing,
14
+ }: {
15
+ indexing?: IndexingPolicy;
16
+ }): ReactNode {
17
+ const status = indexing
18
+ ? indexing.mode === "block"
19
+ ? "Blocked — not indexed"
20
+ : "Indexed"
21
+ : "Unknown";
22
+ const source = indexing
23
+ ? indexing.source === "SITE_INDEXING"
24
+ ? `From SITE_INDEXING=${indexing.value}`
25
+ : indexing.source === "SITE_ENV"
26
+ ? `From SITE_ENV=${indexing.value}`
27
+ : "Safe default — SITE_INDEXING unset"
28
+ : "Set SITE_INDEXING per environment";
29
+
30
+ return (
31
+ <div className="max-w-sm border border-ink/10 bg-white p-4">
32
+ <div className="text-xs uppercase tracking-wider text-ink/50">Search engines</div>
33
+ <div className="mt-1 text-sm text-ink">{status}</div>
34
+ <div className="mt-2 text-[11px] uppercase tracking-wider text-ink/40">{source}</div>
35
+ <p className="mt-3 text-xs text-ink/50">
36
+ Indexing is an environment fact, not a setting here: set <code>SITE_INDEXING=off</code> on
37
+ staging and <code>SITE_INDEXING=on</code> in production. A blocked deployment serves{" "}
38
+ <code>Disallow: /</code> in robots.txt and a <code>noindex, nofollow</code> header on every
39
+ response.
40
+ </p>
41
+ </div>
42
+ );
43
+ }
@@ -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
  );
@@ -7,6 +7,9 @@
7
7
  import type { ReactNode } from "react";
8
8
  import { Check, ChevronLeft, ChevronRight } from "lucide-react";
9
9
 
10
+ import type { IndexingPolicy } from "../../site/indexing";
11
+ import { IndexingStatusCard } from "../IndexingStatusCard";
12
+
10
13
  export type SeoListRow = {
11
14
  /** Page key, e.g. `index` or a CMS-only page key. */
12
15
  key: string;
@@ -21,10 +24,15 @@ export type SeoListDeps = {
21
24
  rows: readonly SeoListRow[];
22
25
  /** Navigate to the SEO editor for one page key. */
23
26
  openEditor: (key: string) => void;
27
+ /**
28
+ * Indexing policy for the running deployment, resolved from the environment
29
+ * server-side (see `site/indexing`). Shown read-only, same as Site settings.
30
+ */
31
+ indexing?: IndexingPolicy;
24
32
  };
25
33
 
26
34
  export function SeoListView({ deps }: { deps: SeoListDeps }): ReactNode {
27
- const { rows, openEditor } = deps;
35
+ const { rows, openEditor, indexing } = deps;
28
36
 
29
37
  return (
30
38
  <div className="p-8 md:p-12 w-full">
@@ -36,6 +44,10 @@ export function SeoListView({ deps }: { deps: SeoListDeps }): ReactNode {
36
44
  defaults from the codebase and are used directly by canonical, hreflang and sitemap.
37
45
  </p>
38
46
 
47
+ <div className="mt-6">
48
+ <IndexingStatusCard indexing={indexing} />
49
+ </div>
50
+
39
51
  <ul className="mt-10 divide-y divide-ink/10 border-y border-ink/10">
40
52
  {rows.map((row) => (
41
53
  <li key={row.key}>
@@ -73,10 +85,15 @@ export type SeoDetailDeps = {
73
85
  onBack: () => void;
74
86
  /** The host's page SEO panel for this page key. */
75
87
  children: ReactNode;
88
+ /**
89
+ * Indexing policy for the running deployment, resolved from the environment
90
+ * server-side (see `site/indexing`). Shown read-only, same as Site settings.
91
+ */
92
+ indexing?: IndexingPolicy;
76
93
  };
77
94
 
78
95
  export function SeoDetailView({ deps }: { deps: SeoDetailDeps }): ReactNode {
79
- const { pageKey, onBack, children } = deps;
96
+ const { pageKey, onBack, children, indexing } = deps;
80
97
 
81
98
  return (
82
99
  <div className="p-8 md:p-12 w-full">
@@ -98,6 +115,10 @@ export function SeoDetailView({ deps }: { deps: SeoDetailDeps }): ReactNode {
98
115
  hreflang, Open Graph and Twitter cards.
99
116
  </p>
100
117
 
118
+ <div className="mt-6">
119
+ <IndexingStatusCard indexing={indexing} />
120
+ </div>
121
+
101
122
  <div className="mt-8">{children}</div>
102
123
  </div>
103
124
  );
@@ -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 ||
@@ -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
+ }