@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.
- package/dist/config.cjs +71 -6
- package/dist/loader.cjs +71 -5
- package/package.json +12 -5
- package/src/cms-proxy.ts +166 -0
- package/src/config.ts +7 -1
- package/src/css.d.ts +2 -0
- package/src/devbar-client.tsx +136 -17
- package/src/devbar.tsx +20 -3
- package/src/gate.ts +85 -0
- package/src/layout-doc.ts +38 -0
- package/src/loader.ts +6 -2
- package/src/page-render.ts +7 -3
- package/src/preview-content.ts +49 -0
- package/src/preview-live.tsx +9 -0
- package/src/preview-refresh.tsx +211 -3
- package/src/preview-route.ts +90 -21
- package/src/render-preview.ts +186 -0
- package/src/request-url.ts +13 -0
- package/src/revalidate.ts +2 -2
- package/src/routes.ts +207 -45
- package/src/site-channel.ts +37 -0
- package/src/site-doc-bridge.tsx +50 -0
- package/src/site-doc.ts +123 -0
- package/src/site-editor.tsx +353 -0
- package/src/source-data.ts +42 -0
- package/src/studio-client.tsx +62 -0
- package/src/studio.tsx +144 -0
package/src/routes.ts
CHANGED
|
@@ -4,19 +4,35 @@
|
|
|
4
4
|
// `route` template (including `/` via indexSlug), loads it and renders MDX
|
|
5
5
|
// or JSON pages. An optional catch-all is required so `/` is a CMS route.
|
|
6
6
|
import { Fragment, Suspense, createElement, type ReactNode } from "react";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_TEMPLATE_SLUG,
|
|
9
|
+
isRoutable,
|
|
10
|
+
isTemplateSlug,
|
|
11
|
+
matchRoute,
|
|
12
|
+
PREVIEW_CHECKOUT_QUERY,
|
|
13
|
+
PREVIEW_COOKIE,
|
|
14
|
+
readPreviewCookie,
|
|
15
|
+
routeSegments,
|
|
16
|
+
type BackmatterDoc,
|
|
17
|
+
type ModelDef,
|
|
18
|
+
type SourceDef,
|
|
19
|
+
} from "@backmatter/core";
|
|
8
20
|
import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
|
|
9
21
|
import { fetchPreviewDoc } from "@backmatter/core/preview";
|
|
10
|
-
import {
|
|
22
|
+
import type { MdxComponents } from "./mdx-compile";
|
|
11
23
|
import { cachedRouteParams, getCachedPublished, registerPublishedRender } from "./cached-doc";
|
|
12
|
-
import {
|
|
24
|
+
import type { BlockComponents, BlockFallbacks } from "./page-render";
|
|
25
|
+
import { renderLayoutDoc, type DocLayout } from "./layout-doc";
|
|
26
|
+
import { previewContentForCheckout, runWithPreviewContent } from "./preview-content";
|
|
13
27
|
import { previewUnavailable } from "./preview-ui";
|
|
28
|
+
import { SiteDocBridge } from "./site-doc-bridge";
|
|
29
|
+
import { requireSource, resolveSourceData } from "./source-data";
|
|
14
30
|
|
|
15
31
|
export const CATCH_ALL_PARAM = "backmatter";
|
|
16
32
|
|
|
17
33
|
export type CatchAllParams = { [CATCH_ALL_PARAM]?: string[] };
|
|
18
34
|
|
|
19
|
-
export type DocLayout
|
|
35
|
+
export type { DocLayout };
|
|
20
36
|
|
|
21
37
|
export type CatchAllOptions = {
|
|
22
38
|
/** MDX component map, usually the generated `components`. */
|
|
@@ -27,6 +43,12 @@ export type CatchAllOptions = {
|
|
|
27
43
|
fallbacks?: BlockFallbacks;
|
|
28
44
|
/** Per-type wrappers, usually the generated `layouts`. */
|
|
29
45
|
layouts?: Record<string, DocLayout>;
|
|
46
|
+
/**
|
|
47
|
+
* External record loaders for `external` models, usually the generated
|
|
48
|
+
* `sources`. The record reaches blocks as `props.data` and layouts as the
|
|
49
|
+
* third argument. A missing record is a 404.
|
|
50
|
+
*/
|
|
51
|
+
sources?: Record<string, SourceDef>;
|
|
30
52
|
/** Overrides `cms.config.locales`. */
|
|
31
53
|
locales?: readonly string[];
|
|
32
54
|
/** Overrides `cms.config.defaultLocale`. */
|
|
@@ -37,6 +59,23 @@ export type CatchAllOptions = {
|
|
|
37
59
|
render?: (doc: BackmatterDoc, body: ReactNode) => ReactNode;
|
|
38
60
|
/** Hook for tests; defaults to Next's `notFound()`. */
|
|
39
61
|
notFound?: () => never;
|
|
62
|
+
/**
|
|
63
|
+
* Generated server action (`.backmatter/preview-action`). When set, preview
|
|
64
|
+
* swaps the body from editor messages instead of refreshing the route.
|
|
65
|
+
*/
|
|
66
|
+
renderPreview?: (
|
|
67
|
+
ref: {
|
|
68
|
+
locale: string;
|
|
69
|
+
type: string;
|
|
70
|
+
slug: string;
|
|
71
|
+
branch?: string;
|
|
72
|
+
tree?: string;
|
|
73
|
+
},
|
|
74
|
+
doc: { frontmatter: Record<string, unknown>; body: string },
|
|
75
|
+
checkoutToken?: string,
|
|
76
|
+
) => Promise<{ ok: true; node: ReactNode } | { ok: false; error: string }>;
|
|
77
|
+
/** Origins allowed to push preview messages. Defaults to the embedding page's referrer. */
|
|
78
|
+
dashboardOrigins?: readonly string[];
|
|
40
79
|
/**
|
|
41
80
|
* Test/host injection. When omitted, the catch-all reads Next draftMode
|
|
42
81
|
* and the signed preview cookie. Never call cookies() on the live path.
|
|
@@ -160,43 +199,127 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
|
|
|
160
199
|
const generateStaticParams: CatchAllRoute["generateStaticParams"] = async () => {
|
|
161
200
|
const docs = await cms.listDocs();
|
|
162
201
|
const params: CatchAllParams[] = [];
|
|
202
|
+
const seen = new Set<string>();
|
|
203
|
+
// Keyed on the URL: a route without `{locale}` yields one page per slug.
|
|
204
|
+
const push = (model: ModelDef, locale: string, slug: string) => {
|
|
205
|
+
const segments = routeSegments(model, locale, slug);
|
|
206
|
+
const key = segments.join("/");
|
|
207
|
+
if (seen.has(key)) return;
|
|
208
|
+
seen.add(key);
|
|
209
|
+
params.push({ [CATCH_ALL_PARAM]: segments });
|
|
210
|
+
};
|
|
163
211
|
for (const doc of docs) {
|
|
164
212
|
const model = byType.get(doc.type);
|
|
165
213
|
if (!model || !isRoutable(model)) continue;
|
|
166
|
-
|
|
214
|
+
// The template document has no page of its own.
|
|
215
|
+
if (isTemplateSlug(model, doc.slug)) continue;
|
|
216
|
+
push(model, doc.locale, doc.slug);
|
|
217
|
+
}
|
|
218
|
+
// Catalog records that never got a CMS file still prerender through the template.
|
|
219
|
+
const locales = matchOptions.locales ?? [matchOptions.defaultLocale];
|
|
220
|
+
for (const model of models) {
|
|
221
|
+
if (!isRoutable(model)) continue;
|
|
222
|
+
const list = options.sources?.[model.type]?.list;
|
|
223
|
+
if (!list) continue;
|
|
224
|
+
for (const locale of locales) {
|
|
225
|
+
for (const slug of await list(locale)) push(model, locale, slug);
|
|
226
|
+
}
|
|
167
227
|
}
|
|
168
228
|
return params;
|
|
169
229
|
};
|
|
170
230
|
|
|
171
231
|
const failFor = options.notFound ?? nextNotFound;
|
|
172
232
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
233
|
+
type GetDocFn = (locale: string, type: string, slug: string) => Promise<BackmatterDoc | null>;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Document plus external record for a route hit, or null for a 404.
|
|
237
|
+
* External types: no record → null; no per-slug doc → the template doc.
|
|
238
|
+
* The template slug itself only renders when `allowTemplate` (preview).
|
|
239
|
+
*/
|
|
240
|
+
async function loadRouteDoc(
|
|
241
|
+
hit: RouteHit,
|
|
242
|
+
getDoc: GetDocFn,
|
|
243
|
+
allowTemplate: boolean,
|
|
244
|
+
): Promise<{ doc: BackmatterDoc; data: unknown } | null> {
|
|
245
|
+
const model = byType.get(hit.type);
|
|
246
|
+
if (!model) return null;
|
|
247
|
+
const source = requireSource(options.sources, model);
|
|
248
|
+
const onTemplate = isTemplateSlug(model, hit.slug);
|
|
249
|
+
if (onTemplate && !allowTemplate) return null;
|
|
250
|
+
const resolved = await resolveSourceData(source, model, hit);
|
|
251
|
+
if (!resolved.ok) return null;
|
|
252
|
+
let doc = await getDoc(hit.locale, hit.type, hit.slug);
|
|
253
|
+
if (!doc && model.external && !onTemplate) {
|
|
254
|
+
const template = model.template ?? DEFAULT_TEMPLATE_SLUG;
|
|
255
|
+
doc = await getDoc(hit.locale, hit.type, template);
|
|
256
|
+
if (!doc) {
|
|
257
|
+
console.warn(
|
|
258
|
+
`[backmatter] ${hit.type}/${hit.slug} has a record but no document; add content/${hit.locale}/${hit.type}/${template} to render every ${hit.type}`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!doc) return null;
|
|
263
|
+
return { doc, data: resolved.data };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function layoutDoc(
|
|
267
|
+
doc: BackmatterDoc,
|
|
268
|
+
preview: { enabled: boolean; branch?: string; tree?: string },
|
|
269
|
+
data?: unknown,
|
|
270
|
+
): Promise<ReactNode> {
|
|
271
|
+
const { apiUrl, workspaceId, previewToken } = cms.config;
|
|
272
|
+
const content =
|
|
273
|
+
preview.enabled && apiUrl && workspaceId && previewToken
|
|
274
|
+
? previewContentForCheckout(
|
|
275
|
+
{ apiUrl, workspaceId, previewToken },
|
|
276
|
+
{ branch: preview.branch, tree: preview.tree },
|
|
277
|
+
)
|
|
278
|
+
: null;
|
|
279
|
+
const inner = await runWithPreviewContent(content, () =>
|
|
280
|
+
renderLayoutDoc(
|
|
281
|
+
doc,
|
|
282
|
+
{
|
|
283
|
+
models,
|
|
284
|
+
components: options.components,
|
|
285
|
+
blocks: options.blocks,
|
|
286
|
+
fallbacks: options.fallbacks,
|
|
287
|
+
layouts: options.layouts,
|
|
288
|
+
sources: options.sources,
|
|
289
|
+
render: options.render,
|
|
290
|
+
},
|
|
291
|
+
data,
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
if (!preview.enabled) return inner;
|
|
295
|
+
if (options.renderPreview) {
|
|
296
|
+
const { PreviewClient } = await import("./preview-refresh");
|
|
297
|
+
return createElement(
|
|
298
|
+
PreviewClient,
|
|
299
|
+
{
|
|
188
300
|
locale: doc.locale,
|
|
189
301
|
type: doc.type,
|
|
190
302
|
slug: doc.slug,
|
|
191
|
-
|
|
192
|
-
|
|
303
|
+
branch: preview.branch,
|
|
304
|
+
tree: preview.tree,
|
|
305
|
+
dashboardOrigins: options.dashboardOrigins ? [...options.dashboardOrigins] : [],
|
|
306
|
+
renderPreview: options.renderPreview,
|
|
307
|
+
},
|
|
308
|
+
inner,
|
|
193
309
|
);
|
|
194
310
|
}
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
311
|
+
// Hosts that have not generated `preview-action` keep the refresh fallback.
|
|
312
|
+
const { PreviewPageReloader } = await import("./preview-refresh");
|
|
313
|
+
return createElement(
|
|
314
|
+
"div",
|
|
315
|
+
null,
|
|
316
|
+
createElement(PreviewPageReloader, {
|
|
317
|
+
locale: doc.locale,
|
|
318
|
+
type: doc.type,
|
|
319
|
+
slug: doc.slug,
|
|
320
|
+
}),
|
|
321
|
+
inner,
|
|
322
|
+
);
|
|
200
323
|
}
|
|
201
324
|
|
|
202
325
|
async function loadPreviewDoc(
|
|
@@ -216,34 +339,54 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
|
|
|
216
339
|
},
|
|
217
340
|
ref,
|
|
218
341
|
));
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
type
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return layoutDoc(doc, true);
|
|
342
|
+
const loaded = await loadRouteDoc(
|
|
343
|
+
hit,
|
|
344
|
+
(locale, type, slug) => getDoc({ locale, type, slug, ...preview.checkout }),
|
|
345
|
+
true,
|
|
346
|
+
);
|
|
347
|
+
if (!loaded) return previewUnavailable("Document not found");
|
|
348
|
+
return layoutDoc(loaded.doc, { enabled: true, ...preview.checkout }, loaded.data);
|
|
227
349
|
} catch (err) {
|
|
228
350
|
return previewUnavailable(err instanceof Error ? err.message : "Failed to load");
|
|
229
351
|
}
|
|
230
352
|
}
|
|
231
353
|
|
|
232
354
|
async function renderPublished(locale: string, type: string, slug: string): Promise<ReactNode> {
|
|
233
|
-
const
|
|
234
|
-
if (!
|
|
355
|
+
const loaded = await loadRouteDoc({ locale, type, slug }, cms.getDoc, false);
|
|
356
|
+
if (!loaded) return null;
|
|
235
357
|
return createElement(
|
|
236
358
|
"div",
|
|
237
359
|
{ "data-backmatter-published": "", style: { display: "contents" } },
|
|
238
|
-
await layoutDoc(doc, false),
|
|
360
|
+
await layoutDoc(loaded.doc, { enabled: false }, loaded.data),
|
|
239
361
|
);
|
|
240
362
|
}
|
|
241
363
|
|
|
242
364
|
async function publishedBody(locale: string, type: string, slug: string): Promise<ReactNode> {
|
|
243
365
|
registerPublishedRender(renderPublished);
|
|
244
366
|
const body = await getCachedPublished(locale, type, slug);
|
|
245
|
-
if (body
|
|
246
|
-
|
|
367
|
+
if (body != null) return body;
|
|
368
|
+
// A tree-only document has no published body. 404 only when preview is off,
|
|
369
|
+
// so the preview branch can still render it. The dynamic read is on the miss path.
|
|
370
|
+
if (options.preview) {
|
|
371
|
+
const state: PreviewState = options.preview.enabled
|
|
372
|
+
? {
|
|
373
|
+
enabled: true,
|
|
374
|
+
checkout: options.preview.checkout ?? {},
|
|
375
|
+
...(options.preview.error ? { error: options.preview.error } : {}),
|
|
376
|
+
}
|
|
377
|
+
: { enabled: false };
|
|
378
|
+
if (previewReplacesPublished(state)) return null;
|
|
379
|
+
return failFor();
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
const mod = (await import("next/headers" as string)) as {
|
|
383
|
+
draftMode: () => Promise<{ isEnabled: boolean }>;
|
|
384
|
+
};
|
|
385
|
+
if ((await mod.draftMode()).isEnabled) return null;
|
|
386
|
+
} catch {
|
|
387
|
+
// No Next runtime (tests, static analysis): a miss is a 404.
|
|
388
|
+
}
|
|
389
|
+
return failFor();
|
|
247
390
|
}
|
|
248
391
|
|
|
249
392
|
const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params, searchParams }) => {
|
|
@@ -264,7 +407,9 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
|
|
|
264
407
|
const resolved = await params;
|
|
265
408
|
const hit = resolve(resolved[CATCH_ALL_PARAM]);
|
|
266
409
|
if (!hit) return failFor();
|
|
267
|
-
|
|
410
|
+
const body = await publishedBody(hit.locale, hit.type, hit.slug);
|
|
411
|
+
if (body == null) return body;
|
|
412
|
+
return createElement(Fragment, null, docBridge(hit), body);
|
|
268
413
|
};
|
|
269
414
|
|
|
270
415
|
const PreviewBranch = async ({
|
|
@@ -287,10 +432,23 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
|
|
|
287
432
|
__html: "[data-backmatter-published]{display:none!important}",
|
|
288
433
|
},
|
|
289
434
|
}),
|
|
435
|
+
docBridge(hit),
|
|
290
436
|
await loadPreviewDoc(hit, preview),
|
|
291
437
|
);
|
|
292
438
|
};
|
|
293
439
|
|
|
440
|
+
function docBridge(hit: { locale: string; type: string; slug: string }) {
|
|
441
|
+
if (!cms.config.workspaceId) return null;
|
|
442
|
+
return createElement(SiteDocBridge, {
|
|
443
|
+
doc: {
|
|
444
|
+
locale: hit.locale,
|
|
445
|
+
type: hit.type,
|
|
446
|
+
slug: hit.slug,
|
|
447
|
+
workspaceId: cms.config.workspaceId,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
294
452
|
const Page: CatchAllRoute["Page"] = (props) =>
|
|
295
453
|
createElement(
|
|
296
454
|
Fragment,
|
|
@@ -303,10 +461,14 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
|
|
|
303
461
|
const resolved = await cachedRouteParams(params);
|
|
304
462
|
const hit = resolve(resolved[CATCH_ALL_PARAM]);
|
|
305
463
|
if (!hit) return {};
|
|
306
|
-
const
|
|
307
|
-
if (!
|
|
308
|
-
const title = doc.frontmatter.title;
|
|
309
|
-
|
|
464
|
+
const loaded = await loadRouteDoc(hit, cms.getDoc, false);
|
|
465
|
+
if (!loaded) return {};
|
|
466
|
+
const title = loaded.doc.frontmatter.title;
|
|
467
|
+
if (typeof title === "string" && title.trim()) return { title };
|
|
468
|
+
const source = options.sources?.[hit.type];
|
|
469
|
+
const fromSource =
|
|
470
|
+
source?.metadata && loaded.data != null ? source.metadata(loaded.data).title : undefined;
|
|
471
|
+
return { title: fromSource ?? hit.slug };
|
|
310
472
|
};
|
|
311
473
|
|
|
312
474
|
return { Page, CmsRoute, generateStaticParams, generateMetadata, resolve };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// packages/next/src/site-channel.ts
|
|
2
|
+
// One in-memory preview channel for the on-site editor and the page it edits.
|
|
3
|
+
// The pair lives for the lifetime of the tab. Do not close it.
|
|
4
|
+
import { createLocalPreviewTransport, type PreviewTransport } from "@backmatter/core/preview";
|
|
5
|
+
|
|
6
|
+
const CHANNEL_KEY = "__backmatterSitePreview";
|
|
7
|
+
|
|
8
|
+
type ChannelPair = { editor: PreviewTransport; page: PreviewTransport };
|
|
9
|
+
|
|
10
|
+
function channel(): ChannelPair {
|
|
11
|
+
const host = globalThis as typeof globalThis & { [CHANNEL_KEY]?: ChannelPair };
|
|
12
|
+
if (!host[CHANNEL_KEY]) host[CHANNEL_KEY] = createLocalPreviewTransport();
|
|
13
|
+
return host[CHANNEL_KEY];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sitePageTransport(): PreviewTransport {
|
|
17
|
+
return channel().page;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function siteEditorTransport(): PreviewTransport {
|
|
21
|
+
return channel().editor;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function fanInPreviewTransports(parts: readonly PreviewTransport[]): PreviewTransport {
|
|
25
|
+
return {
|
|
26
|
+
send(message) {
|
|
27
|
+
for (const part of parts) part.send(message);
|
|
28
|
+
},
|
|
29
|
+
subscribe(listener) {
|
|
30
|
+
const stops = parts.map((part) => part.subscribe(listener));
|
|
31
|
+
return () => {
|
|
32
|
+
for (const stop of stops) stop();
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
close() {},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// packages/next/src/site-doc-bridge.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { sameSiteDoc, type SiteDocRef } from "./site-doc";
|
|
6
|
+
|
|
7
|
+
type Bus = {
|
|
8
|
+
current: SiteDocRef | null;
|
|
9
|
+
listeners: Set<(doc: SiteDocRef) => void>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const BUS_KEY = "__backmatterSiteDoc";
|
|
13
|
+
|
|
14
|
+
function bus(): Bus {
|
|
15
|
+
const host = globalThis as typeof globalThis & { [BUS_KEY]?: Bus };
|
|
16
|
+
if (!host[BUS_KEY]) host[BUS_KEY] = { current: null, listeners: new Set() };
|
|
17
|
+
return host[BUS_KEY];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function publishSiteDoc(doc: SiteDocRef) {
|
|
21
|
+
const channel = bus();
|
|
22
|
+
if (sameSiteDoc(channel.current, doc)) return;
|
|
23
|
+
channel.current = doc;
|
|
24
|
+
for (const listener of channel.listeners) listener(doc);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function subscribeSiteDoc(listener: (doc: SiteDocRef) => void): () => void {
|
|
28
|
+
const channel = bus();
|
|
29
|
+
channel.listeners.add(listener);
|
|
30
|
+
if (channel.current) listener(channel.current);
|
|
31
|
+
return () => channel.listeners.delete(listener);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Rendered by the CMS page so the bar hears the document for the current route. */
|
|
35
|
+
export function SiteDocBridge({ doc }: { doc: SiteDocRef }) {
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
publishSiteDoc(doc);
|
|
38
|
+
}, [doc.locale, doc.type, doc.slug, doc.workspaceId]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<span
|
|
42
|
+
hidden
|
|
43
|
+
data-backmatter-doc=""
|
|
44
|
+
data-locale={doc.locale}
|
|
45
|
+
data-type={doc.type}
|
|
46
|
+
data-slug={doc.slug}
|
|
47
|
+
data-workspace={doc.workspaceId}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
package/src/site-doc.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// packages/next/src/site-doc.ts
|
|
2
|
+
export type SiteDocRef = {
|
|
3
|
+
locale: string;
|
|
4
|
+
type: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function parseSiteDoc(content: string | null | undefined): SiteDocRef | null {
|
|
10
|
+
if (!content) return null;
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(content) as Record<string, unknown>;
|
|
13
|
+
if (
|
|
14
|
+
typeof value.locale !== "string" ||
|
|
15
|
+
typeof value.type !== "string" ||
|
|
16
|
+
typeof value.slug !== "string" ||
|
|
17
|
+
typeof value.workspaceId !== "string" ||
|
|
18
|
+
!value.locale ||
|
|
19
|
+
!value.type ||
|
|
20
|
+
!value.slug ||
|
|
21
|
+
!value.workspaceId
|
|
22
|
+
) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
locale: value.locale,
|
|
27
|
+
type: value.type,
|
|
28
|
+
slug: value.slug,
|
|
29
|
+
workspaceId: value.workspaceId,
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readSiteDoc(): SiteDocRef | null {
|
|
37
|
+
if (typeof document === "undefined") return null;
|
|
38
|
+
const marked = document.querySelector("[data-backmatter-doc]");
|
|
39
|
+
if (marked) {
|
|
40
|
+
const doc = {
|
|
41
|
+
locale: marked.getAttribute("data-locale") ?? "",
|
|
42
|
+
type: marked.getAttribute("data-type") ?? "",
|
|
43
|
+
slug: marked.getAttribute("data-slug") ?? "",
|
|
44
|
+
workspaceId: marked.getAttribute("data-workspace") ?? "",
|
|
45
|
+
};
|
|
46
|
+
if (doc.locale && doc.type && doc.slug && doc.workspaceId) return doc;
|
|
47
|
+
}
|
|
48
|
+
return parseSiteDoc(document.querySelector('meta[name="backmatter:doc"]')?.getAttribute("content"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sameSiteDoc(a: SiteDocRef | null, b: SiteDocRef | null): boolean {
|
|
52
|
+
if (a === b) return true;
|
|
53
|
+
if (!a || !b) return false;
|
|
54
|
+
return (
|
|
55
|
+
a.locale === b.locale &&
|
|
56
|
+
a.type === b.type &&
|
|
57
|
+
a.slug === b.slug &&
|
|
58
|
+
a.workspaceId === b.workspaceId
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type SiteBlock = {
|
|
63
|
+
id: string;
|
|
64
|
+
type: string;
|
|
65
|
+
props: Record<string, unknown>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function siteBlocks(value: unknown): SiteBlock[] {
|
|
69
|
+
if (!Array.isArray(value)) return [];
|
|
70
|
+
return value.filter((row): row is SiteBlock => {
|
|
71
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return false;
|
|
72
|
+
const block = row as Record<string, unknown>;
|
|
73
|
+
return (
|
|
74
|
+
typeof block.id === "string" &&
|
|
75
|
+
typeof block.type === "string" &&
|
|
76
|
+
!!block.props &&
|
|
77
|
+
typeof block.props === "object" &&
|
|
78
|
+
!Array.isArray(block.props)
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function moveSiteBlock(blocks: SiteBlock[], id: string, direction: -1 | 1): SiteBlock[] {
|
|
84
|
+
const at = blocks.findIndex((block) => block.id === id);
|
|
85
|
+
const next = at + direction;
|
|
86
|
+
if (at < 0 || next < 0 || next >= blocks.length) return blocks;
|
|
87
|
+
const copy = [...blocks];
|
|
88
|
+
const [row] = copy.splice(at, 1);
|
|
89
|
+
copy.splice(next, 0, row!);
|
|
90
|
+
return copy;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function patchSiteBlock(
|
|
94
|
+
blocks: SiteBlock[],
|
|
95
|
+
id: string,
|
|
96
|
+
prop: string,
|
|
97
|
+
value: unknown,
|
|
98
|
+
): SiteBlock[] {
|
|
99
|
+
return blocks.map((block) =>
|
|
100
|
+
block.id === id ? { ...block, props: { ...block.props, [prop]: value } } : block,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Append text from an AI SDK UI message stream chunk. Incomplete lines stay buffered. */
|
|
105
|
+
export function takeChatDeltas(buffer: string, chunk: string): { text: string; rest: string } {
|
|
106
|
+
const combined = buffer + chunk;
|
|
107
|
+
const lines = combined.split("\n");
|
|
108
|
+
const rest = lines.pop() ?? "";
|
|
109
|
+
let text = "";
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const trimmed = line.trim();
|
|
112
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
113
|
+
const payload = trimmed.slice(5).trim();
|
|
114
|
+
if (!payload || payload === "[DONE]") continue;
|
|
115
|
+
try {
|
|
116
|
+
const event = JSON.parse(payload) as { type?: string; delta?: string };
|
|
117
|
+
if (event.type === "text-delta" && typeof event.delta === "string") text += event.delta;
|
|
118
|
+
} catch {
|
|
119
|
+
// a non-JSON event is not assistant text
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { text, rest };
|
|
123
|
+
}
|