@backmatter/next 0.0.17 → 0.0.19

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 CHANGED
@@ -73,8 +73,8 @@ __export(exports_config, {
73
73
  withBackmatter: () => withBackmatter
74
74
  });
75
75
  module.exports = __toCommonJS(exports_config);
76
- var import_node_fs3 = require("node:fs");
77
- var import_node_path3 = __toESM(require("node:path"));
76
+ var import_node_fs4 = require("node:fs");
77
+ var import_node_path4 = __toESM(require("node:path"));
78
78
 
79
79
  // ../core/src/node/generate.ts
80
80
  var import_node_fs2 = require("node:fs");
@@ -398,16 +398,295 @@ function generate(options) {
398
398
  };
399
399
  }
400
400
 
401
+ // ../core/src/node/registry-import.ts
402
+ var import_node_fs3 = require("node:fs");
403
+ var import_node_path3 = __toESM(require("node:path"));
404
+ var REGISTRY_SPECIFIER = "backmatter";
405
+ function registryAliases() {
406
+ return REGISTRY_FILES.map((file) => {
407
+ const stem = file.slice(0, -".ts".length);
408
+ return {
409
+ specifier: stem === "index" ? REGISTRY_SPECIFIER : `${REGISTRY_SPECIFIER}/${stem}`,
410
+ file
411
+ };
412
+ });
413
+ }
414
+ function registryTurbopackAlias(appRoot, outDir = DEFAULT_OUT_DIR) {
415
+ const alias = {};
416
+ const base = import_node_path3.default.resolve(appRoot);
417
+ for (const { specifier, file } of registryAliases()) {
418
+ const abs = import_node_path3.default.resolve(appRoot, outDir, file);
419
+ alias[specifier] = asRelative(import_node_path3.default.relative(base, abs).split(import_node_path3.default.sep).join("/"));
420
+ }
421
+ return alias;
422
+ }
423
+ function registryWebpackAlias(root, outDir = DEFAULT_OUT_DIR) {
424
+ const alias = {};
425
+ for (const { specifier, file } of registryAliases()) {
426
+ alias[`${specifier}$`] = import_node_path3.default.resolve(root, outDir, file);
427
+ }
428
+ return alias;
429
+ }
430
+ function registryImportPaths(outDir = DEFAULT_OUT_DIR, baseUrl) {
431
+ const base = !baseUrl || baseUrl === "." ? "." : baseUrl;
432
+ const from = import_node_path3.default.resolve("/registry-root", base);
433
+ const to = import_node_path3.default.resolve("/registry-root", outDir);
434
+ const folder = asRelative(import_node_path3.default.relative(from, to).split(import_node_path3.default.sep).join("/"));
435
+ const paths = {};
436
+ for (const { specifier, file } of registryAliases()) {
437
+ paths[specifier] = [`${folder}/${file}`];
438
+ }
439
+ return paths;
440
+ }
441
+ function addRegistryPaths(source, outDir = DEFAULT_OUT_DIR) {
442
+ let parsed;
443
+ try {
444
+ parsed = looseParse(source);
445
+ } catch {
446
+ return source;
447
+ }
448
+ if (!isRecord(parsed))
449
+ return source;
450
+ const options = parsed.compilerOptions;
451
+ if (!isRecord(options))
452
+ return source;
453
+ const baseUrl = typeof options.baseUrl === "string" ? options.baseUrl : undefined;
454
+ const mappings = registryImportPaths(outDir, baseUrl);
455
+ const existing = isRecord(options.paths) ? options.paths : undefined;
456
+ if (options.paths !== undefined && !existing)
457
+ return source;
458
+ const missing = Object.fromEntries(Object.entries(mappings).filter(([key]) => !existing || existing[key] === undefined));
459
+ if (Object.keys(missing).length === 0)
460
+ return source;
461
+ const compiler = findKeyBrace(source, "compilerOptions", 0, source.length);
462
+ if (!compiler)
463
+ return source;
464
+ const compilerEnd = matchBrace(source, compiler.brace);
465
+ if (compilerEnd < 0)
466
+ return source;
467
+ const nl = source.includes(`\r
468
+ `) ? `\r
469
+ ` : `
470
+ `;
471
+ if (existing) {
472
+ const pathsBrace = findKeyBrace(source, "paths", compiler.brace + 1, compilerEnd);
473
+ if (!pathsBrace || pathsBrace.brace > compilerEnd)
474
+ return source;
475
+ const indent2 = innerIndent(source, pathsBrace.brace);
476
+ const entries2 = formatEntries(missing, indent2, nl);
477
+ return insertEntries(source, pathsBrace.brace, entries2, indent2, nl);
478
+ }
479
+ const indent = innerIndent(source, compiler.brace);
480
+ const child = `${indent} `;
481
+ const entries = formatEntries(missing, child, nl);
482
+ const block = `${indent}"paths": {${nl}${entries},${nl}${indent}}`;
483
+ return `${source.slice(0, compiler.brace + 1)}${nl}${block},${source.slice(compiler.brace + 1)}`;
484
+ }
485
+ function ensureRegistryTsconfig(root, outDir = DEFAULT_OUT_DIR) {
486
+ const tsconfig = import_node_path3.default.join(root, "tsconfig.json");
487
+ const jsconfig = import_node_path3.default.join(root, "jsconfig.json");
488
+ const file = import_node_fs3.existsSync(tsconfig) ? tsconfig : import_node_fs3.existsSync(jsconfig) ? jsconfig : null;
489
+ if (!file)
490
+ return null;
491
+ const before = import_node_fs3.readFileSync(file, "utf-8");
492
+ const after = addRegistryPaths(before, outDir);
493
+ if (after === before)
494
+ return null;
495
+ import_node_fs3.writeFileSync(file, after);
496
+ return `updated ${import_node_path3.default.basename(file)}`;
497
+ }
498
+ function asRelative(rel) {
499
+ if (rel.startsWith("./") || rel.startsWith("../"))
500
+ return rel;
501
+ return `./${rel}`;
502
+ }
503
+ function insertEntries(source, open, entries, indent, nl) {
504
+ let rest = source.slice(open + 1);
505
+ if (!/^\r?\n/.test(rest))
506
+ rest = `${nl}${indent}${rest.trimStart()}`;
507
+ return `${source.slice(0, open + 1)}${nl}${entries},${rest}`;
508
+ }
509
+ function formatEntries(mappings, indent, nl) {
510
+ return Object.entries(mappings).map(([key, value]) => `${indent}"${key}": [${value.map((entry) => JSON.stringify(entry)).join(", ")}]`).join(`,${nl}`);
511
+ }
512
+ function innerIndent(source, open) {
513
+ const rest = source.slice(open + 1);
514
+ const nl = rest.search(/\r?\n/);
515
+ if (nl !== -1) {
516
+ const after = rest.slice(nl).replace(/^\r?\n/, "");
517
+ const indent = after.match(/^[ \t]*/)?.[0] ?? "";
518
+ const next = after[indent.length];
519
+ if (indent.length > 0 && next && next !== "}")
520
+ return indent;
521
+ }
522
+ const lineStart = source.lastIndexOf(`
523
+ `, open) + 1;
524
+ const base = source.slice(lineStart, open).match(/^[ \t]*/)?.[0] ?? "";
525
+ return `${base} `;
526
+ }
527
+ function looseParse(source) {
528
+ const stripped = stripJsonComments(source).replace(/,\s*([}\]])/g, "$1");
529
+ return JSON.parse(stripped);
530
+ }
531
+ function stripJsonComments(input) {
532
+ let out = "";
533
+ let i = 0;
534
+ let inString = false;
535
+ let escaped = false;
536
+ while (i < input.length) {
537
+ const c = input[i];
538
+ const next = input[i + 1];
539
+ if (inString) {
540
+ out += c;
541
+ if (escaped)
542
+ escaped = false;
543
+ else if (c === "\\")
544
+ escaped = true;
545
+ else if (c === '"')
546
+ inString = false;
547
+ i++;
548
+ continue;
549
+ }
550
+ if (c === '"') {
551
+ inString = true;
552
+ out += c;
553
+ i++;
554
+ continue;
555
+ }
556
+ if (c === "/" && next === "/") {
557
+ i += 2;
558
+ while (i < input.length && input[i] !== `
559
+ `)
560
+ i++;
561
+ continue;
562
+ }
563
+ if (c === "/" && next === "*") {
564
+ i += 2;
565
+ while (i < input.length && !(input[i] === "*" && input[i + 1] === "/"))
566
+ i++;
567
+ i += 2;
568
+ continue;
569
+ }
570
+ out += c;
571
+ i++;
572
+ }
573
+ return out;
574
+ }
575
+ function isRecord(value) {
576
+ return typeof value === "object" && value !== null && !Array.isArray(value);
577
+ }
578
+ function findKeyBrace(source, key, start, end) {
579
+ const needle = `"${key}"`;
580
+ let i = start;
581
+ let inString = false;
582
+ let escaped = false;
583
+ while (i < end) {
584
+ const c = source[i];
585
+ const next = source[i + 1];
586
+ if (inString) {
587
+ if (escaped)
588
+ escaped = false;
589
+ else if (c === "\\")
590
+ escaped = true;
591
+ else if (c === '"')
592
+ inString = false;
593
+ i++;
594
+ continue;
595
+ }
596
+ if (c === '"') {
597
+ if (source.startsWith(needle, i)) {
598
+ let j = i + needle.length;
599
+ while (j < end && /\s/.test(source[j]))
600
+ j++;
601
+ if (source[j] === ":") {
602
+ j++;
603
+ while (j < end && /\s/.test(source[j]))
604
+ j++;
605
+ if (source[j] === "{")
606
+ return { brace: j };
607
+ }
608
+ }
609
+ inString = true;
610
+ i++;
611
+ continue;
612
+ }
613
+ if (c === "/" && next === "/") {
614
+ i += 2;
615
+ while (i < end && source[i] !== `
616
+ `)
617
+ i++;
618
+ continue;
619
+ }
620
+ if (c === "/" && next === "*") {
621
+ i += 2;
622
+ while (i < end && !(source[i] === "*" && source[i + 1] === "/"))
623
+ i++;
624
+ i += 2;
625
+ continue;
626
+ }
627
+ i++;
628
+ }
629
+ return null;
630
+ }
631
+ function matchBrace(source, open) {
632
+ let depth = 0;
633
+ let i = open;
634
+ let inString = false;
635
+ let escaped = false;
636
+ while (i < source.length) {
637
+ const c = source[i];
638
+ const next = source[i + 1];
639
+ if (inString) {
640
+ if (escaped)
641
+ escaped = false;
642
+ else if (c === "\\")
643
+ escaped = true;
644
+ else if (c === '"')
645
+ inString = false;
646
+ i++;
647
+ continue;
648
+ }
649
+ if (c === '"') {
650
+ inString = true;
651
+ i++;
652
+ continue;
653
+ }
654
+ if (c === "/" && next === "/") {
655
+ i += 2;
656
+ while (i < source.length && source[i] !== `
657
+ `)
658
+ i++;
659
+ continue;
660
+ }
661
+ if (c === "/" && next === "*") {
662
+ i += 2;
663
+ while (i < source.length && !(source[i] === "*" && source[i + 1] === "/"))
664
+ i++;
665
+ i += 2;
666
+ continue;
667
+ }
668
+ if (c === "{")
669
+ depth++;
670
+ else if (c === "}") {
671
+ depth--;
672
+ if (depth === 0)
673
+ return i;
674
+ }
675
+ i++;
676
+ }
677
+ return -1;
678
+ }
679
+
401
680
  // src/config.ts
402
681
  var LOADER = "@backmatter/next/loader";
403
682
  var SDK = "@backmatter/next";
404
683
  function warnMissingInstrumentation(root) {
405
684
  if (detectSdk(root) !== "@backmatter/next")
406
685
  return;
407
- const hasRoot = import_node_fs3.existsSync(import_node_path3.default.join(root, "instrumentation.ts"));
408
- const hasSrc = import_node_fs3.existsSync(import_node_path3.default.join(root, "src", "instrumentation.ts"));
686
+ const hasRoot = import_node_fs4.existsSync(import_node_path4.default.join(root, "instrumentation.ts"));
687
+ const hasSrc = import_node_fs4.existsSync(import_node_path4.default.join(root, "src", "instrumentation.ts"));
409
688
  if (!hasRoot && !hasSrc) {
410
- console.warn("backmatter: add instrumentation.ts that imports .backmatter/sync — block schema sync will not run without it");
689
+ console.warn("backmatter: add instrumentation.ts that imports backmatter/sync — block schema sync will not run without it");
411
690
  }
412
691
  }
413
692
  function defaultLog(result, root) {
@@ -423,7 +702,7 @@ function defaultLog(result, root) {
423
702
  }
424
703
  function resolveLoaderOptions(options = {}) {
425
704
  return {
426
- root: import_node_path3.default.resolve(options.root ?? process.cwd()),
705
+ root: import_node_path4.default.resolve(options.root ?? process.cwd()),
427
706
  outDir: options.outDir ?? DEFAULT_OUT_DIR,
428
707
  modelsDir: options.modelsDir ?? DEFAULT_DIRS.models,
429
708
  blocksDir: options.blocksDir ?? DEFAULT_DIRS.blocks,
@@ -442,7 +721,7 @@ function generateRegistry(options = {}) {
442
721
  return result;
443
722
  }
444
723
  function registryMatchers(loaderOptions) {
445
- const rel = import_node_path3.default.relative(loaderOptions.root, import_node_path3.default.resolve(loaderOptions.root, loaderOptions.outDir)).split(import_node_path3.default.sep).join("/");
724
+ const rel = import_node_path4.default.relative(loaderOptions.root, import_node_path4.default.resolve(loaderOptions.root, loaderOptions.outDir)).split(import_node_path4.default.sep).join("/");
446
725
  const escaped = rel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
447
726
  return {
448
727
  glob: `**/${rel}/*.ts`,
@@ -457,17 +736,30 @@ function withBackmatter(nextConfig = {}, options = {}) {
457
736
  const cfg = nextConfig;
458
737
  const existingTurbo = cfg.turbopack ?? {};
459
738
  const existingRules = existingTurbo.rules ?? {};
739
+ const existingAlias = existingTurbo.resolveAlias ?? {};
460
740
  const turbopackRule = { loaders: [use] };
461
741
  const turbopack = {
462
742
  ...existingTurbo,
743
+ resolveAlias: {
744
+ ...registryTurbopackAlias(loaderOptions.root, loaderOptions.outDir),
745
+ ...existingAlias
746
+ },
463
747
  rules: { ...existingRules, [glob]: turbopackRule }
464
748
  };
465
749
  const webpack = (config, context) => {
466
750
  config.module ??= {};
467
751
  config.module.rules ??= [];
468
752
  config.module.rules.push({ test, enforce: "pre", use: [use] });
753
+ config.resolve ??= {};
754
+ const prevAlias = config.resolve.alias;
755
+ const prev = prevAlias && typeof prevAlias === "object" && !Array.isArray(prevAlias) ? prevAlias : {};
756
+ config.resolve.alias = {
757
+ ...registryWebpackAlias(loaderOptions.root, loaderOptions.outDir),
758
+ ...prev
759
+ };
469
760
  return cfg.webpack ? cfg.webpack(config, context) : config;
470
761
  };
762
+ ensureRegistryTsconfig(loaderOptions.root, loaderOptions.outDir);
471
763
  const cacheComponents = cfg.cacheComponents ?? true;
472
764
  const transpilePackages = [
473
765
  ...new Set([...cfg.transpilePackages ?? [], "@backmatter/next", "@backmatter/core"])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/next",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Experimental Next.js adapter for Backmatter. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/src/cached-doc.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // packages/next/src/cached-doc.ts
2
+ import type { ReactNode } from "react";
2
3
  import { contentCacheTags, listCacheTags, type BackmatterDoc, type BackmatterDocSummary } from "@backmatter/core";
3
4
 
4
5
  export type CacheFns = {
@@ -17,8 +18,15 @@ export type ListDocsFn = (filter?: {
17
18
  locale?: string;
18
19
  }) => Promise<BackmatterDocSummary[]>;
19
20
 
21
+ export type PublishedRender = (
22
+ locale: string,
23
+ type: string,
24
+ slug: string,
25
+ ) => Promise<ReactNode>;
26
+
20
27
  let registeredGetDoc: GetDocFn | null = null;
21
28
  let registeredListDocs: ListDocsFn | null = null;
29
+ let registeredPublishedRender: PublishedRender | null = null;
22
30
 
23
31
  /** Called from `createBackmatter` so `"use cache"` only closes over serializable args. */
24
32
  export function registerCachedDocGet(getDoc: GetDocFn): void {
@@ -29,6 +37,11 @@ export function registerCachedListDocs(listDocs: ListDocsFn): void {
29
37
  registeredListDocs = listDocs;
30
38
  }
31
39
 
40
+ /** The catch-all registers the MDX/block render. `"use cache"` only receives ids. */
41
+ export function registerPublishedRender(render: PublishedRender): void {
42
+ registeredPublishedRender = render;
43
+ }
44
+
32
45
  async function loadCacheFns(): Promise<CacheFns> {
33
46
  try {
34
47
  const mod = (await import("next/cache" as string)) as CacheFns;
@@ -80,6 +93,27 @@ export async function getCachedDocs(
80
93
  return listDocs({ type, locale });
81
94
  }
82
95
 
96
+ /**
97
+ * Finished published page (MDX body or composer blocks, plus layout).
98
+ * The cache entry is what a static prerender and a normal link prefetch store.
99
+ */
100
+ export async function getCachedPublished(
101
+ locale: string,
102
+ type: string,
103
+ slug: string,
104
+ cache?: CacheFns,
105
+ ): Promise<ReactNode> {
106
+ "use cache";
107
+ const fns = cache ?? (await loadCacheFns());
108
+ fns.cacheLife("hours");
109
+ fns.cacheTag(...contentCacheTags(locale, type, slug));
110
+ const render = registeredPublishedRender;
111
+ if (!render) {
112
+ throw new Error("[backmatter] getCachedPublished called before a published render was registered");
113
+ }
114
+ return render(locale, type, slug);
115
+ }
116
+
83
117
  /** Unwrap Next `params` inside `"use cache"` so Cache Components can prerender. */
84
118
  export async function cachedRouteParams<T extends object>(
85
119
  params: Promise<T> | T,
package/src/config.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // packages/next/src/config.ts
2
2
  // `withBackmatter(nextConfig)` for next.config.{js,mjs,ts}.
3
3
  //
4
- // 1. Generates `.backmatter/` once when the config loads, so the first compile can
5
- // resolve `import { cms } from ".backmatter"`.
4
+ // 1. Generates `.backmatter/` once when the config loads, and aliases it to the
5
+ // `backmatter` import (`import { cms } from "backmatter"`).
6
6
  // 2. Registers `@backmatter/next/loader` for the registry files with Turbopack
7
7
  // (Next 16 default) and webpack (`next build --webpack`). The loader declares
8
8
  // `models/`, `blocks/`, `richtext/`, and `mdx/` as context dependencies, so the bundler's
@@ -16,6 +16,11 @@ import {
16
16
  generate,
17
17
  type GenerateResult,
18
18
  } from "@backmatter/core/generate";
19
+ import {
20
+ ensureRegistryTsconfig,
21
+ registryTurbopackAlias,
22
+ registryWebpackAlias,
23
+ } from "@backmatter/core/node/registry-import";
19
24
  import type { BackmatterLoaderOptions } from "./loader";
20
25
 
21
26
  export type WithBackmatterOptions = {
@@ -52,6 +57,7 @@ export type NextConfigLike = {
52
57
 
53
58
  export type WebpackConfigLike = {
54
59
  module?: { rules?: unknown[] } & Record<string, unknown>;
60
+ resolve?: { alias?: Record<string, unknown> } & Record<string, unknown>;
55
61
  } & Record<string, unknown>;
56
62
 
57
63
  export function warnMissingInstrumentation(root: string): void {
@@ -60,7 +66,7 @@ export function warnMissingInstrumentation(root: string): void {
60
66
  const hasSrc = existsSync(path.join(root, "src", "instrumentation.ts"));
61
67
  if (!hasRoot && !hasSrc) {
62
68
  console.warn(
63
- "backmatter: add instrumentation.ts that imports .backmatter/sync — block schema sync will not run without it",
69
+ "backmatter: add instrumentation.ts that imports backmatter/sync — block schema sync will not run without it",
64
70
  );
65
71
  }
66
72
  }
@@ -141,9 +147,14 @@ export function withBackmatter<T extends object>(
141
147
  const cfg = nextConfig as T & NextConfigLike;
142
148
  const existingTurbo = (cfg.turbopack ?? {}) as Record<string, unknown>;
143
149
  const existingRules = (existingTurbo.rules ?? {}) as Record<string, unknown>;
150
+ const existingAlias = (existingTurbo.resolveAlias ?? {}) as Record<string, unknown>;
144
151
  const turbopackRule: TurbopackRule = { loaders: [use] };
145
152
  const turbopack = {
146
153
  ...existingTurbo,
154
+ resolveAlias: {
155
+ ...registryTurbopackAlias(loaderOptions.root, loaderOptions.outDir),
156
+ ...existingAlias,
157
+ },
147
158
  rules: { ...existingRules, [glob]: turbopackRule },
148
159
  };
149
160
 
@@ -152,17 +163,30 @@ export function withBackmatter<T extends object>(
152
163
  config.module.rules ??= [];
153
164
  // `enforce: "pre"` runs before Next's SWC loader; we return TypeScript, SWC compiles it.
154
165
  config.module.rules.push({ test, enforce: "pre", use: [use] });
166
+ config.resolve ??= {};
167
+ const prevAlias = config.resolve.alias;
168
+ const prev =
169
+ prevAlias && typeof prevAlias === "object" && !Array.isArray(prevAlias) ? prevAlias : {};
170
+ config.resolve.alias = {
171
+ ...registryWebpackAlias(loaderOptions.root, loaderOptions.outDir),
172
+ ...prev,
173
+ };
155
174
  return cfg.webpack ? cfg.webpack(config, context) : config;
156
175
  };
157
176
 
177
+ ensureRegistryTsconfig(loaderOptions.root, loaderOptions.outDir);
178
+
158
179
  const cacheComponents = cfg.cacheComponents ?? true;
180
+ // Published pages are static files. Partial prefetching would replace those
181
+ // files with one shared shell for every CMS URL, so a normal link would
182
+ // prefetch the shell instead of the finished page. Hosts can still opt in.
159
183
  const transpilePackages = [
160
184
  ...new Set([...(cfg.transpilePackages ?? []), "@backmatter/next", "@backmatter/core"]),
161
185
  ];
162
186
  return {
163
187
  ...nextConfig,
164
188
  cacheComponents,
165
- partialPrefetching: cfg.partialPrefetching ?? cacheComponents,
189
+ partialPrefetching: cfg.partialPrefetching ?? false,
166
190
  transpilePackages,
167
191
  turbopack,
168
192
  webpack,
@@ -10,6 +10,25 @@ import remarkGfm from "remark-gfm";
10
10
 
11
11
  export type MdxComponents = Record<string, ComponentType<any>>;
12
12
 
13
+ function UnknownMdx(props: { children?: ReactNode }) {
14
+ return props.children ?? null;
15
+ }
16
+
17
+ /**
18
+ * MDX copies `components` with object spread, which skips a Proxy.
19
+ * Fill each capitalized tag the host did not provide so one unknown tag
20
+ * does not fail the page render.
21
+ */
22
+ function mdxComponentsWithFallback(source: string, components?: MdxComponents): MdxComponents {
23
+ const provided: MdxComponents = { ...components };
24
+ for (const match of source.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g)) {
25
+ const name = match[1];
26
+ if (!name || provided[name]) continue;
27
+ provided[name] = UnknownMdx;
28
+ }
29
+ return provided;
30
+ }
31
+
13
32
  export async function compileMdx(
14
33
  source: string,
15
34
  components?: MdxComponents,
@@ -22,7 +41,7 @@ export async function compileMdx(
22
41
  remarkPlugins: [remarkGfm],
23
42
  baseUrl: import.meta.url,
24
43
  });
25
- return createElement(Content, { components });
44
+ return createElement(Content, { components: mdxComponentsWithFallback(source, components) });
26
45
  } catch (err) {
27
46
  const message = err instanceof Error ? err.message : "MDX compile failed";
28
47
  return createElement("pre", { role: "alert" }, message);
package/src/routes.ts CHANGED
@@ -8,7 +8,7 @@ import { isRoutable, matchRoute, modelFormat, PREVIEW_CHECKOUT_QUERY, PREVIEW_CO
8
8
  import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
9
9
  import { fetchPreviewDoc } from "@backmatter/core/preview";
10
10
  import { renderDoc, type MdxComponents } from "./mdx-compile";
11
- import { cachedRouteParams } from "./cached-doc";
11
+ import { cachedRouteParams, getCachedPublished, registerPublishedRender } from "./cached-doc";
12
12
  import { renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
13
13
  import { previewUnavailable } from "./preview-ui";
14
14
 
@@ -177,11 +177,9 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
177
177
  ? await renderPage(doc, options.blocks, options.fallbacks)
178
178
  : await renderDoc(doc, { components: options.components });
179
179
  let body = rendered;
180
- if (!isPage && previewEnabled) {
181
- const { PreviewBody } = await import("./preview-live");
182
- body = createElement(PreviewBody, { doc, components: options.components }, rendered);
183
- }
184
- if (previewEnabled && isPage) {
180
+ if (previewEnabled) {
181
+ // Refresh on the server. Passing the host component map into the client
182
+ // preview wrapper is not serializable and crashes the page.
185
183
  const { PreviewPageReloader } = await import("./preview-refresh");
186
184
  body = createElement(
187
185
  "div",
@@ -191,7 +189,7 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
191
189
  type: doc.type,
192
190
  slug: doc.slug,
193
191
  }),
194
- body,
192
+ rendered,
195
193
  );
196
194
  }
197
195
  const layout = options.layouts?.[doc.type];
@@ -231,15 +229,30 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
231
229
  }
232
230
  }
233
231
 
232
+ async function renderPublished(locale: string, type: string, slug: string): Promise<ReactNode> {
233
+ const doc = await cms.getDoc(locale, type, slug);
234
+ if (!doc) return null;
235
+ return createElement(
236
+ "div",
237
+ { "data-backmatter-published": "", style: { display: "contents" } },
238
+ await layoutDoc(doc, false),
239
+ );
240
+ }
241
+
242
+ async function publishedBody(locale: string, type: string, slug: string): Promise<ReactNode> {
243
+ registerPublishedRender(renderPublished);
244
+ const body = await getCachedPublished(locale, type, slug);
245
+ if (body == null) return failFor();
246
+ return body;
247
+ }
248
+
234
249
  const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params, searchParams }) => {
235
250
  const resolved = await cachedRouteParams(params);
236
251
  const hit = resolve(resolved[CATCH_ALL_PARAM]);
237
252
  if (!hit) return failFor();
238
253
  const preview = await resolvePreviewState(cms, options.preview, searchParams);
239
254
  if (previewReplacesPublished(preview)) return loadPreviewDoc(hit, preview);
240
- const doc = await cms.getDoc(hit.locale, hit.type, hit.slug);
241
- if (!doc) return failFor();
242
- return layoutDoc(doc, false);
255
+ return publishedBody(hit.locale, hit.type, hit.slug);
243
256
  };
244
257
 
245
258
  const PublishedRoute = async ({
@@ -251,13 +264,7 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
251
264
  const resolved = await params;
252
265
  const hit = resolve(resolved[CATCH_ALL_PARAM]);
253
266
  if (!hit) return failFor();
254
- const doc = await cms.getDoc(hit.locale, hit.type, hit.slug);
255
- if (!doc) return failFor();
256
- return createElement(
257
- "div",
258
- { "data-backmatter-published": "", style: { display: "contents" } },
259
- await layoutDoc(doc, false),
260
- );
267
+ return publishedBody(hit.locale, hit.type, hit.slug);
261
268
  };
262
269
 
263
270
  const PreviewBranch = async ({
@@ -288,11 +295,7 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
288
295
  createElement(
289
296
  Fragment,
290
297
  null,
291
- createElement(
292
- Suspense,
293
- { fallback: createElement("div", { "aria-busy": "true", style: { minHeight: "100vh" } }) },
294
- createElement(PublishedRoute, props),
295
- ),
298
+ createElement(PublishedRoute, props),
296
299
  createElement(Suspense, { fallback: null }, createElement(PreviewBranch, props)),
297
300
  );
298
301