@backmatter/next 0.0.21 → 0.0.23

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.
@@ -1,7 +1,7 @@
1
1
  // packages/next/src/devbar-client.tsx
2
2
  "use client";
3
3
 
4
- import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
4
+ import { Suspense, lazy, useEffect, useState, type CSSProperties, type ReactNode } from "react";
5
5
  import {
6
6
  clearHostPreviewSession,
7
7
  fetchCmsSession,
@@ -9,6 +9,18 @@ import {
9
9
  resolveDevMode,
10
10
  type CmsSessionUser,
11
11
  } from "@backmatter/core/preview";
12
+ import {
13
+ ENTER_ATTEMPT_KEY,
14
+ enterPath,
15
+ gateOpen,
16
+ isGateShortcut,
17
+ markerCookie,
18
+ recentEnterAttempt,
19
+ stripOptInParam,
20
+ } from "./gate";
21
+
22
+ /** The bar and its panels load only after the gate opens. */
23
+ const SiteChrome = lazy(() => import("./site-editor").then((m) => ({ default: m.SiteChrome })));
12
24
 
13
25
  export type { CmsSessionUser };
14
26
 
@@ -80,7 +92,7 @@ const barStyle: CSSProperties = {
80
92
  alignItems: "center",
81
93
  gap: 10,
82
94
  minHeight: 32,
83
- maxWidth: "min(360px, calc(100vw - 24px))",
95
+ maxWidth: "min(520px, calc(100vw - 24px))",
84
96
  padding: "0 12px",
85
97
  paddingBottom: "env(safe-area-inset-bottom, 0px)",
86
98
  background: "#f4f4f5",
@@ -187,10 +199,12 @@ function Divider() {
187
199
  export function DevBarView({
188
200
  state,
189
201
  onExit,
202
+ onEdit,
190
203
  exiting,
191
204
  }: {
192
205
  state: DevModeState;
193
206
  onExit?: () => void;
207
+ onEdit?: () => void;
194
208
  exiting?: boolean;
195
209
  }) {
196
210
  if (state.status !== "ready") return null;
@@ -240,6 +254,20 @@ export function DevBarView({
240
254
  <Segment>draft</Segment>
241
255
  </>
242
256
  ) : null}
257
+ {!live && onEdit ? (
258
+ <>
259
+ <Divider />
260
+ <button
261
+ type="button"
262
+ className="backmatter-devbar-exit"
263
+ aria-label="Edit this page"
264
+ onClick={() => onEdit()}
265
+ style={exitButtonStyle}
266
+ >
267
+ Edit
268
+ </button>
269
+ </>
270
+ ) : null}
243
271
  {!live ? (
244
272
  <>
245
273
  <Divider />
@@ -259,22 +287,113 @@ export function DevBarView({
259
287
  );
260
288
  }
261
289
 
262
- export function DevBarClient({ apiUrl }: { apiUrl: string }) {
263
- const state = useDevMode({ apiUrl });
290
+ const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
291
+
292
+ type Gate = "closed" | "open";
293
+
294
+ /**
295
+ * Opt-in check with no network: `?backmatter=1` (then stripped from the URL and
296
+ * remembered in the marker cookie), the marker itself, or the desktop
297
+ * shortcut. Public visitors stop here.
298
+ */
299
+ function useGate(): Gate {
300
+ const [gate, setGate] = useState<Gate>("closed");
301
+
302
+ useEffect(() => {
303
+ // Framed by an editor (the Studio's split preview, the dashboard's split
304
+ // view): that editor is the chrome, so the page shows no bar of its own.
305
+ if (window.self !== window.top) return;
306
+ const secure = location.protocol === "https:";
307
+ if (gateOpen({ search: location.search, cookie: document.cookie })) {
308
+ document.cookie = markerCookie(true, secure);
309
+ const clean = stripOptInParam(location.href);
310
+ if (clean) history.replaceState(history.state, "", clean);
311
+ setGate("open");
312
+ return;
313
+ }
314
+ const onKey = (e: KeyboardEvent) => {
315
+ if (!isGateShortcut(e)) return;
316
+ e.preventDefault();
317
+ document.cookie = markerCookie(true, secure);
318
+ setGate("open");
319
+ };
320
+ window.addEventListener("keydown", onKey);
321
+ return () => window.removeEventListener("keydown", onKey);
322
+ }, []);
323
+
324
+ return gate;
325
+ }
326
+
327
+ /**
328
+ * Past the gate: ask this origin for the session. None means a top-level
329
+ * bounce to the dashboard, which returns here with a ticket. A return without
330
+ * a session (login abandoned, site not in the workspace) clears the marker
331
+ * instead of bouncing again.
332
+ */
333
+ function useSiteSession(gate: Gate, previewEntryPath: string): DevModeState {
334
+ const [state, setState] = useState<DevModeState>({ status: "hidden" });
335
+
336
+ useEffect(() => {
337
+ if (gate !== "open") return;
338
+ let cancelled = false;
339
+ void loadDevModeState("").then((next) => {
340
+ if (cancelled) return;
341
+ if (next.status === "ready" && next.mode === "preview") {
342
+ sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
343
+ setState(next);
344
+ return;
345
+ }
346
+ if (recentEnterAttempt(sessionStorage.getItem(ENTER_ATTEMPT_KEY), Date.now())) {
347
+ sessionStorage.removeItem(ENTER_ATTEMPT_KEY);
348
+ document.cookie = markerCookie(false, location.protocol === "https:");
349
+ setState({ status: "hidden" });
350
+ return;
351
+ }
352
+ sessionStorage.setItem(ENTER_ATTEMPT_KEY, String(Date.now()));
353
+ location.assign(enterPath(previewEntryPath, location.pathname + location.search + location.hash));
354
+ });
355
+ return () => {
356
+ cancelled = true;
357
+ };
358
+ }, [gate, previewEntryPath]);
359
+
360
+ return state;
361
+ }
362
+
363
+ export function DevBarClient({
364
+ studioPath,
365
+ previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
366
+ }: {
367
+ /** Unused since the bar stopped calling the CMS origin; kept for host layouts that still pass it. */
368
+ apiUrl?: string;
369
+ studioPath?: string;
370
+ previewEntryPath?: string;
371
+ }) {
372
+ const gate = useGate();
373
+ const state = useSiteSession(gate, previewEntryPath);
264
374
  const [exiting, setExiting] = useState(false);
265
- return (
266
- <DevBarView
267
- state={state}
268
- exiting={exiting}
269
- onExit={() => {
270
- if (exiting) return;
271
- setExiting(true);
272
- void exitHostPreview().then((ok) => {
273
- if (!ok) setExiting(false);
274
- });
275
- }}
276
- />
277
- );
375
+ const exit = () => {
376
+ if (exiting) return;
377
+ setExiting(true);
378
+ void exitHostPreview().then((ok) => {
379
+ if (!ok) setExiting(false);
380
+ });
381
+ };
382
+ if (state.status !== "ready") return null;
383
+ if (state.mode === "preview") {
384
+ return (
385
+ <Suspense fallback={null}>
386
+ <SiteChrome
387
+ branch={state.branch}
388
+ tree={state.tree}
389
+ exiting={exiting}
390
+ onExit={exit}
391
+ studioPath={studioPath}
392
+ />
393
+ </Suspense>
394
+ );
395
+ }
396
+ return <DevBarView state={state} exiting={exiting} onExit={exit} />;
278
397
  }
279
398
 
280
399
  export async function exitHostPreview(
package/src/devbar.tsx CHANGED
@@ -13,7 +13,24 @@ export {
13
13
  type DevModeState,
14
14
  } from "./devbar-client";
15
15
 
16
- /** Host layout: `<DevBar />`. Optional apiUrl for local/self-hosted CMS. */
17
- export function DevBar({ apiUrl }: { apiUrl?: string } = {}) {
18
- return <DevBarClient apiUrl={resolveDevBarApiUrl(apiUrl)} />;
16
+ /**
17
+ * Host layout: `<DevBar />`. Public visitors get a gate of a few hundred
18
+ * bytes that makes no request; `?backmatter=1`, the `bm_ui` marker cookie or
19
+ * ⌘⇧E opens it. `studioPath` is where `@backmatter/studio` is mounted on this
20
+ * site (default `/backmatter`); the bar's panels are frames of that route.
21
+ * `previewEntryPath` is the ticket route (default `/api/backmatter/preview`).
22
+ * `apiUrl` is accepted for older layouts and no longer used.
23
+ */
24
+ export function DevBar({
25
+ apiUrl,
26
+ studioPath,
27
+ previewEntryPath,
28
+ }: { apiUrl?: string; studioPath?: string; previewEntryPath?: string } = {}) {
29
+ return (
30
+ <DevBarClient
31
+ apiUrl={resolveDevBarApiUrl(apiUrl)}
32
+ studioPath={studioPath}
33
+ previewEntryPath={previewEntryPath}
34
+ />
35
+ );
19
36
  }
package/src/gate.ts ADDED
@@ -0,0 +1,85 @@
1
+ // packages/next/src/gate.ts
2
+ // The visitor gate in front of the editing bar. Pure helpers so the decision
3
+ // is testable without a DOM: a page opens the bar only when the visitor opted
4
+ // in through `?backmatter=1`, carries the marker cookie `bm_ui=1`, or presses
5
+ // the desktop shortcut. Anyone else gets no fetch and no editor code.
6
+
7
+ export const MARKER_COOKIE = "bm_ui";
8
+ export const OPT_IN_PARAM = "backmatter";
9
+ /** 30 days, per origin. Not HttpOnly: the gate reads it before any request. */
10
+ export const MARKER_TTL_S = 30 * 24 * 60 * 60;
11
+ /** Written when the gate bounces to the dashboard; read on return to stop a loop. */
12
+ export const ENTER_ATTEMPT_KEY = "backmatter:enter";
13
+ export const ENTER_ATTEMPT_WINDOW_MS = 60_000;
14
+
15
+ export function hasMarker(cookieHeader: string | null | undefined): boolean {
16
+ if (!cookieHeader) return false;
17
+ for (const part of cookieHeader.split(";")) {
18
+ const [key, ...rest] = part.trim().split("=");
19
+ if (key === MARKER_COOKIE) return rest.join("=") === "1";
20
+ }
21
+ return false;
22
+ }
23
+
24
+ export function hasOptInParam(search: string): boolean {
25
+ return new URLSearchParams(search).get(OPT_IN_PARAM) === "1";
26
+ }
27
+
28
+ export function gateOpen(input: { search: string; cookie: string | null | undefined }): boolean {
29
+ return hasOptInParam(input.search) || hasMarker(input.cookie);
30
+ }
31
+
32
+ /** `document.cookie` assignment that sets or clears the marker. */
33
+ export function markerCookie(on: boolean, secure = false): string {
34
+ const parts = [
35
+ `${MARKER_COOKIE}=${on ? "1" : ""}`,
36
+ "Path=/",
37
+ `Max-Age=${on ? MARKER_TTL_S : 0}`,
38
+ "SameSite=Lax",
39
+ ];
40
+ if (secure) parts.push("Secure");
41
+ return parts.join("; ");
42
+ }
43
+
44
+ /** The same href without `?backmatter=1`, or null when the param was absent. */
45
+ export function stripOptInParam(href: string): string | null {
46
+ const url = new URL(href);
47
+ if (url.searchParams.get(OPT_IN_PARAM) !== "1") return null;
48
+ url.searchParams.delete(OPT_IN_PARAM);
49
+ return url.toString();
50
+ }
51
+
52
+ /** ⌘⇧E on macOS, Ctrl+Shift+E elsewhere. Not bound by the major browsers. */
53
+ export function isGateShortcut(e: {
54
+ key: string;
55
+ metaKey: boolean;
56
+ ctrlKey: boolean;
57
+ shiftKey: boolean;
58
+ altKey: boolean;
59
+ }): boolean {
60
+ if (!e.shiftKey || e.altKey) return false;
61
+ if (!(e.metaKey || e.ctrlKey)) return false;
62
+ return e.key === "E" || e.key === "e";
63
+ }
64
+
65
+ /** Same-origin path only: must start with one `/`. Anything else goes home. */
66
+ export function safeReturnPath(raw: string | null | undefined): string {
67
+ const value = raw?.trim() ?? "";
68
+ if (!value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return "/";
69
+ return value;
70
+ }
71
+
72
+ /** The host route that bounces to the dashboard `enter` and comes back to `returnPath`. */
73
+ export function enterPath(previewEntryPath: string, returnPath: string): string {
74
+ return `${previewEntryPath}?enter=1&return=${encodeURIComponent(safeReturnPath(returnPath))}`;
75
+ }
76
+
77
+ /**
78
+ * Loop guard: true when a bounce started within the window, meaning we came
79
+ * back from the dashboard without a session. The caller then gives up and
80
+ * clears the marker instead of bouncing again.
81
+ */
82
+ export function recentEnterAttempt(stored: string | null, now: number): boolean {
83
+ const at = Number(stored);
84
+ return Number.isFinite(at) && at > 0 && now - at < ENTER_ATTEMPT_WINDOW_MS;
85
+ }
@@ -0,0 +1,38 @@
1
+ // packages/next/src/layout-doc.ts
2
+ // Shared body render for the catch-all and `renderPreview`. No preview chrome.
3
+ // `data` is the external source record already resolved by the caller
4
+ // (see source-data.ts); this module only threads it to blocks and the layout.
5
+ import { createElement, type ReactNode } from "react";
6
+ import { modelFormat, type BackmatterDoc, type ModelDef, type SourceDef } from "@backmatter/core";
7
+ import { renderDoc, type MdxComponents } from "./mdx-compile";
8
+ import { renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
9
+
10
+ export type DocLayout = (doc: BackmatterDoc, body: ReactNode, data?: unknown) => ReactNode;
11
+
12
+ export type LayoutDocOptions = {
13
+ models: readonly ModelDef[];
14
+ components?: MdxComponents;
15
+ blocks?: BlockComponents;
16
+ fallbacks?: BlockFallbacks;
17
+ layouts?: Record<string, DocLayout>;
18
+ /** `sources/{type}.ts` loaders, usually the generated `sources`. Resolved by the caller. */
19
+ sources?: Record<string, SourceDef>;
20
+ render?: (doc: BackmatterDoc, body: ReactNode) => ReactNode;
21
+ };
22
+
23
+ export async function renderLayoutDoc(
24
+ doc: BackmatterDoc,
25
+ options: LayoutDocOptions,
26
+ data?: unknown,
27
+ ): Promise<ReactNode> {
28
+ const model = options.models.find((item) => item.type === doc.type);
29
+ const isPage = Boolean(model && modelFormat(model) === "page");
30
+ const rendered = isPage
31
+ ? await renderPage(doc, options.blocks, options.fallbacks, data)
32
+ : await renderDoc(doc, { components: options.components });
33
+ const layout = options.layouts?.[doc.type];
34
+ const laidOut = layout ? layout(doc, rendered, data) : rendered;
35
+ if (options.render) return options.render(doc, laidOut);
36
+ if (layout || isPage) return laidOut;
37
+ return createElement("article", null, laidOut);
38
+ }
package/src/loader.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // packages/next/src/loader.ts
2
2
  // Bundler loader for `.backmatter/index.ts`, `.backmatter/blocks.ts`,
3
- // `.backmatter/mdx.ts`, `.backmatter/richtext.ts`, and `.backmatter/layouts.ts`.
3
+ // `.backmatter/mdx.ts`, `.backmatter/richtext.ts`, `.backmatter/layouts.ts`, and `.backmatter/sources.ts`.
4
4
  //
5
5
  // This is the Tailwind-JIT mechanism: instead of running our own watcher we
6
6
  // tell the bundler that these modules depend on the `models/`, `blocks/`,
7
- // `richtext/`, `mdx/`, and `layouts/` directories. webpack and Turbopack already watch the filesystem;
7
+ // `richtext/`, `mdx/`, `layouts/`, and `sources/` directories. webpack and Turbopack already watch the filesystem;
8
8
  // when a file is added, removed or renamed there they invalidate the module,
9
9
  // re-run this loader, and we regenerate the barrel from a fresh `readdir`.
10
10
  import path from "node:path";
@@ -19,6 +19,7 @@ export type BackmatterLoaderOptions = {
19
19
  mdxDir: string;
20
20
  layoutsDir: string;
21
21
  objectsDir: string;
22
+ sourcesDir: string;
22
23
  sdk: string;
23
24
  };
24
25
 
@@ -40,6 +41,7 @@ export default function backmatterLoader(this: BackmatterLoaderContext, source:
40
41
  const mdxDir = path.resolve(root, options.mdxDir);
41
42
  const layoutsDir = path.resolve(root, options.layoutsDir);
42
43
  const objectsDir = path.resolve(root, options.objectsDir);
44
+ const sourcesDir = path.resolve(root, options.sourcesDir);
43
45
 
44
46
  this.addContextDependency(modelsDir);
45
47
  this.addContextDependency(blocksDir);
@@ -47,6 +49,7 @@ export default function backmatterLoader(this: BackmatterLoaderContext, source:
47
49
  this.addContextDependency(mdxDir);
48
50
  this.addContextDependency(layoutsDir);
49
51
  this.addContextDependency(objectsDir);
52
+ this.addContextDependency(sourcesDir);
50
53
  this.cacheable?.(true);
51
54
 
52
55
  const result = generate({
@@ -58,6 +61,7 @@ export default function backmatterLoader(this: BackmatterLoaderContext, source:
58
61
  mdxDir: options.mdxDir,
59
62
  layoutsDir: options.layoutsDir,
60
63
  objectsDir: options.objectsDir,
64
+ sourcesDir: options.sourcesDir,
61
65
  sdk: options.sdk,
62
66
  });
63
67
  if (result.configFile) this.addDependency(result.configFile);
@@ -1,6 +1,6 @@
1
1
  // packages/next/src/page-render.ts
2
2
  import { createElement, type ComponentType, type ReactNode } from "react";
3
- import { imageUrlFromUnknown, type BackmatterDoc } from "@backmatter/core";
3
+ import { RESERVED_BLOCK_PROP, imageUrlFromUnknown, type BackmatterDoc } from "@backmatter/core";
4
4
 
5
5
  export type BlockModule = Record<string, unknown>;
6
6
  export type BlockLoader = (() => Promise<BlockModule>) & { $$backmatterLoader?: boolean };
@@ -75,11 +75,15 @@ export async function LazyBlock(props: {
75
75
  return createElement(Cmp, props.blockProps);
76
76
  }
77
77
 
78
- /** Render a page document's `blocks` array. Unknown types are skipped. */
78
+ /**
79
+ * Render a page document's `blocks` array. Unknown types are skipped.
80
+ * `data` is the external source record; every block receives it as `props.data`.
81
+ */
79
82
  export async function renderPage(
80
83
  doc: Pick<BackmatterDoc, "frontmatter">,
81
84
  components?: BlockComponents,
82
85
  _fallbacks?: BlockFallbacks,
86
+ data?: unknown,
83
87
  ): Promise<ReactNode> {
84
88
  const blocks = asBlocks(doc.frontmatter.blocks);
85
89
  const children = await Promise.all(
@@ -104,7 +108,7 @@ export async function renderPage(
104
108
  return null;
105
109
  }
106
110
  const key = typeof block.id === "string" ? block.id : undefined;
107
- return createElement(Cmp, { key, ...props });
111
+ return createElement(Cmp, { key, ...props, [RESERVED_BLOCK_PROP]: data });
108
112
  }),
109
113
  );
110
114
  const rendered = children.filter((child) => child != null);
@@ -0,0 +1,49 @@
1
+ // packages/next/src/preview-content.ts
2
+ //
3
+ // While a preview render is on the stack, cms.getDoc and cms.listDocs read the
4
+ // checkout (HEAD overlaid with that user's working tree). Published renders
5
+ // never enter this store, so they stay on the cached client.
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ import type { BackmatterDoc, BackmatterDocSummary } from "@backmatter/core";
8
+ import { fetchPreviewDoc, fetchPreviewDocs } from "@backmatter/core/preview";
9
+
10
+ export type PreviewContentConfig = {
11
+ apiUrl: string;
12
+ workspaceId: string;
13
+ previewToken: string;
14
+ };
15
+
16
+ export type PreviewContent = {
17
+ getDoc(locale: string, type: string, slug: string): Promise<BackmatterDoc | null>;
18
+ listDocs(filter?: { type?: string; locale?: string }): Promise<BackmatterDocSummary[]>;
19
+ };
20
+
21
+ const current = new AsyncLocalStorage<PreviewContent>();
22
+
23
+ export function previewContentForCheckout(
24
+ config: PreviewContentConfig,
25
+ checkout: { branch?: string; tree?: string },
26
+ ): PreviewContent {
27
+ const options = {
28
+ apiUrl: config.apiUrl,
29
+ workspaceId: config.workspaceId,
30
+ previewToken: config.previewToken,
31
+ };
32
+ return {
33
+ getDoc(locale, type, slug) {
34
+ return fetchPreviewDoc(options, { locale, type, slug, ...checkout });
35
+ },
36
+ listDocs(filter) {
37
+ return fetchPreviewDocs(options, { ...checkout, ...filter });
38
+ },
39
+ };
40
+ }
41
+
42
+ export function currentPreviewContent(): PreviewContent | null {
43
+ return current.getStore() ?? null;
44
+ }
45
+
46
+ export function runWithPreviewContent<T>(content: PreviewContent | null, fn: () => T): T {
47
+ if (!content) return fn();
48
+ return current.run(content, fn);
49
+ }
@@ -105,6 +105,15 @@ export function PreviewPageReloader({
105
105
  return null;
106
106
  }
107
107
 
108
+ export function PreviewRemoved() {
109
+ return (
110
+ <section>
111
+ <h1>Removed in your working tree</h1>
112
+ <p>This document is deleted until you commit or revert.</p>
113
+ </section>
114
+ );
115
+ }
116
+
108
117
  export function PreviewLiveSlot({ children }: { children?: ReactNode }) {
109
118
  return children ?? null;
110
119
  }