@backmatter/core 0.0.19 → 0.0.21
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 +1 -1
- package/src/config.ts +25 -0
- package/src/enter.ts +68 -0
- package/src/fields.ts +17 -4
- package/src/index.ts +14 -0
- package/src/node/cli.ts +69 -0
- package/src/preview.ts +20 -6
package/package.json
CHANGED
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");
|
package/src/preview.ts
CHANGED
|
@@ -67,6 +67,16 @@ export type DevMode = {
|
|
|
67
67
|
tree?: string;
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
/** Why the host session endpoint refused to open the bar. */
|
|
71
|
+
export type HostSessionReason = "invalid" | "anonymous" | "unavailable";
|
|
72
|
+
|
|
73
|
+
export type HostPreviewSession = {
|
|
74
|
+
enabled: boolean;
|
|
75
|
+
branch?: string;
|
|
76
|
+
tree?: string;
|
|
77
|
+
reason?: HostSessionReason;
|
|
78
|
+
};
|
|
79
|
+
|
|
70
80
|
export function resolveDevMode(session: {
|
|
71
81
|
enabled: boolean;
|
|
72
82
|
branch?: string;
|
|
@@ -79,28 +89,32 @@ export function resolveDevMode(session: {
|
|
|
79
89
|
};
|
|
80
90
|
}
|
|
81
91
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
function hostSessionReason(value: unknown): HostSessionReason {
|
|
93
|
+
if (value === "invalid" || value === "unavailable") return value;
|
|
94
|
+
return "anonymous";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function fetchHostPreviewSession(origin = ""): Promise<HostPreviewSession> {
|
|
85
98
|
try {
|
|
86
99
|
const res = await fetch(`${origin.replace(/\/$/, "")}/api/backmatter/preview?session=1`, {
|
|
87
100
|
credentials: "same-origin",
|
|
88
101
|
cache: "no-store",
|
|
89
102
|
});
|
|
90
|
-
if (!res.ok) return { enabled: false };
|
|
103
|
+
if (!res.ok) return { enabled: false, reason: "unavailable" };
|
|
91
104
|
const data = (await res.json()) as {
|
|
92
105
|
mode?: string;
|
|
93
106
|
branch?: string;
|
|
94
107
|
tree?: string;
|
|
108
|
+
reason?: unknown;
|
|
95
109
|
};
|
|
96
|
-
if (data.mode !== "preview") return { enabled: false };
|
|
110
|
+
if (data.mode !== "preview") return { enabled: false, reason: hostSessionReason(data.reason) };
|
|
97
111
|
return {
|
|
98
112
|
enabled: true,
|
|
99
113
|
...(typeof data.branch === "string" && data.branch ? { branch: data.branch } : {}),
|
|
100
114
|
...(typeof data.tree === "string" && data.tree ? { tree: data.tree } : {}),
|
|
101
115
|
};
|
|
102
116
|
} catch {
|
|
103
|
-
return { enabled: false };
|
|
117
|
+
return { enabled: false, reason: "unavailable" };
|
|
104
118
|
}
|
|
105
119
|
}
|
|
106
120
|
|