@backmatter/core 0.0.19 → 0.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/core",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "Experimental Backmatter SDK. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/src/config.ts CHANGED
@@ -63,6 +63,8 @@ export const DEFAULT_DIRS: Required<BackmatterDirs> = {
63
63
  };
64
64
  export const DEFAULT_LOCALE = "en";
65
65
  export const CLOUD_API_URL = "https://api.backmatter.app";
66
+ export const CLOUD_DASHBOARD_URL = "https://dash.backmatter.app";
67
+ export const LOCAL_DASHBOARD_URL = "http://localhost:5173";
66
68
 
67
69
  function withHttps(url: string): string {
68
70
  if (url.startsWith("http://") || url.startsWith("https://")) return url;
@@ -88,6 +90,29 @@ export function isDeployedHost(env: Record<string, string | undefined>): boolean
88
90
  );
89
91
  }
90
92
 
93
+ /**
94
+ * Dashboard origin the dev bar frames.
95
+ * Explicit URL, else `BACKMATTER_DASHBOARD_URL`, else the cloud dashboard on a
96
+ * deployed host, else the local Vite app. A loopback URL in `.env` is ignored
97
+ * during a local `next build`, so the built site does not keep pointing at the laptop.
98
+ */
99
+ export function resolveDashboardUrl(
100
+ explicit?: string,
101
+ env: Record<string, string | undefined> = envOf(),
102
+ ): string {
103
+ const fromProp = explicit?.trim();
104
+ const fromEnv = fromProp ? undefined : env.BACKMATTER_DASHBOARD_URL?.trim();
105
+ const candidate = fromProp || fromEnv || "";
106
+ const ignoreDevLoopback =
107
+ !fromProp &&
108
+ Boolean(candidate && isLoopbackApiUrl(candidate)) &&
109
+ env.NEXT_PHASE === "phase-production-build" &&
110
+ !onPlatformDeploy(env);
111
+ if (candidate && !ignoreDevLoopback) return stripTrailingSlash(candidate);
112
+ if (isDeployedHost(env)) return CLOUD_DASHBOARD_URL;
113
+ return LOCAL_DASHBOARD_URL;
114
+ }
115
+
91
116
  /** DevBar /me origin: explicit, else env, else cloud API on a deployed host. */
92
117
  export function resolveDevBarApiUrl(
93
118
  explicit: string | undefined,
package/src/enter.ts ADDED
@@ -0,0 +1,68 @@
1
+ // packages/core/src/enter.ts
2
+ // The dashboard is the identity provider. A site that needs a session sends
3
+ // the browser top-level to `{dashboard}/enter`, which mints a ticket from the
4
+ // first-party cookie and returns to `{site}/api/backmatter/preview?ticket=`.
5
+
6
+ export const ENTER_PATH = "/enter";
7
+ export const PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
8
+
9
+ export type EnterRequest = {
10
+ workspaceId: string;
11
+ /** Absolute URL on the site to land on after the session is set. */
12
+ returnUrl: string;
13
+ };
14
+
15
+ export function studioEnterUrl(dashboardUrl: string, req: EnterRequest): string {
16
+ const url = new URL(ENTER_PATH, dashboardUrl.replace(/\/+$/, "") + "/");
17
+ url.searchParams.set("workspace", req.workspaceId);
18
+ url.searchParams.set("return", req.returnUrl);
19
+ return url.toString();
20
+ }
21
+
22
+ export function parseEnterSearch(search: string): EnterRequest | null {
23
+ const params = new URLSearchParams(search);
24
+ const workspaceId = params.get("workspace")?.trim() ?? "";
25
+ const returnUrl = params.get("return")?.trim() ?? "";
26
+ if (!workspaceId || !returnUrl) return null;
27
+ return { workspaceId, returnUrl };
28
+ }
29
+
30
+ export type EnterTarget = {
31
+ /** Site origin the ticket is for. Must be one of the workspace's `siteUrls`. */
32
+ origin: string;
33
+ /** Same-origin path, with search, the ticket redirects to. */
34
+ redirect: string;
35
+ };
36
+
37
+ /**
38
+ * Resolve where a return URL points, allowing only origins the workspace has
39
+ * declared. Returns null for a foreign origin, a bad URL, or a non-http scheme.
40
+ */
41
+ export function resolveEnterTarget(returnUrl: string, siteUrls: readonly string[]): EnterTarget | null {
42
+ let url: URL;
43
+ try {
44
+ url = new URL(returnUrl);
45
+ } catch {
46
+ return null;
47
+ }
48
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
49
+ const allowed = new Set(
50
+ siteUrls
51
+ .map((raw) => {
52
+ try {
53
+ return new URL(raw).origin;
54
+ } catch {
55
+ return null;
56
+ }
57
+ })
58
+ .filter((o): o is string => Boolean(o)),
59
+ );
60
+ if (!allowed.has(url.origin)) return null;
61
+ const redirect = `${url.pathname}${url.search}` || "/";
62
+ return { origin: url.origin, redirect };
63
+ }
64
+
65
+ /** The URL the dashboard sends the browser to once it holds a ticket. */
66
+ export function previewEntryUrl(origin: string, ticket: string): string {
67
+ return `${origin}${PREVIEW_ENTRY_PATH}?ticket=${encodeURIComponent(ticket)}`;
68
+ }
package/src/fields.ts CHANGED
@@ -28,6 +28,12 @@ export type FieldDef<
28
28
  object?: string;
29
29
  /** Nested fields for object/array (inline or copied from defineObject). */
30
30
  fields?: Record<string, FieldDef>;
31
+ /**
32
+ * Name of the Studio input that edits this field, e.g. `"color"`. The site's
33
+ * `defineStudio({ fields })` maps names to components; unknown names fall
34
+ * back to the default input for the kind.
35
+ */
36
+ input?: string;
31
37
  };
32
38
 
33
39
  export type FieldSnapshot = {
@@ -36,20 +42,22 @@ export type FieldSnapshot = {
36
42
  relationTo?: string;
37
43
  object?: string;
38
44
  fields?: Record<string, FieldSnapshot>;
45
+ input?: string;
39
46
  };
40
47
 
41
48
  type ObjectLike = { name: string; fields: Record<string, FieldDef> };
42
49
 
43
- type Opts<R extends boolean> = { required?: R };
50
+ type Opts<R extends boolean> = { required?: R; input?: string };
44
51
 
45
52
  function base<K extends FieldKind, R extends boolean, To extends string>(
46
53
  kind: K,
47
- opts?: { required?: R; relationTo?: To },
54
+ opts?: { required?: R; relationTo?: To; input?: string },
48
55
  ): FieldDef<K, R, To> {
49
56
  return {
50
57
  kind,
51
58
  ...(opts?.required ? { required: opts.required } : {}),
52
59
  ...(opts?.relationTo ? { relationTo: opts.relationTo } : {}),
60
+ ...(opts?.input ? { input: opts.input } : {}),
53
61
  } as FieldDef<K, R, To>;
54
62
  }
55
63
 
@@ -59,10 +67,11 @@ function composite<K extends "object" | "array", R extends boolean>(
59
67
  opts?: Opts<R>,
60
68
  ): FieldDef<K, R> {
61
69
  const required = opts?.required ? { required: opts.required as R } : {};
70
+ const input = opts?.input ? { input: opts.input } : {};
62
71
  if (typeof def === "string") {
63
- return { kind, object: def, ...required };
72
+ return { kind, object: def, ...required, ...input };
64
73
  }
65
- return { kind, object: def.name, fields: def.fields, ...required };
74
+ return { kind, object: def.name, fields: def.fields, ...required, ...input };
66
75
  }
67
76
 
68
77
  export const field = {
@@ -86,6 +95,7 @@ export function serializeField(def: FieldDef): FieldSnapshot {
86
95
  ...(def.required ? { required: true } : {}),
87
96
  ...(def.relationTo ? { relationTo: def.relationTo } : {}),
88
97
  ...(def.object ? { object: def.object } : {}),
98
+ ...(def.input ? { input: def.input } : {}),
89
99
  };
90
100
  if (def.fields) {
91
101
  const fields: Record<string, FieldSnapshot> = {};
@@ -125,6 +135,9 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
125
135
  if (typeof value.object === "string" && value.object.length > 0) {
126
136
  snap.object = value.object;
127
137
  }
138
+ if (typeof value.input === "string" && value.input.length > 0) {
139
+ snap.input = value.input;
140
+ }
128
141
  if (value.fields && typeof value.fields === "object" && !Array.isArray(value.fields)) {
129
142
  const fields: Record<string, FieldSnapshot> = {};
130
143
  for (const [name, nested] of Object.entries(value.fields as Record<string, unknown>)) {
package/src/index.ts CHANGED
@@ -58,9 +58,12 @@ export {
58
58
  detectSiteUrl,
59
59
  isDeployedHost,
60
60
  resolveDevBarApiUrl,
61
+ resolveDashboardUrl,
61
62
  DEFAULT_DIRS,
62
63
  DEFAULT_LOCALE,
63
64
  CLOUD_API_URL,
65
+ CLOUD_DASHBOARD_URL,
66
+ LOCAL_DASHBOARD_URL,
64
67
  } from "./config";
65
68
 
66
69
  export type { RouteMatch, RouteModel, MatchRouteOptions } from "./routes";
@@ -165,3 +168,14 @@ export {
165
168
  revalidateTagsForCommit,
166
169
  revalidateUrl,
167
170
  } from "./content-committed";
171
+
172
+ export {
173
+ ENTER_PATH,
174
+ PREVIEW_ENTRY_PATH,
175
+ parseEnterSearch,
176
+ previewEntryUrl,
177
+ resolveEnterTarget,
178
+ studioEnterUrl,
179
+ type EnterRequest,
180
+ type EnterTarget,
181
+ } from "./enter";
package/src/node/cli.ts CHANGED
@@ -359,6 +359,44 @@ export const { GET, POST, PUT, DELETE } = createCmsProxy(cms);
359
359
  `;
360
360
  }
361
361
 
362
+ const STUDIO_DIR = path.join("(backmatter)", "backmatter");
363
+
364
+ function renderStudioLayout(): string {
365
+ return `// app/(backmatter)/backmatter/layout.tsx — own root layout so the site's
366
+ // header, footer, fonts and global CSS do not wrap the Studio.
367
+ export { StudioLayout as default } from "@backmatter/next/studio";
368
+ `;
369
+ }
370
+
371
+ function renderStudioPage(studioImport: string): string {
372
+ return `// app/(backmatter)/backmatter/[[...path]]/page.tsx — the Backmatter Studio,
373
+ // mounted on this site. Editors sign in at the dashboard; the Studio talks to
374
+ // /api/backmatter/cms on this origin with the site session cookie.
375
+ import { createStudioPage } from "@backmatter/next/studio";
376
+ import { cms } from "${REGISTRY_SPECIFIER}";
377
+ import studio from "${studioImport}";
378
+
379
+ export default createStudioPage(cms, { studio });
380
+ `;
381
+ }
382
+
383
+ function renderStudioDefinition(): string {
384
+ return `// backmatter.studio.tsx — how the Studio looks and which inputs it uses on
385
+ // this site. A "use client" module: field inputs and block previews are
386
+ // ordinary React components bundled by the site.
387
+ "use client";
388
+
389
+ import { defineStudio } from "@backmatter/studio/define";
390
+
391
+ export default defineStudio({
392
+ // theme: { accent: "#1d4ed8" },
393
+ // fields: { color: ColorInput }, // field.text({ input: "color" })
394
+ // blocks: { Hero: { preview: HeroPreview } },
395
+ menu: [{ label: "Open site", href: "/" }],
396
+ });
397
+ `;
398
+ }
399
+
362
400
  function renderRevalidateRoute(): string {
363
401
  return `// api/backmatter/revalidate/route.ts — content.committed from the BACKMATTER server.
364
402
  import { createRevalidateRoute } from "@backmatter/next/revalidate";
@@ -433,6 +471,37 @@ export async function runInit(args: CliArgs): Promise<string[]> {
433
471
  notes.push(`kept existing ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
434
472
  }
435
473
 
474
+ const studioDefinition = path.join(root, "backmatter.studio.tsx");
475
+ if (!existsSync(studioDefinition)) {
476
+ await writeFile(studioDefinition, renderStudioDefinition());
477
+ notes.push("wrote backmatter.studio.tsx");
478
+ } else {
479
+ notes.push("kept existing backmatter.studio.tsx");
480
+ }
481
+
482
+ const studioDir = path.join(root, appDir, STUDIO_DIR);
483
+ const studioLayout = path.join(studioDir, "layout.tsx");
484
+ if (!existsSync(studioLayout)) {
485
+ await mkdir(studioDir, { recursive: true });
486
+ await writeFile(studioLayout, renderStudioLayout());
487
+ notes.push(`wrote ${path.join(appDir, STUDIO_DIR, "layout.tsx")}`);
488
+ } else {
489
+ notes.push(`kept existing ${path.join(appDir, STUDIO_DIR, "layout.tsx")}`);
490
+ }
491
+ const studioPageDir = path.join(studioDir, "[[...path]]");
492
+ const studioPage = path.join(studioPageDir, "page.tsx");
493
+ if (!existsSync(studioPage)) {
494
+ await mkdir(studioPageDir, { recursive: true });
495
+ const studioImport = path
496
+ .relative(studioPageDir, path.join(root, "backmatter.studio"))
497
+ .split(path.sep)
498
+ .join("/");
499
+ await writeFile(studioPage, renderStudioPage(studioImport));
500
+ notes.push(`wrote ${path.join(appDir, STUDIO_DIR, "[[...path]]", "page.tsx")}`);
501
+ } else {
502
+ notes.push(`kept existing ${path.join(appDir, STUDIO_DIR, "[[...path]]", "page.tsx")}`);
503
+ }
504
+
436
505
  const proxyFile = path.join(root, "proxy.ts");
437
506
  const srcProxy = path.join(root, "src", "proxy.ts");
438
507
  const middlewareFile = path.join(root, "middleware.ts");