@rmdes/indiekit-endpoint-site-config 1.0.0-beta.13 → 1.0.0-beta.15

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/README.md CHANGED
@@ -220,6 +220,25 @@ Phase 4 makes the composition editor the homepage source of truth (the v3 homepa
220
220
 
221
221
  **Phase 6 surfaces** — the hub already lists `listing`, `posttype`, and `pages` as disabled cards; their composition surfaces (and the blog sidebars' cutover off the v3 doc) land in Phase 6.
222
222
 
223
+ ### Phase 5: True preview + build status
224
+
225
+ Phase 5 adds a true preview (the draft rendered through the **production** theme renderer, zero drift) and a post-publish build-status surface.
226
+
227
+ **Routes** (both on the session-protected design router):
228
+
229
+ | Method | Path | Purpose |
230
+ |--------|------|---------|
231
+ | POST | `/design/homepage/preview` | Write the preview-draft artifact on demand (never per keystroke) |
232
+ | GET | `/design/api/build-status` | Build-status API polled by the publish strip (`Cache-Control: no-store`) |
233
+
234
+ **Preview-draft artifact** — the Update-preview POST writes `content/_data/compositions/preview-draft.json` (`{schemaVersion: 4, kind: "preview", tree, revision, token, generatedAt}`, atomic tmp + rename) from the draft tree (or the published tree when no draft exists). The theme renders it at `/preview/<token>/` — an unguessable 16-byte token stored on the `siteConfig` doc. Each preview write bumps a monotonic revision; the editor's preview pane polls the same-origin iframe until the new revision appears. **Publish rotates the token** (previously shared preview URLs expire) and rewrites a fresh preview-draft from the now-published tree — warn-only, a preview refresh failure never masks a successful publish.
235
+
236
+ **Custom-tree scope (deliberate)** — the preview POST accepts custom (hand-built) trees, since they render through the same production renderer. But the editor view stays read-only for custom trees and offers **no preview pane affordance**; previewing a hand-built tree is its author's out-of-band concern.
237
+
238
+ **Build-status API** — `GET /design/api/build-status` reads `/app/data/build-status.json` (written by the theme's build hooks as `{state: "building"|"ok", buildId, startedAt, finishedAt, durationSeconds, incremental, lastOkDurationSeconds}` and by start.sh's crash wrapper as a minimal `{state: "failed", error, finishedAt}`) and responds with the raw fields plus a computed `stuck` flag: a `building` state that has overrun `max(2 × lastOkDurationSeconds, 120)` seconds (the 120s floor absorbs full post-boot builds; 60 is the default when the duration is absent). The endpoint is tolerant by contract — an absent or corrupt file responds `{state: "unknown"}`, never a 500, and a `building` object missing `startedAt` is never stuck.
239
+
240
+ **Publish-flow strip** — after a publish (`?published=1`) the draft bar's "Live" row gains a build-status strip. With JS, `editor.js` polls the API every 5s while a sessionStorage watch (stamped with the publish time) is active: `building` shows "Rebuilding — usually ~Xs on this site. Your current site stays online."; `ok` with `finishedAt` after the publish shows "Live · <time>" (terminal); `failed` shows the error excerpt plus a republish hint (terminal — the live site is unchanged); `stuck` explains that publishing again rewrites the homepage artifact (which also heals a missed watcher event). Without JS, the strip renders the last-known status server-side with a reload-to-update note (no meta-refresh).
241
+
223
242
  ## Theme integration
224
243
 
225
244
  The companion Eleventy theme [`indiekit-eleventy-theme`](https://github.com/rmdes/indiekit-eleventy-theme) reads:
package/assets/editor.css CHANGED
@@ -419,6 +419,26 @@
419
419
  flex-wrap: wrap;
420
420
  }
421
421
 
422
+ /* Publish-flow build-status strip (Phase 5 S2) — rides in the draft bar's
423
+ "Live" state after a publish. */
424
+ .sc-build-status {
425
+ flex-basis: 100%;
426
+ border-top: 1px solid var(--color-outline-variant, #ddd);
427
+ padding-top: var(--space-2xs, 0.375rem);
428
+ }
429
+
430
+ .sc-build-status__text {
431
+ margin: 0;
432
+ font: var(--font-caption, 0.875rem/1.4 sans-serif);
433
+ color: var(--color-on-offset, #666);
434
+ }
435
+
436
+ .sc-build-status__note {
437
+ margin: var(--space-3xs, 0.25rem) 0 0;
438
+ font: var(--font-caption, 0.75rem/1.4 sans-serif);
439
+ color: var(--color-on-offset, #666);
440
+ }
441
+
422
442
  /* ---- Focus visibility (WCAG 2.4.7) ----
423
443
  Cards themselves aren't focusable; every interactive element inside the
424
444
  editor, hub, and add dialog gets a visible focus ring. Same accent as the
@@ -441,3 +461,80 @@
441
461
  flex-direction: column;
442
462
  gap: var(--space-s, 0.75rem);
443
463
  }
464
+
465
+ /* ---- Right-pane mode toggle + true preview (Phase 5) ----
466
+ The pane wrapper takes over the sticky behavior the bare structural
467
+ preview had as a direct grid child (a sticky element inside a
468
+ content-height wrapper has no room to stick). */
469
+
470
+ .sc-design__pane {
471
+ position: sticky;
472
+ top: var(--space-s, 0.75rem);
473
+ min-width: 0;
474
+ display: flex;
475
+ flex-direction: column;
476
+ gap: var(--space-xs, 0.5rem);
477
+ }
478
+
479
+ .sc-design__pane .sc-design__preview {
480
+ position: static; /* sticky moved to the wrapper */
481
+ }
482
+
483
+ @media (max-width: 1100px) {
484
+ .sc-design__pane {
485
+ position: static;
486
+ }
487
+ }
488
+
489
+ .sc-pane-toggle {
490
+ display: flex;
491
+ gap: var(--space-2xs, 0.375rem);
492
+ }
493
+
494
+ .sc-pane-toggle__link {
495
+ font: var(--font-caption, 0.8125rem/1.4 sans-serif);
496
+ font-weight: 700;
497
+ text-decoration: none;
498
+ color: var(--color-on-offset, #666);
499
+ padding: var(--space-2xs, 0.25rem) var(--space-xs, 0.5rem);
500
+ border: 1px solid var(--color-outline-variant, #ddd);
501
+ border-radius: var(--border-radius-small, 0.25rem);
502
+ }
503
+
504
+ .sc-pane-toggle__link[aria-current="true"] {
505
+ color: var(--color-on-primary, #fff);
506
+ background: var(--color-primary, #0066cc);
507
+ border-color: var(--color-primary, #0066cc);
508
+ }
509
+
510
+ .sc-design__preview--live {
511
+ display: flex;
512
+ flex-direction: column;
513
+ gap: var(--space-xs, 0.5rem);
514
+ }
515
+
516
+ .sc-preview-bar {
517
+ display: flex;
518
+ gap: var(--space-xs, 0.5rem);
519
+ flex-wrap: wrap;
520
+ }
521
+
522
+ .sc-preview-status {
523
+ font: var(--font-caption, 0.8125rem/1.4 sans-serif);
524
+ color: var(--color-on-offset, #666);
525
+ min-height: 1.2em; /* no layout shift when status text appears */
526
+ margin: 0;
527
+ }
528
+
529
+ .sc-preview-frame {
530
+ width: 100%;
531
+ height: min(70vh, 48rem);
532
+ border: 1px solid var(--color-outline-variant, #ddd);
533
+ border-radius: var(--border-radius-small, 0.25rem);
534
+ background: var(--color-background, #fff);
535
+ }
536
+
537
+ .sc-preview-empty {
538
+ font: var(--font-caption, 0.8125rem/1.4 sans-serif);
539
+ color: var(--color-on-offset, #666);
540
+ }
package/assets/editor.js CHANGED
@@ -147,8 +147,282 @@ function initUndoFlash(i18n) {
147
147
  flash.append(dismiss);
148
148
  }
149
149
 
150
+ /** (5) True-preview pane (Phase 5). Enhancement over the plain
151
+ * Update-preview POST: fetch with `Accept: application/json`, then poll the
152
+ * same-origin iframe every 3s — reload it and read the theme page's
153
+ * `[data-preview-revision]` — until the just-written revision shows up.
154
+ * Status copy quotes the site's measured build time (expectedSeconds from
155
+ * build-status.json); polling caps at 3× that — with a 30s floor so a
156
+ * 2s-build site doesn't flip to "taking longer than usual" after one poll
157
+ * (90s when the expected time is unknown) — then shows "taking longer than
158
+ * usual" and leaves the manual reload button as the recovery affordance.
159
+ * The no-JS path (plain POST + full page reloads) works without any of
160
+ * this. */
161
+ const PREVIEW_POLL_MS = 3000;
162
+ const PREVIEW_DEFAULT_CAP_MS = 90_000;
163
+ const PREVIEW_MIN_CAP_MS = 30_000;
164
+
165
+ function initPreviewPane(i18n) {
166
+ const form = document.querySelector("[data-sc-preview-form]");
167
+ if (!form) return; // structural pane (or no editor) — nothing to enhance
168
+ const pane = form.closest(".sc-design__preview");
169
+ const status = pane.querySelector("[data-sc-preview-status]");
170
+ const reload = pane.querySelector("[data-sc-preview-reload]");
171
+ let timer = null;
172
+
173
+ const getFrame = () => pane.querySelector("[data-sc-preview-frame]");
174
+
175
+ const setStatus = (text) => {
176
+ if (status) status.textContent = text;
177
+ };
178
+
179
+ const reloadFrame = (iframe) => {
180
+ // Same-origin reload; re-assigning src is the fallback (also covers a
181
+ // frame that never finished its first load).
182
+ try {
183
+ iframe.contentWindow.location.reload();
184
+ } catch {
185
+ iframe.src = iframe.src;
186
+ }
187
+ };
188
+
189
+ /** First Update-preview on a fresh site: no token existed at render time,
190
+ * so the iframe wasn't server-rendered — create it from the response. */
191
+ const ensureFrame = (token) => {
192
+ let iframe = getFrame();
193
+ if (iframe) return iframe;
194
+ pane.querySelector("[data-sc-preview-empty]")?.remove();
195
+ iframe = document.createElement("iframe");
196
+ iframe.className = "sc-preview-frame";
197
+ iframe.setAttribute("data-sc-preview-frame", "");
198
+ iframe.title = i18n.previewFrameTitle || "";
199
+ iframe.src = `/preview/${encodeURIComponent(token)}/`;
200
+ pane.append(iframe);
201
+ return iframe;
202
+ };
203
+
204
+ const frameRevision = (iframe) => {
205
+ try {
206
+ return (
207
+ iframe.contentDocument
208
+ ?.querySelector("[data-preview-revision]")
209
+ ?.getAttribute("data-preview-revision") ?? null
210
+ );
211
+ } catch {
212
+ return null; // mid-load or cross-origin — keep polling
213
+ }
214
+ };
215
+
216
+ const stopPolling = () => {
217
+ if (timer) {
218
+ clearInterval(timer);
219
+ timer = null;
220
+ }
221
+ };
222
+
223
+ const startPolling = ({ token, revision, expectedSeconds }) => {
224
+ stopPolling();
225
+ const iframe = ensureFrame(token);
226
+ const expected =
227
+ typeof expectedSeconds === "number" && expectedSeconds > 0 ? expectedSeconds : null;
228
+ const capMs = expected
229
+ ? Math.max(expected * 3 * 1000, PREVIEW_MIN_CAP_MS)
230
+ : PREVIEW_DEFAULT_CAP_MS;
231
+ const startedAt = Date.now();
232
+ setStatus(
233
+ expected
234
+ ? (i18n.previewBuilding || "").replace("{{seconds}}", String(Math.round(expected)))
235
+ : i18n.previewBuildingUnknown || "",
236
+ );
237
+ timer = setInterval(() => {
238
+ if (frameRevision(iframe) === String(revision)) {
239
+ stopPolling();
240
+ setStatus(i18n.previewReady || "");
241
+ return;
242
+ }
243
+ if (Date.now() - startedAt > capMs) {
244
+ stopPolling();
245
+ setStatus(i18n.previewSlow || "");
246
+ return;
247
+ }
248
+ reloadFrame(iframe);
249
+ }, PREVIEW_POLL_MS);
250
+ };
251
+
252
+ if (reload) {
253
+ reload.hidden = false;
254
+ reload.addEventListener("click", () => {
255
+ const iframe = getFrame();
256
+ if (iframe) reloadFrame(iframe);
257
+ });
258
+ }
259
+
260
+ form.addEventListener("submit", async (event) => {
261
+ event.preventDefault();
262
+ try {
263
+ const response = await fetch(form.action, {
264
+ method: "POST",
265
+ headers: { Accept: "application/json" },
266
+ });
267
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
268
+ startPolling(await response.json());
269
+ } catch {
270
+ // Fall back to the plain POST (full reload) — the no-JS path.
271
+ stopPolling();
272
+ form.submit();
273
+ }
274
+ });
275
+ }
276
+
277
+ /** (6) Publish-flow build-status strip (Phase 5 S2). After a publish
278
+ * (?published=1) the draft bar — now "Live" — gains a strip tracking the
279
+ * Eleventy rebuild. The strip element only renders on the publish flash;
280
+ * sessionStorage carries the watch across reloads of that page, stamped
281
+ * with the publish time so "finishedAt after the publish" is checkable.
282
+ * Polling is a passive 5s GET of the authed build-status API (a cheap fs
283
+ * read server-side); the watch ends on terminal states (post-publish ok,
284
+ * or failed) and when the user navigates off the flash page. A stale "ok"
285
+ * from BEFORE the publish keeps the rebuilding copy — the new build just
286
+ * hasn't been observed yet.
287
+ *
288
+ * No stamp + a terminal status = a reload AFTER the watch already ended
289
+ * (endWatch cleared the stamp; the URL still says ?published=1). The probe
290
+ * branch handles it: fetch once FIRST and, if the status is already
291
+ * terminal (ok with a finishedAt, or failed), render it as-is and never
292
+ * (re)start the watch — re-stamping with the reload time would compare
293
+ * finishedAt against the WRONG moment and replace a correct "Live · time"
294
+ * with an eternal "Rebuilding…". Only a non-terminal probe (building,
295
+ * unknown, fetch failure) stamps and starts polling.
296
+ *
297
+ * The no-JS path renders the last-known status server-side with a
298
+ * reload-to-update note. */
299
+ const BUILD_STATUS_POLL_MS = 5000;
300
+ const BUILD_STATUS_URL = "/site-config/design/api/build-status";
301
+ const PUBLISH_WATCH_KEY = "scPublishWatch";
302
+ const BUILD_ERROR_EXCERPT_CHARS = 140;
303
+
304
+ function initBuildStatus(i18n) {
305
+ const strip = document.querySelector("[data-sc-build-status]");
306
+ if (!strip) {
307
+ // The watch lives only on the page showing the publish flash —
308
+ // navigating away ends it (a lingering stamp would poison the
309
+ // "finishedAt after publish" check on the NEXT publish).
310
+ sessionStorage.removeItem(PUBLISH_WATCH_KEY);
311
+ return;
312
+ }
313
+ const text = strip.querySelector("[data-sc-build-text]");
314
+ if (!text) return;
315
+
316
+ const setText = (value) => {
317
+ text.textContent = value;
318
+ };
319
+
320
+ // The moment finishedAt must beat for an "ok" to count as terminal.
321
+ // Stamp path: the publish time. Probe path: 0 — any finished build is
322
+ // terminal there (see the docblock above).
323
+ let publishedAt = 0;
324
+ let timer = null;
325
+ let ended = false;
326
+ const endWatch = () => {
327
+ ended = true;
328
+ sessionStorage.removeItem(PUBLISH_WATCH_KEY);
329
+ if (timer) {
330
+ clearInterval(timer);
331
+ timer = null;
332
+ }
333
+ };
334
+
335
+ const render = (status) => {
336
+ const state = status?.state;
337
+ if (state === "building") {
338
+ if (status.stuck) return setText(i18n.buildStuck || "");
339
+ const seconds =
340
+ typeof status.lastOkDurationSeconds === "number" && status.lastOkDurationSeconds > 0
341
+ ? Math.round(status.lastOkDurationSeconds)
342
+ : null;
343
+ return setText(
344
+ seconds
345
+ ? (i18n.buildBuilding || "").replace("{{seconds}}", String(seconds))
346
+ : i18n.buildBuildingUnknown || "",
347
+ );
348
+ }
349
+ if (state === "ok") {
350
+ const finishedAt =
351
+ typeof status.finishedAt === "string" ? Date.parse(status.finishedAt) : Number.NaN;
352
+ if (!Number.isNaN(finishedAt) && finishedAt > publishedAt) {
353
+ // Terminal: the build landed. (Client-side display only —
354
+ // templates never see this Date.)
355
+ setText(
356
+ (i18n.buildLive || "").replace(
357
+ "{{time}}",
358
+ new Date(finishedAt).toLocaleTimeString(),
359
+ ),
360
+ );
361
+ endWatch();
362
+ return;
363
+ }
364
+ // Stale ok from before the publish (or finishedAt dropped): the
365
+ // rebuild hasn't been observed yet — keep waiting.
366
+ return setText(i18n.buildBuildingUnknown || "");
367
+ }
368
+ if (state === "failed") {
369
+ const parts = [i18n.buildFailed || ""];
370
+ if (typeof status.error === "string" && status.error !== "") {
371
+ parts.push(status.error.slice(0, BUILD_ERROR_EXCERPT_CHARS));
372
+ }
373
+ parts.push(i18n.buildRetryHint || "");
374
+ setText(parts.filter(Boolean).join(" "));
375
+ endWatch(); // terminal: failed (the live site is unchanged)
376
+ return;
377
+ }
378
+ // unknown — or any unrecognized/garbage state — renders neutral copy
379
+ // and keeps polling (the writer may catch up).
380
+ setText(i18n.buildUnknown || "");
381
+ };
382
+
383
+ const fetchStatus = async () => {
384
+ try {
385
+ const response = await fetch(BUILD_STATUS_URL, {
386
+ headers: { Accept: "application/json" },
387
+ });
388
+ if (!response.ok) return null; // transient — caller keeps polling
389
+ return await response.json();
390
+ } catch {
391
+ return null; // network blip — the last rendered copy stands
392
+ }
393
+ };
394
+
395
+ const tick = async () => {
396
+ const status = await fetchStatus();
397
+ if (status) render(status);
398
+ };
399
+
400
+ const stamp = Number(sessionStorage.getItem(PUBLISH_WATCH_KEY));
401
+ if (Number.isFinite(stamp) && stamp > 0) {
402
+ // Mid-watch reload of the flash page: keep the original publish stamp.
403
+ publishedAt = stamp;
404
+ timer = setInterval(tick, BUILD_STATUS_POLL_MS);
405
+ tick();
406
+ return;
407
+ }
408
+
409
+ // No stamp: a fresh publish flash, or a reload after the watch ended.
410
+ // Probe FIRST — in probe mode (publishedAt 0) render() treats any ok
411
+ // with a finishedAt, and any failed, as terminal: show it, never watch.
412
+ (async () => {
413
+ const status = await fetchStatus();
414
+ if (status) render(status);
415
+ if (ended) return; // already terminal — the rendered copy is final
416
+ publishedAt = Date.now();
417
+ sessionStorage.setItem(PUBLISH_WATCH_KEY, String(publishedAt));
418
+ timer = setInterval(tick, BUILD_STATUS_POLL_MS);
419
+ })();
420
+ }
421
+
150
422
  const i18n = readI18n();
151
423
  initSortable();
152
424
  initAddDialog();
153
425
  initSearchFilter(i18n);
154
426
  initUndoFlash(i18n);
427
+ initPreviewPane(i18n);
428
+ initBuildStatus(i18n);
@@ -49,6 +49,14 @@ import {
49
49
  createDraftFromTree,
50
50
  } from "../storage/composition-draft.js";
51
51
  import { buildHomepageTree } from "../storage/migrate-v3-to-v4.js";
52
+ import {
53
+ getPreviewState,
54
+ ensureToken,
55
+ bumpRevision,
56
+ rotateToken,
57
+ } from "../storage/preview-state.js";
58
+ import { readBuildStatus } from "../storage/read-build-status.js";
59
+ import { writePreviewDraft } from "../render/write-preview-draft.js";
52
60
  import { treeToZones, zonesToTree } from "../editor/zones.js";
53
61
  import {
54
62
  addBlock,
@@ -162,6 +170,91 @@ export function parseUndoPayload(raw) {
162
170
  }
163
171
  }
164
172
 
173
+ // Stuck-build detection (spec §5.3): a "building" state that has overrun
174
+ // max(2 × lastOkDurationSeconds, 120) seconds. The 120s floor matters — the
175
+ // first post-boot build is FULL (~2min on the big site) while
176
+ // lastOkDurationSeconds usually reflects a fast incremental, so without the
177
+ // floor every reboot would flag a false stuck. 60 is the formula's default
178
+ // when the duration is absent or garbage (2 × 60 = the floor).
179
+ const STUCK_FLOOR_SECONDS = 120;
180
+ const STUCK_DEFAULT_OK_SECONDS = 60;
181
+
182
+ /**
183
+ * Is this build-status object an overdue ("stuck") build?
184
+ *
185
+ * Tolerant by contract: start.sh's crash wrapper drops fields, so a
186
+ * "building" object missing (or garbling) `startedAt` is NEVER stuck — we
187
+ * can't measure how long it has run, and a false "stuck" banner is worse
188
+ * than a quiet one.
189
+ *
190
+ * @param {object | null | undefined} status Parsed build-status content
191
+ * @param {number} nowMs Current epoch ms (injected for tests)
192
+ * @returns {boolean}
193
+ */
194
+ export function isStuckBuild(status, nowMs) {
195
+ if (status?.state !== "building") return false;
196
+ const startedAt =
197
+ typeof status.startedAt === "string" ? Date.parse(status.startedAt) : Number.NaN;
198
+ if (Number.isNaN(startedAt)) return false;
199
+ const lastOk =
200
+ typeof status.lastOkDurationSeconds === "number" && status.lastOkDurationSeconds > 0
201
+ ? status.lastOkDurationSeconds
202
+ : STUCK_DEFAULT_OK_SECONDS;
203
+ const thresholdMs = Math.max(2 * lastOk, STUCK_FLOOR_SECONDS) * 1000;
204
+ return nowMs - startedAt > thresholdMs;
205
+ }
206
+
207
+ /**
208
+ * The build-status API/locals shape: the raw file fields + computed `stuck`;
209
+ * absent/corrupt file (reader returned null) → a neutral unknown. An
210
+ * unrecognized `state` passes through untouched — the UI owns rendering
211
+ * anything unrecognized as unknown.
212
+ *
213
+ * `finishedAt` is the one field the view DATE-FORMATS (`| date` → date-fns
214
+ * parseISO, which crashes/garbles on non-ISO values), so an unparseable
215
+ * value is stripped here at the single shared merge point (API + GET
216
+ * locals): the view's ok branch is gated on the field and falls through to
217
+ * the neutral no-time copy instead of crashing the no-JS GET.
218
+ *
219
+ * @param {object | null} status Result of readBuildStatus
220
+ * @param {number} [nowMs=Date.now()]
221
+ * @returns {object} `{ ...status, stuck }` or `{ state: "unknown", stuck: false }`
222
+ */
223
+ export function mergeBuildStatus(status, nowMs = Date.now()) {
224
+ if (!status) return { state: "unknown", stuck: false };
225
+ const merged = { ...status, stuck: isStuckBuild(status, nowMs) };
226
+ if (
227
+ merged.finishedAt !== undefined &&
228
+ (typeof merged.finishedAt !== "string" || Number.isNaN(Date.parse(merged.finishedAt)))
229
+ ) {
230
+ delete merged.finishedAt; // merged is our own copy — the input stays untouched
231
+ }
232
+ return merged;
233
+ }
234
+
235
+ /**
236
+ * GET /design/api/build-status handler factory (Phase 5 S2). A passive fs
237
+ * read of /app/data/build-status.json — cheap enough for the publish strip's
238
+ * 5s polling. Cache-Control: no-store (a build status is stale the moment
239
+ * it's cached). NEVER 500s on an absent/corrupt file — the tolerant reader
240
+ * maps both to unknown.
241
+ *
242
+ * @param {object} [options] Test seams
243
+ * @param {() => Promise<object | null>} [options.readStatus]
244
+ * @param {() => number} [options.now]
245
+ * @returns {import("express").RequestHandler}
246
+ */
247
+ export function buildStatusHandler({ readStatus = readBuildStatus, now = Date.now } = {}) {
248
+ return async (request, response, next) => {
249
+ try {
250
+ response.set("Cache-Control", "no-store");
251
+ response.json(mergeBuildStatus(await readStatus(), now()));
252
+ } catch (error) {
253
+ next(error);
254
+ }
255
+ };
256
+ }
257
+
165
258
  /**
166
259
  * Surface redirect query params as flash template vars.
167
260
  * @param {object} [query]
@@ -305,10 +398,21 @@ function findNode(zones, blockId) {
305
398
  * @param {(prefix: string) => string} [overrides.idFactory]
306
399
  * @param {(doc: object) => Promise<unknown>} [overrides.writeArtifact]
307
400
  * Forwarded to publishDraft (defaults to the real artifact writer there)
401
+ * @param {(input: object) => Promise<unknown>} [overrides.writePreviewArtifact]
402
+ * Preview-draft writer (defaults to writePreviewDraft)
403
+ * @param {() => Promise<object | null>} [overrides.readStatus]
404
+ * build-status.json reader (defaults to readBuildStatus)
405
+ * @param {() => number} [overrides.now] Clock (stuck math test seam)
308
406
  * @returns {import("express").Router}
309
407
  */
310
408
  export function designRouter(Indiekit, overrides = {}) {
311
- const { idFactory = defaultIdFactory, writeArtifact } = overrides;
409
+ const {
410
+ idFactory = defaultIdFactory,
411
+ writeArtifact,
412
+ writePreviewArtifact = writePreviewDraft,
413
+ readStatus = readBuildStatus,
414
+ now = Date.now,
415
+ } = overrides;
312
416
  const router = express.Router();
313
417
 
314
418
  const catalogOf = () => Indiekit.config?.application?.blockCatalog || [];
@@ -361,9 +465,20 @@ export function designRouter(Indiekit, overrides = {}) {
361
465
  };
362
466
  if (!state) return { ...base, noComposition: true };
363
467
  const zones = treeToZones(state.tree);
468
+ // Custom-tree preview scope (deliberate, Phase 5): the preview POST
469
+ // accepts custom trees (they render fine through the production
470
+ // renderer), but this read-only view offers NO preview pane affordance —
471
+ // the editor is read-only for custom trees, full stop. Hand-built trees
472
+ // are previewed by their authors out-of-band.
364
473
  if (zones.custom) return { ...base, customTree: true, isDraft: state.isDraft };
365
474
  const token = typeof query.u === "string" ? query.u : null;
366
475
  const undo = token ? parseUndoPayload(token) : null;
476
+ // Right-pane mode is server state (?pane=preview) so the toggle works
477
+ // without JS by full reload; structural stays the default.
478
+ const pane = query.pane === "preview" ? "preview" : "structural";
479
+ const preview = Indiekit.database
480
+ ? await getPreviewState(Indiekit.database)
481
+ : { token: null, revision: 0 };
367
482
  return {
368
483
  ...base,
369
484
  zones,
@@ -372,6 +487,9 @@ export function designRouter(Indiekit, overrides = {}) {
372
487
  isDraft: state.isDraft,
373
488
  draftUpdatedAt: state.doc.draftUpdatedAt ?? null,
374
489
  undo: undo ? { ...undo, token } : null,
490
+ pane,
491
+ preview,
492
+ previewing: typeof query.previewing === "string" ? query.previewing : null,
375
493
  ...extra,
376
494
  };
377
495
  }
@@ -410,12 +528,24 @@ export function designRouter(Indiekit, overrides = {}) {
410
528
  const db = requireDb(response);
411
529
  if (!db) return;
412
530
  const state = await getEditorState(db, SURFACE_ID);
413
- response.render(EDITOR_VIEW, await editorLocals(state, request.query ?? {}));
531
+ const query = request.query ?? {};
532
+ // No-JS publish flow: ?published=1 renders the LAST-KNOWN build status
533
+ // server-side (same tolerant reader as the API) with a reload-to-update
534
+ // note; editor.js replaces it with 5s polling.
535
+ const extra = query.published
536
+ ? { buildStatus: mergeBuildStatus(await readStatus(), now()) }
537
+ : {};
538
+ response.render(EDITOR_VIEW, await editorLocals(state, query, extra));
414
539
  } catch (error) {
415
540
  next(error);
416
541
  }
417
542
  });
418
543
 
544
+ // -- build-status API (Phase 5 S2) --
545
+ // Authed (the whole design router mounts behind the session gate);
546
+ // polled every 5s by the publish strip in editor.js.
547
+ router.get("/api/build-status", buildStatusHandler({ readStatus, now }));
548
+
419
549
  router.post("/homepage/blocks/add", async (request, response, next) => {
420
550
  try {
421
551
  const db = requireDb(response);
@@ -665,6 +795,40 @@ export function designRouter(Indiekit, overrides = {}) {
665
795
  }
666
796
  });
667
797
 
798
+ // On-demand preview-draft write (Phase 5). NOTE: custom trees ARE allowed
799
+ // here — a custom tree renders through the same PRODUCTION renderer at
800
+ // /preview/<token>/; only the EDITOR (block ops) is read-only for custom
801
+ // trees. The artifact is written ONLY on this explicit POST and on publish
802
+ // (every write triggers an incremental Eleventy rebuild — ~25s on large
803
+ // sites), never per keystroke.
804
+ router.post("/homepage/preview", async (request, response, next) => {
805
+ try {
806
+ const db = requireDb(response);
807
+ if (!db) return;
808
+ const state = await getEditorState(db, SURFACE_ID);
809
+ // state.tree = draftTree ?? published tree; a doc with neither (never
810
+ // seeded, draft discarded) has nothing to preview.
811
+ if (!state?.tree) return flashError(response, "no-composition");
812
+ const token = await ensureToken(db);
813
+ const revision = await bumpRevision(db);
814
+ await writePreviewArtifact({ tree: state.tree, revision, token });
815
+ // Dual response (plain-POST-first): editor.js fetches with
816
+ // Accept: application/json; the no-JS form post gets the standard
817
+ // redirect and the GET locals carry token/revision.
818
+ if ((request.headers?.accept ?? "").includes("application/json")) {
819
+ const status = await readStatus();
820
+ const expectedSeconds =
821
+ typeof status?.lastOkDurationSeconds === "number"
822
+ ? status.lastOkDurationSeconds
823
+ : null;
824
+ return response.json({ token, revision, expectedSeconds });
825
+ }
826
+ return response.redirect(303, `${HOME}?pane=preview&previewing=${revision}`);
827
+ } catch (error) {
828
+ next(error);
829
+ }
830
+ });
831
+
668
832
  router.post("/homepage/publish", async (request, response, next) => {
669
833
  try {
670
834
  const db = requireDb(response);
@@ -673,7 +837,29 @@ export function designRouter(Indiekit, overrides = {}) {
673
837
  ...(writeArtifact ? { writeArtifact } : {}),
674
838
  updatedBy: userIdent(),
675
839
  });
676
- if (result.ok) return response.redirect(303, `${HOME}?published=1`);
840
+ if (result.ok) {
841
+ // Per-publish token rotation (spec §5.3): previously shared preview
842
+ // URLs expire — the old /preview/<token>/ page dies on the next
843
+ // rebuild, intentionally. A FRESH preview-draft is written from the
844
+ // now-published tree so the preview pane tracks the NEW token
845
+ // immediately. The publish itself already succeeded (db promotion +
846
+ // artifact) — a preview refresh failure must never turn it into an
847
+ // error page, so this block only warns.
848
+ try {
849
+ const token = await rotateToken(db);
850
+ const revision = await bumpRevision(db);
851
+ const published = await db.collection("compositions").findOne({ _id: SURFACE_ID });
852
+ if (published?.tree) {
853
+ await writePreviewArtifact({ tree: published.tree, revision, token });
854
+ }
855
+ } catch (error) {
856
+ console.warn(
857
+ "[site-config] preview rotation after publish failed:",
858
+ error?.message ?? String(error),
859
+ );
860
+ }
861
+ return response.redirect(303, `${HOME}?published=1`);
862
+ }
677
863
  if (result.error === "not-found") return flashError(response, "no-composition");
678
864
  if (result.error === "conflict") return flashError(response, "conflict");
679
865
  // The flash only says "failed validation" — log the actual validator
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Preview-draft artifact writer (site-builder Phase 5, spec §2.4/§5.3).
3
+ *
4
+ * The theme's `/preview/<token>/` page renders this artifact through the
5
+ * PRODUCTION composition renderer (zero drift from the live homepage).
6
+ * Written ON DEMAND only — the editor's explicit "Update preview" POST and
7
+ * the post-publish refresh — NEVER per keystroke: every artifact write
8
+ * triggers an incremental Eleventy rebuild (~25s on large sites).
9
+ *
10
+ * The artifact is BUILT from explicit arguments (never picked off a MongoDB
11
+ * doc), so editor-internal fields can't leak by construction — the same
12
+ * deliberate-contract posture as write-composition-json.js's whitelist.
13
+ *
14
+ * Uses tmp-file + rename so the Eleventy watcher never reads a partial file
15
+ * (a direct writeFile races the watcher and can crash the build on
16
+ * JSON.parse). Mirrors the atomic pattern in write-composition-json.js.
17
+ *
18
+ * The token is an unguessable-path token (defense in depth, rotated on
19
+ * publish) — it ends up in this public-ish artifact and the preview URL by
20
+ * design, but must never be logged.
21
+ * @module render/write-preview-draft
22
+ */
23
+
24
+ import { writeFile, rename, mkdir, unlink } from "node:fs/promises";
25
+ import { randomBytes } from "node:crypto";
26
+ import { join } from "node:path";
27
+
28
+ export const PREVIEW_DRAFT_FILE = "preview-draft.json";
29
+
30
+ const isoNow = () => new Date().toISOString();
31
+
32
+ /**
33
+ * Write the preview-draft artifact to disk atomically (tmp file → rename).
34
+ * Creates the output directory if it does not exist.
35
+ *
36
+ * @param {object} input
37
+ * @param {object} input.tree - Composition tree to preview (draft or published)
38
+ * @param {number} input.revision - Monotonic preview revision (preview-state.js)
39
+ * @param {string} input.token - Unguessable preview path token (preview-state.js)
40
+ * @param {string} [outputDir="/app/data/content/_data/compositions"] - Destination directory
41
+ * @param {object} [options]
42
+ * @param {() => string} [options.now] ISO timestamp factory (workspace rule:
43
+ * dates are ISO 8601 strings, never Date objects)
44
+ * @returns {Promise<string>} The output path written
45
+ */
46
+ export async function writePreviewDraft(
47
+ { tree, revision, token },
48
+ outputDir = "/app/data/content/_data/compositions",
49
+ options = {},
50
+ ) {
51
+ const { now = isoNow } = options;
52
+ // Explicit construction — exactly the spec §2.4 shape, nothing else.
53
+ const artifact = {
54
+ schemaVersion: 4,
55
+ kind: "preview",
56
+ tree,
57
+ revision,
58
+ token,
59
+ generatedAt: now(),
60
+ };
61
+
62
+ const outputPath = join(outputDir, PREVIEW_DRAFT_FILE);
63
+ await mkdir(outputDir, { recursive: true });
64
+ const tmp = `${outputPath}.${randomBytes(6).toString("hex")}.tmp`;
65
+ try {
66
+ await writeFile(tmp, JSON.stringify(artifact, undefined, 2), "utf8");
67
+ await rename(tmp, outputPath);
68
+ } catch (error) {
69
+ // Best-effort cleanup — don't leak tmp files into the watched _data dir.
70
+ await unlink(tmp).catch(() => {});
71
+ throw error;
72
+ }
73
+ return outputPath;
74
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Preview token + revision lifecycle (site-builder Phase 5, spec §5.3) —
3
+ * stored as sibling fields on the siteConfig doc (`previewToken`,
4
+ * `previewRevision`), the same same-doc posture as composition-draft.js.
5
+ *
6
+ * THE TOKEN: 16 random bytes, base64url (≈22 chars) — an unguessable-path
7
+ * token for `/preview/<token>/`, ephemeral defense-in-depth. It is NEVER an
8
+ * IndieAuth token and must never be logged. Rotated unconditionally on every
9
+ * publish so previously shared preview URLs expire (the old URL dies on the
10
+ * next rebuild — intentional, per-publish rotation policy).
11
+ *
12
+ * THE REVISION: a monotonic integer bumped on every preview-draft write. The
13
+ * theme's preview page embeds it (`data-preview-revision`); the editor polls
14
+ * the iframe until the just-written revision shows up.
15
+ *
16
+ * ATOMICITY: field-level updates only ($set / $setOnInsert / $inc — never a
17
+ * whole-doc replace), matching the composition-draft.js convention.
18
+ * ensureToken is read-back-after-atomic-write so concurrent boots/requests
19
+ * always converge on the single persisted token, never two.
20
+ *
21
+ * @module storage/preview-state
22
+ */
23
+
24
+ import { randomBytes } from "node:crypto";
25
+
26
+ const SITE_CONFIG_ID = "primary";
27
+
28
+ const defaultRandom = () => randomBytes(16).toString("base64url");
29
+
30
+ /**
31
+ * Read the current preview state from the siteConfig doc.
32
+ *
33
+ * @param {object} db MongoDB database handle (`collection(name)`)
34
+ * @returns {Promise<{ token: string | null, revision: number }>}
35
+ */
36
+ export async function getPreviewState(db) {
37
+ const doc = await db.collection("siteConfig").findOne({ _id: SITE_CONFIG_ID });
38
+ return {
39
+ token: doc?.previewToken ?? null,
40
+ revision: doc?.previewRevision ?? 0,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Return the persisted preview token, generating one atomically when absent.
46
+ *
47
+ * Two atomic field-level writes + a read-back: (1) $setOnInsert via upsert
48
+ * covers the no-doc case (a concurrent upsert race surfaces as a duplicate
49
+ * _id error — swallowed, the doc now exists either way); (2) a
50
+ * $exists-filtered $set covers a pre-existing doc that lacks the field
51
+ * (exactly one concurrent caller matches). The read-back is authoritative,
52
+ * so every concurrent caller returns the SAME persisted token — never two.
53
+ *
54
+ * @param {object} db
55
+ * @param {object} [options]
56
+ * @param {() => string} [options.random] Token factory (test seam)
57
+ * @returns {Promise<string>} The persisted token
58
+ */
59
+ export async function ensureToken(db, options = {}) {
60
+ const { random = defaultRandom } = options;
61
+ const candidate = random();
62
+ const siteConfig = db.collection("siteConfig");
63
+ try {
64
+ await siteConfig.updateOne(
65
+ { _id: SITE_CONFIG_ID },
66
+ { $setOnInsert: { previewToken: candidate } },
67
+ { upsert: true },
68
+ );
69
+ } catch (error) {
70
+ // E11000: a concurrent upsert inserted the doc first — fine, it exists.
71
+ if (error?.code !== 11_000) throw error;
72
+ }
73
+ await siteConfig.updateOne(
74
+ { _id: SITE_CONFIG_ID, previewToken: { $exists: false } },
75
+ { $set: { previewToken: candidate } },
76
+ );
77
+ const doc = await siteConfig.findOne({ _id: SITE_CONFIG_ID });
78
+ return doc.previewToken;
79
+ }
80
+
81
+ /**
82
+ * Atomically increment the preview revision and return the NEW value
83
+ * (findOneAndUpdate, not update-then-read — a racing bump can never make two
84
+ * callers see the same number).
85
+ *
86
+ * @param {object} db
87
+ * @returns {Promise<number>} The post-increment revision
88
+ */
89
+ export async function bumpRevision(db) {
90
+ const result = await db.collection("siteConfig").findOneAndUpdate(
91
+ { _id: SITE_CONFIG_ID },
92
+ { $inc: { previewRevision: 1 } },
93
+ { upsert: true, returnDocument: "after" },
94
+ );
95
+ // Driver v4/v5 wraps the doc in { value }; v6 returns it directly.
96
+ const doc = result?.value ?? result;
97
+ return doc.previewRevision;
98
+ }
99
+
100
+ /**
101
+ * Unconditionally replace the preview token (the publish path — every
102
+ * publish expires previously shared preview URLs).
103
+ *
104
+ * @param {object} db
105
+ * @param {object} [options]
106
+ * @param {() => string} [options.random] Token factory (test seam)
107
+ * @returns {Promise<string>} The new token
108
+ */
109
+ export async function rotateToken(db, options = {}) {
110
+ const { random = defaultRandom } = options;
111
+ const token = random();
112
+ await db.collection("siteConfig").updateOne(
113
+ { _id: SITE_CONFIG_ID },
114
+ { $set: { previewToken: token } },
115
+ { upsert: true },
116
+ );
117
+ return token;
118
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tolerant reader for the Eleventy build-status file (site-builder Phase 5,
3
+ * spec §2.4/§5.3). The file lives OUTSIDE the site output at
4
+ * `/app/data/build-status.json` — written by the theme's build hooks
5
+ * (building/ok) and start.sh's crash wrapper (failed); read here by the
6
+ * preview flow (S1: `lastOkDurationSeconds` → "~Xs on this site" copy) and
7
+ * the authed build-status API (S2).
8
+ *
9
+ * TOLERANT BY CONTRACT: absent, unreadable, corrupt, or non-object content
10
+ * all return null — the status file is an observability aid and must never
11
+ * take a request down. Callers own the "unknown" presentation.
12
+ *
13
+ * @module storage/read-build-status
14
+ */
15
+
16
+ import { readFile } from "node:fs/promises";
17
+
18
+ export const BUILD_STATUS_PATH = "/app/data/build-status.json";
19
+
20
+ /**
21
+ * Read and parse the build-status file.
22
+ *
23
+ * @param {string} [path=BUILD_STATUS_PATH] File path (test seam)
24
+ * @returns {Promise<object | null>} The parsed status object, or null when
25
+ * the file is absent/corrupt/not a plain object
26
+ */
27
+ export async function readBuildStatus(path = BUILD_STATUS_PATH) {
28
+ try {
29
+ const parsed = JSON.parse(await readFile(path, "utf8"));
30
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
31
+ return parsed;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
package/locales/en.json CHANGED
@@ -269,6 +269,30 @@
269
269
  },
270
270
  "preview": {
271
271
  "title": "Layout preview"
272
+ },
273
+ "previewPane": {
274
+ "toggleLabel": "Preview mode",
275
+ "structural": "Structural",
276
+ "live": "Preview",
277
+ "update": "Update preview",
278
+ "reload": "Reload preview",
279
+ "frameTitle": "Homepage preview",
280
+ "empty": "No preview yet — choose “Update preview” to build one.",
281
+ "requested": "Preview update requested — the site is rebuilding. Reload this page in a moment to refresh the preview.",
282
+ "building": "Building preview… (~{{seconds}}s on this site)",
283
+ "buildingUnknown": "Building preview…",
284
+ "ready": "Preview is up to date.",
285
+ "slow": "This is taking longer than usual — the preview will appear once the build finishes. Use “Reload preview” to check again."
286
+ },
287
+ "buildStatus": {
288
+ "building": "Rebuilding — usually ~{{seconds}}s on this site. Your current site stays online.",
289
+ "buildingUnknown": "Rebuilding — your current site stays online.",
290
+ "live": "Live · {{time}}",
291
+ "failed": "Build failed — your live site is unchanged.",
292
+ "retryHint": "Republish to retry.",
293
+ "stuck": "This is taking longer than usual. Publishing again rewrites the homepage and usually unblocks a stalled build.",
294
+ "unknown": "Build status unavailable — your live site is unchanged.",
295
+ "reloadNote": "Reload this page to update the build status."
272
296
  }
273
297
  },
274
298
  "homepage": {
package/locales/fr.json CHANGED
@@ -269,6 +269,30 @@
269
269
  },
270
270
  "preview": {
271
271
  "title": "Aperçu de la mise en page"
272
+ },
273
+ "previewPane": {
274
+ "toggleLabel": "Mode d'aperçu",
275
+ "structural": "Structure",
276
+ "live": "Aperçu",
277
+ "update": "Mettre à jour l'aperçu",
278
+ "reload": "Recharger l'aperçu",
279
+ "frameTitle": "Aperçu de la page d'accueil",
280
+ "empty": "Pas encore d'aperçu — choisissez « Mettre à jour l'aperçu » pour le générer.",
281
+ "requested": "Mise à jour de l'aperçu demandée — le site se reconstruit. Rechargez cette page dans un instant pour actualiser l'aperçu.",
282
+ "building": "Construction de l'aperçu… (~{{seconds}} s sur ce site)",
283
+ "buildingUnknown": "Construction de l'aperçu…",
284
+ "ready": "L'aperçu est à jour.",
285
+ "slow": "C'est plus long que d'habitude — l'aperçu apparaîtra à la fin de la construction. Utilisez « Recharger l'aperçu » pour vérifier."
286
+ },
287
+ "buildStatus": {
288
+ "building": "Reconstruction en cours — environ {{seconds}} s sur ce site. Votre site actuel reste en ligne.",
289
+ "buildingUnknown": "Reconstruction en cours — votre site actuel reste en ligne.",
290
+ "live": "En ligne · {{time}}",
291
+ "failed": "La construction a échoué — votre site en ligne est inchangé.",
292
+ "retryHint": "Republiez pour réessayer.",
293
+ "stuck": "C'est plus long que d'habitude. Republier réécrit la page d'accueil et débloque généralement une construction bloquée.",
294
+ "unknown": "Statut de construction indisponible — votre site en ligne est inchangé.",
295
+ "reloadNote": "Rechargez cette page pour actualiser le statut de construction."
272
296
  }
273
297
  },
274
298
  "homepage": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-site-config",
3
- "version": "1.0.0-beta.13",
3
+ "version": "1.0.0-beta.15",
4
4
  "type": "module",
5
5
  "description": "Site identity, branding, and navigation configuration for Indiekit",
6
6
  "main": "index.js",
@@ -0,0 +1,34 @@
1
+ {# True preview pane (Phase 5, right pane when ?pane=preview). The iframe is
2
+ the theme's /preview/<token>/ page — the preview-draft.json artifact
3
+ rendered through the PRODUCTION composition renderer (zero drift), same
4
+ origin as the admin. The artifact is written ONLY by the Update-preview
5
+ POST (and on publish) — never per keystroke (every write triggers an
6
+ incremental Eleventy rebuild).
7
+
8
+ No-JS path: the form POST redirects back with
9
+ ?pane=preview&previewing=<revision>; the status line says the site is
10
+ rebuilding and a full page reload refreshes the iframe. editor.js
11
+ enhances with fetch (Accept: application/json) + same-origin revision
12
+ polling. Context: preview {token, revision}, previewing. #}
13
+ <div class="sc-design__preview sc-design__preview--live">
14
+ <div class="sc-preview-bar">
15
+ <form method="post" action="/site-config/design/homepage/preview" class="sc-mini-form" data-sc-preview-form>
16
+ <button class="button button--small">{{ __("siteConfig.design.previewPane.update") }}</button>
17
+ </form>
18
+ {# JS-only manual reload affordance — editor.js reveals it. #}
19
+ <button type="button" class="button button--small button--secondary" hidden data-sc-preview-reload>
20
+ {{ __("siteConfig.design.previewPane.reload") }}
21
+ </button>
22
+ </div>
23
+ <p class="sc-preview-status" data-sc-preview-status aria-live="polite">
24
+ {%- if previewing %}{{ __("siteConfig.design.previewPane.requested") }}{% endif -%}
25
+ </p>
26
+ {% if preview.token %}
27
+ <iframe class="sc-preview-frame" data-sc-preview-frame
28
+ src="/preview/{{ preview.token }}/"
29
+ data-sc-revision="{{ preview.revision }}"
30
+ title="{{ __('siteConfig.design.previewPane.frameTitle') }}"></iframe>
31
+ {% else %}
32
+ <p class="sc-preview-empty" data-sc-preview-empty>{{ __("siteConfig.design.previewPane.empty") }}</p>
33
+ {% endif %}
34
+ </div>
@@ -30,9 +30,25 @@
30
30
  deferred classic scripts and modules both execute in document order. #}
31
31
  <script src="/assets/@rmdes-indiekit-endpoint-site-config/vendor/Sortable.min.js" defer></script>
32
32
  <script type="module" src="/assets/@rmdes-indiekit-endpoint-site-config/editor.js"></script>
33
+ {# i18n's __() interpolates {{var}} at RENDER time (mustache: a missing
34
+ variable becomes an empty string). Strings whose placeholders are replaced
35
+ CLIENT-side by editor.js must be passed through as themselves
36
+ (e.g. { seconds: "{{seconds}}" }) so the literal placeholder survives. #}
33
37
  {% set editorI18n = {
34
38
  searchEmpty: __("siteConfig.design.addBlock.searchEmpty"),
35
- dismiss: __("siteConfig.design.flash.dismiss")
39
+ dismiss: __("siteConfig.design.flash.dismiss"),
40
+ previewBuilding: __("siteConfig.design.previewPane.building", { seconds: "{{seconds}}" }),
41
+ previewBuildingUnknown: __("siteConfig.design.previewPane.buildingUnknown"),
42
+ previewReady: __("siteConfig.design.previewPane.ready"),
43
+ previewSlow: __("siteConfig.design.previewPane.slow"),
44
+ previewFrameTitle: __("siteConfig.design.previewPane.frameTitle"),
45
+ buildBuilding: __("siteConfig.design.buildStatus.building", { seconds: "{{seconds}}" }),
46
+ buildBuildingUnknown: __("siteConfig.design.buildStatus.buildingUnknown"),
47
+ buildLive: __("siteConfig.design.buildStatus.live", { time: "{{time}}" }),
48
+ buildFailed: __("siteConfig.design.buildStatus.failed"),
49
+ buildRetryHint: __("siteConfig.design.buildStatus.retryHint"),
50
+ buildStuck: __("siteConfig.design.buildStatus.stuck"),
51
+ buildUnknown: __("siteConfig.design.buildStatus.unknown")
36
52
  } %}
37
53
  <script type="application/json" id="sc-editor-i18n">{{ editorI18n | dump | replace("</", "<\\/") | safe }}</script>
38
54
 
@@ -229,12 +245,56 @@
229
245
  </form>
230
246
  {% else %}
231
247
  <p class="sc-draft-bar__status">{{ __("siteConfig.design.draftBar.live") }}</p>
248
+ {# Publish-flow build-status strip (Phase 5 S2): renders ONLY on the
249
+ publish flash (?published=1) with the LAST-KNOWN status server-side
250
+ (no-JS path; the noscript note says reload to update — no
251
+ meta-refresh). editor.js takes over with 5s polling of the
252
+ build-status API. Unrecognized states render the neutral copy. #}
253
+ {% if success == "published" %}
254
+ <div class="sc-build-status" data-sc-build-status aria-live="polite">
255
+ <p class="sc-build-status__text" data-sc-build-text>
256
+ {%- if buildStatus.state == "building" and buildStatus.stuck %}
257
+ {{ __("siteConfig.design.buildStatus.stuck") }}
258
+ {%- elif buildStatus.state == "building" %}
259
+ {%- if buildStatus.lastOkDurationSeconds is number and buildStatus.lastOkDurationSeconds > 0 %}
260
+ {{ __("siteConfig.design.buildStatus.building", { seconds: buildStatus.lastOkDurationSeconds | round }) }}
261
+ {%- else %}
262
+ {{ __("siteConfig.design.buildStatus.buildingUnknown") }}
263
+ {%- endif %}
264
+ {%- elif buildStatus.state == "ok" and buildStatus.finishedAt %}
265
+ <time datetime="{{ buildStatus.finishedAt }}">{{ __("siteConfig.design.buildStatus.live", { time: buildStatus.finishedAt | date("PPp") }) }}</time>
266
+ {%- elif buildStatus.state == "failed" %}
267
+ {{ __("siteConfig.design.buildStatus.failed") }}
268
+ {%- if buildStatus.error %} {{ buildStatus.error | truncate(140) }}{% endif %}
269
+ {{ __("siteConfig.design.buildStatus.retryHint") }}
270
+ {%- else %}
271
+ {{ __("siteConfig.design.buildStatus.unknown") }}
272
+ {%- endif %}
273
+ </p>
274
+ <noscript><p class="sc-build-status__note">{{ __("siteConfig.design.buildStatus.reloadNote") }}</p></noscript>
275
+ </div>
276
+ {% endif %}
232
277
  {% endif %}
233
278
  </div>
234
279
  </div>
235
280
 
236
- {# Right pane: structural preview #}
237
- {% include "partials/design-preview.njk" %}
281
+ {# Right pane: Structural (Phase 4 skeleton, default) | Preview (Phase 5
282
+ true preview — the theme renders preview-draft.json through the
283
+ PRODUCTION renderer at /preview/<token>/). Pane choice is server state
284
+ (?pane=preview) via plain links, so no-JS toggles by full reload. #}
285
+ <div class="sc-design__pane">
286
+ <nav class="sc-pane-toggle" aria-label="{{ __('siteConfig.design.previewPane.toggleLabel') }}">
287
+ <a class="sc-pane-toggle__link" href="/site-config/design/homepage"
288
+ {%- if pane != "preview" %} aria-current="true"{% endif %}>{{ __("siteConfig.design.previewPane.structural") }}</a>
289
+ <a class="sc-pane-toggle__link" href="/site-config/design/homepage?pane=preview"
290
+ {%- if pane == "preview" %} aria-current="true"{% endif %}>{{ __("siteConfig.design.previewPane.live") }}</a>
291
+ </nav>
292
+ {% if pane == "preview" %}
293
+ {% include "partials/design-preview-live.njk" %}
294
+ {% else %}
295
+ {% include "partials/design-preview.njk" %}
296
+ {% endif %}
297
+ </div>
238
298
  </div>
239
299
 
240
300
  {# The shared add-block dialog (one per page; zone preset by editor.js) #}