canopycms 0.0.68-int.103 → 0.0.68-int.105

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.
@@ -63,9 +63,10 @@ declare const assetRefsFileSchema: z.ZodObject<{
63
63
  }[];
64
64
  }>;
65
65
  export type AssetRefsFile = z.infer<typeof assetRefsFileSchema>;
66
- /** A URL in the output that no stored object can answer. */
66
+ /** A URL in the output that no stored object can answer, or an inline flight script that cannot be read. */
67
67
  export interface AssetRefProblem {
68
68
  file: string;
69
+ /** The URL, or the script's position when the problem is a script. */
69
70
  url: string;
70
71
  error: string;
71
72
  }
@@ -12,6 +12,7 @@ import { z } from 'zod';
12
12
  import { ASSET_PREFIXES } from '../assets/asset-prefixes.js';
13
13
  import { canonicalizeTransformPath } from '../assets/transform-directives.js';
14
14
  import { atomicWriteFile } from '../utils/atomic-write.js';
15
+ import { extractInlineFlight } from './inline-flight.js';
15
16
  /** @internal Written into the scanned directory, so a manifest the adopter builds afterwards covers it. */
16
17
  export const ASSET_REFS_FILENAME = 'canopy-asset-refs.json';
17
18
  /**
@@ -84,11 +85,12 @@ async function listScannedFiles(root) {
84
85
  .filter((rel) => rel !== ASSET_REFS_FILENAME)
85
86
  .sort();
86
87
  }
88
+ const HTML_FILE_RE = /\.html?$/;
87
89
  /** The route a static-export HTML file serves (`about/index.html` and `about.html` are `/about`). */
88
90
  function routeForFile(file) {
89
- if (!/\.html?$/.test(file))
91
+ if (!HTML_FILE_RE.test(file))
90
92
  return undefined;
91
- const withoutExt = file.replace(/\.html?$/, '');
93
+ const withoutExt = file.replace(HTML_FILE_RE, '');
92
94
  const route = withoutExt === 'index' ? '' : withoutExt.replace(/\/index$/, '');
93
95
  return `/${route}`;
94
96
  }
@@ -191,13 +193,8 @@ export async function collectAssetRefs(outDir) {
191
193
  const transforms = new RefCollector();
192
194
  const statics = new RefCollector();
193
195
  const problems = [];
194
- let scannedFiles = 0;
195
- for (const file of files) {
196
- const filePath = path.join(root, file);
197
- if (path.extname(file) === '' && (await startsBinary(filePath)))
198
- continue;
199
- scannedFiles++;
200
- const text = decodeUrlEscapes(await fs.readFile(filePath, 'utf-8'));
196
+ const scan = (file, raw) => {
197
+ const text = decodeUrlEscapes(raw);
201
198
  for (const match of text.matchAll(TRANSFORM_URL_RE)) {
202
199
  const [url, filename] = trimTrailingPunctuation(match[0], match[3]);
203
200
  const [, encodedDirectives, hash32] = match;
@@ -228,6 +225,25 @@ export async function collectAssetRefs(outDir) {
228
225
  statics.add(`${ASSET_PREFIXES.public}/${hash32}/${filename}`, file);
229
226
  }
230
227
  }
228
+ };
229
+ let scannedFiles = 0;
230
+ for (const file of files) {
231
+ const filePath = path.join(root, file);
232
+ if (path.extname(file) === '' && (await startsBinary(filePath)))
233
+ continue;
234
+ scannedFiles++;
235
+ const text = await fs.readFile(filePath, 'utf-8');
236
+ if (!HTML_FILE_RE.test(file)) {
237
+ scan(file, text);
238
+ continue;
239
+ }
240
+ // A page's inline RSC payload is split across scripts at arbitrary bytes (inline-flight.ts).
241
+ const page = extractInlineFlight(text);
242
+ scan(file, page.markup);
243
+ for (const flight of page.texts)
244
+ scan(file, flight);
245
+ for (const { script, error } of page.problems)
246
+ problems.push({ file, url: script, error });
231
247
  }
232
248
  if (problems.length > 0)
233
249
  throw new AssetRefsError(problems);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reassemble the RSC (flight) stream Next's App Router inlines into a page, so a URL the stream
3
+ * splits across two scripts is scanned whole.
4
+ *
5
+ * Next writes each chunk of the stream as its own `<script>`, and React Flight cuts chunks at
6
+ * arbitrary bytes (it fills fixed-size views and enqueues the partial one at every flush), so a URL
7
+ * can straddle two scripts and the page text holds only its fragments. The format is the same in Next 15.5.21 and 16.1.7
8
+ * (`createInlinedDataReadableStream` in `dist/server/app-render/use-flight-response.js`, read back
9
+ * by `nextServerDataCallback` in `dist/client/app-index.js`):
10
+ *
11
+ * - `(self.__next_f=self.__next_f||[]).push([0])` starts the stream, followed in the same script by
12
+ * `;self.__next_f.push([2,formState])` when there is form state;
13
+ * - each chunk is then `self.__next_f.push([1,text])`, or `[3,base64]` for a chunk that is not
14
+ * valid UTF-8, whose bytes join the same stream;
15
+ * - every argument is `JSON.stringify` output passed through `htmlEscapeJsonString`, which only
16
+ * rewrites `& < > U+2028 U+2029` as `\uXXXX` escapes, so it parses as JSON and never holds
17
+ * `</script`.
18
+ *
19
+ * Every push on a page joins one stream: Next inlines one flight stream per page.
20
+ */
21
+ export interface InlineFlight {
22
+ /** The page with each parsed flight script replaced by a space, so no fragment is scanned. */
23
+ markup: string;
24
+ /** The stream's text, joined in document order, then each form state as JSON. */
25
+ texts: string[];
26
+ /** Scripts that name `__next_f` but are not flight pushes; their data cannot be scanned. */
27
+ problems: {
28
+ script: string;
29
+ error: string;
30
+ }[];
31
+ }
32
+ /**
33
+ * Split `html` into its markup and its inline flight stream. Only a script whose body names
34
+ * `__next_f` is flight; one that does but does not parse stays in the markup and is reported,
35
+ * because skipping it could drop a reference.
36
+ */
37
+ export declare function extractInlineFlight(html: string): InlineFlight;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Reassemble the RSC (flight) stream Next's App Router inlines into a page, so a URL the stream
3
+ * splits across two scripts is scanned whole.
4
+ *
5
+ * Next writes each chunk of the stream as its own `<script>`, and React Flight cuts chunks at
6
+ * arbitrary bytes (it fills fixed-size views and enqueues the partial one at every flush), so a URL
7
+ * can straddle two scripts and the page text holds only its fragments. The format is the same in Next 15.5.21 and 16.1.7
8
+ * (`createInlinedDataReadableStream` in `dist/server/app-render/use-flight-response.js`, read back
9
+ * by `nextServerDataCallback` in `dist/client/app-index.js`):
10
+ *
11
+ * - `(self.__next_f=self.__next_f||[]).push([0])` starts the stream, followed in the same script by
12
+ * `;self.__next_f.push([2,formState])` when there is form state;
13
+ * - each chunk is then `self.__next_f.push([1,text])`, or `[3,base64]` for a chunk that is not
14
+ * valid UTF-8, whose bytes join the same stream;
15
+ * - every argument is `JSON.stringify` output passed through `htmlEscapeJsonString`, which only
16
+ * rewrites `& < > U+2028 U+2029` as `\uXXXX` escapes, so it parses as JSON and never holds
17
+ * `</script`.
18
+ *
19
+ * Every push on a page joins one stream: Next inlines one flight stream per page.
20
+ */
21
+ import { getErrorMessage } from '../utils/error.js';
22
+ const PUSH_CALL_RE = /(?:\(self\.__next_f=self\.__next_f\|\|\[\]\)|self\.__next_f)\.push\(/y;
23
+ const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
24
+ /** The index just past the JSON array that opens at `start`; `JSON.parse` checks the rest. */
25
+ function jsonArrayEnd(body, start) {
26
+ if (body[start] !== '[')
27
+ throw new Error(`expected a JSON array at ${start}`);
28
+ let depth = 0;
29
+ let inString = false;
30
+ for (let i = start; i < body.length; i++) {
31
+ const c = body[i];
32
+ if (inString) {
33
+ if (c === '\\')
34
+ i++;
35
+ else if (c === '"')
36
+ inString = false;
37
+ }
38
+ else if (c === '"') {
39
+ inString = true;
40
+ }
41
+ else if (c === '[' || c === '{') {
42
+ depth++;
43
+ }
44
+ else if (c === ']' || c === '}') {
45
+ depth--;
46
+ if (depth === 0)
47
+ return i + 1;
48
+ }
49
+ }
50
+ throw new Error('unterminated JSON array');
51
+ }
52
+ /** The arguments of a script body that is nothing but `;`-joined `self.__next_f.push(...)` calls. */
53
+ function parsePushArguments(body) {
54
+ const args = [];
55
+ let pos = 0;
56
+ for (;;) {
57
+ PUSH_CALL_RE.lastIndex = pos;
58
+ if (!PUSH_CALL_RE.test(body))
59
+ throw new Error(`expected self.__next_f.push( at ${pos}`);
60
+ const end = jsonArrayEnd(body, PUSH_CALL_RE.lastIndex);
61
+ args.push(JSON.parse(body.slice(PUSH_CALL_RE.lastIndex, end)));
62
+ if (body[end] !== ')')
63
+ throw new Error(`expected ) at ${end}`);
64
+ pos = end + 1;
65
+ if (pos === body.length)
66
+ return args;
67
+ if (body[pos] !== ';')
68
+ throw new Error(`expected ; or the end of the script at ${pos}`);
69
+ pos++;
70
+ }
71
+ }
72
+ /** A push's contribution: bytes of the stream, form state as JSON, or nothing (the bootstrap). */
73
+ function readSegment(arg) {
74
+ if (!Array.isArray(arg))
75
+ throw new Error('argument is not an array');
76
+ const [kind, payload] = arg;
77
+ if (kind === 0 && arg.length === 1)
78
+ return {};
79
+ if (kind === 1 && arg.length === 2 && typeof payload === 'string') {
80
+ return { bytes: Buffer.from(payload, 'utf8') };
81
+ }
82
+ if (kind === 2 && arg.length === 2)
83
+ return { formState: JSON.stringify(payload) };
84
+ if (kind === 3 && arg.length === 2 && typeof payload === 'string' && BASE64_RE.test(payload)) {
85
+ return { bytes: Buffer.from(payload, 'base64') };
86
+ }
87
+ throw new Error(`unknown segment ${JSON.stringify(arg).slice(0, 40)}`);
88
+ }
89
+ /** Each script element's span and body, in document order; one pass, however the page ends. */
90
+ function* scriptElements(html) {
91
+ const openRe = /<script\b[^<>]*>/gi;
92
+ const closeRe = /<\/script\s*>/gi;
93
+ for (let open = openRe.exec(html); open; open = openRe.exec(html)) {
94
+ closeRe.lastIndex = openRe.lastIndex;
95
+ const close = closeRe.exec(html);
96
+ if (!close)
97
+ return;
98
+ yield {
99
+ start: open.index,
100
+ end: closeRe.lastIndex,
101
+ body: html.slice(openRe.lastIndex, close.index),
102
+ };
103
+ openRe.lastIndex = closeRe.lastIndex;
104
+ }
105
+ }
106
+ /**
107
+ * Split `html` into its markup and its inline flight stream. Only a script whose body names
108
+ * `__next_f` is flight; one that does but does not parse stays in the markup and is reported,
109
+ * because skipping it could drop a reference.
110
+ */
111
+ export function extractInlineFlight(html) {
112
+ const stream = [];
113
+ const formStates = [];
114
+ const problems = [];
115
+ let markup = '';
116
+ let last = 0;
117
+ for (const { start, end, body } of scriptElements(html)) {
118
+ if (!body.includes('__next_f'))
119
+ continue;
120
+ let segments;
121
+ try {
122
+ segments = parsePushArguments(body).map(readSegment);
123
+ }
124
+ catch (err) {
125
+ problems.push({
126
+ script: `<script> at offset ${start}`,
127
+ error: `Not a Next inline flight script (${getErrorMessage(err)}); its RSC payload cannot be scanned`,
128
+ });
129
+ continue;
130
+ }
131
+ for (const { bytes, formState } of segments) {
132
+ if (bytes)
133
+ stream.push(bytes);
134
+ if (formState !== undefined)
135
+ formStates.push(formState);
136
+ }
137
+ markup += `${html.slice(last, start)} `;
138
+ last = end;
139
+ }
140
+ const texts = stream.length > 0 ? [Buffer.concat(stream).toString('utf8'), ...formStates] : formStates;
141
+ return { markup: markup + html.slice(last), texts, problems };
142
+ }
package/dist/cli/cli.js CHANGED
@@ -12653,6 +12653,123 @@ var init_atomic_write2 = __esm({
12653
12653
  }
12654
12654
  });
12655
12655
 
12656
+ // dist/build/inline-flight.js
12657
+ function jsonArrayEnd(body, start) {
12658
+ if (body[start] !== "[")
12659
+ throw new Error(`expected a JSON array at ${start}`);
12660
+ let depth = 0;
12661
+ let inString = false;
12662
+ for (let i = start; i < body.length; i++) {
12663
+ const c = body[i];
12664
+ if (inString) {
12665
+ if (c === "\\")
12666
+ i++;
12667
+ else if (c === '"')
12668
+ inString = false;
12669
+ } else if (c === '"') {
12670
+ inString = true;
12671
+ } else if (c === "[" || c === "{") {
12672
+ depth++;
12673
+ } else if (c === "]" || c === "}") {
12674
+ depth--;
12675
+ if (depth === 0)
12676
+ return i + 1;
12677
+ }
12678
+ }
12679
+ throw new Error("unterminated JSON array");
12680
+ }
12681
+ function parsePushArguments(body) {
12682
+ const args = [];
12683
+ let pos = 0;
12684
+ for (; ; ) {
12685
+ PUSH_CALL_RE.lastIndex = pos;
12686
+ if (!PUSH_CALL_RE.test(body))
12687
+ throw new Error(`expected self.__next_f.push( at ${pos}`);
12688
+ const end = jsonArrayEnd(body, PUSH_CALL_RE.lastIndex);
12689
+ args.push(JSON.parse(body.slice(PUSH_CALL_RE.lastIndex, end)));
12690
+ if (body[end] !== ")")
12691
+ throw new Error(`expected ) at ${end}`);
12692
+ pos = end + 1;
12693
+ if (pos === body.length)
12694
+ return args;
12695
+ if (body[pos] !== ";")
12696
+ throw new Error(`expected ; or the end of the script at ${pos}`);
12697
+ pos++;
12698
+ }
12699
+ }
12700
+ function readSegment(arg) {
12701
+ if (!Array.isArray(arg))
12702
+ throw new Error("argument is not an array");
12703
+ const [kind, payload] = arg;
12704
+ if (kind === 0 && arg.length === 1)
12705
+ return {};
12706
+ if (kind === 1 && arg.length === 2 && typeof payload === "string") {
12707
+ return { bytes: Buffer.from(payload, "utf8") };
12708
+ }
12709
+ if (kind === 2 && arg.length === 2)
12710
+ return { formState: JSON.stringify(payload) };
12711
+ if (kind === 3 && arg.length === 2 && typeof payload === "string" && BASE64_RE.test(payload)) {
12712
+ return { bytes: Buffer.from(payload, "base64") };
12713
+ }
12714
+ throw new Error(`unknown segment ${JSON.stringify(arg).slice(0, 40)}`);
12715
+ }
12716
+ function* scriptElements(html) {
12717
+ const openRe = /<script\b[^<>]*>/gi;
12718
+ const closeRe = /<\/script\s*>/gi;
12719
+ for (let open = openRe.exec(html); open; open = openRe.exec(html)) {
12720
+ closeRe.lastIndex = openRe.lastIndex;
12721
+ const close = closeRe.exec(html);
12722
+ if (!close)
12723
+ return;
12724
+ yield {
12725
+ start: open.index,
12726
+ end: closeRe.lastIndex,
12727
+ body: html.slice(openRe.lastIndex, close.index)
12728
+ };
12729
+ openRe.lastIndex = closeRe.lastIndex;
12730
+ }
12731
+ }
12732
+ function extractInlineFlight(html) {
12733
+ const stream = [];
12734
+ const formStates = [];
12735
+ const problems = [];
12736
+ let markup = "";
12737
+ let last = 0;
12738
+ for (const { start, end, body } of scriptElements(html)) {
12739
+ if (!body.includes("__next_f"))
12740
+ continue;
12741
+ let segments;
12742
+ try {
12743
+ segments = parsePushArguments(body).map(readSegment);
12744
+ } catch (err2) {
12745
+ problems.push({
12746
+ script: `<script> at offset ${start}`,
12747
+ error: `Not a Next inline flight script (${getErrorMessage(err2)}); its RSC payload cannot be scanned`
12748
+ });
12749
+ continue;
12750
+ }
12751
+ for (const { bytes, formState } of segments) {
12752
+ if (bytes)
12753
+ stream.push(bytes);
12754
+ if (formState !== void 0)
12755
+ formStates.push(formState);
12756
+ }
12757
+ markup += `${html.slice(last, start)} `;
12758
+ last = end;
12759
+ }
12760
+ const texts = stream.length > 0 ? [Buffer.concat(stream).toString("utf8"), ...formStates] : formStates;
12761
+ return { markup: markup + html.slice(last), texts, problems };
12762
+ }
12763
+ var PUSH_CALL_RE, BASE64_RE;
12764
+ var init_inline_flight = __esm({
12765
+ "dist/build/inline-flight.js"() {
12766
+ "use strict";
12767
+ init_error();
12768
+ PUSH_CALL_RE = /(?:\(self\.__next_f=self\.__next_f\|\|\[\]\)|self\.__next_f)\.push\(/y;
12769
+ BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
12770
+ }
12771
+ });
12772
+
12656
12773
  // dist/build/asset-refs.js
12657
12774
  import fs24 from "node:fs/promises";
12658
12775
  import path26 from "node:path";
@@ -12662,9 +12779,9 @@ async function listScannedFiles(root) {
12662
12779
  return entries.filter((entry) => entry.isFile() && SCANNED_EXTENSIONS.has(path26.extname(entry.name))).map((entry) => path26.relative(root, path26.join(entry.parentPath, entry.name))).map((rel) => rel.split(path26.sep).join("/")).filter((rel) => rel !== ASSET_REFS_FILENAME).sort();
12663
12780
  }
12664
12781
  function routeForFile(file) {
12665
- if (!/\.html?$/.test(file))
12782
+ if (!HTML_FILE_RE.test(file))
12666
12783
  return void 0;
12667
- const withoutExt = file.replace(/\.html?$/, "");
12784
+ const withoutExt = file.replace(HTML_FILE_RE, "");
12668
12785
  const route = withoutExt === "index" ? "" : withoutExt.replace(/\/index$/, "");
12669
12786
  return `/${route}`;
12670
12787
  }
@@ -12718,13 +12835,8 @@ async function collectAssetRefs(outDir) {
12718
12835
  const transforms = new RefCollector();
12719
12836
  const statics = new RefCollector();
12720
12837
  const problems = [];
12721
- let scannedFiles = 0;
12722
- for (const file of files) {
12723
- const filePath2 = path26.join(root, file);
12724
- if (path26.extname(file) === "" && await startsBinary(filePath2))
12725
- continue;
12726
- scannedFiles++;
12727
- const text3 = decodeUrlEscapes(await fs24.readFile(filePath2, "utf-8"));
12838
+ const scan = (file, raw) => {
12839
+ const text3 = decodeUrlEscapes(raw);
12728
12840
  for (const match of text3.matchAll(TRANSFORM_URL_RE)) {
12729
12841
  const [url, filename] = trimTrailingPunctuation(match[0], match[3]);
12730
12842
  const [, encodedDirectives, hash32] = match;
@@ -12751,6 +12863,24 @@ async function collectAssetRefs(outDir) {
12751
12863
  statics.add(`${ASSET_PREFIXES.public}/${hash32}/${filename}`, file);
12752
12864
  }
12753
12865
  }
12866
+ };
12867
+ let scannedFiles = 0;
12868
+ for (const file of files) {
12869
+ const filePath2 = path26.join(root, file);
12870
+ if (path26.extname(file) === "" && await startsBinary(filePath2))
12871
+ continue;
12872
+ scannedFiles++;
12873
+ const text3 = await fs24.readFile(filePath2, "utf-8");
12874
+ if (!HTML_FILE_RE.test(file)) {
12875
+ scan(file, text3);
12876
+ continue;
12877
+ }
12878
+ const page = extractInlineFlight(text3);
12879
+ scan(file, page.markup);
12880
+ for (const flight of page.texts)
12881
+ scan(file, flight);
12882
+ for (const { script, error } of page.problems)
12883
+ problems.push({ file, url: script, error });
12754
12884
  }
12755
12885
  if (problems.length > 0)
12756
12886
  throw new AssetRefsError(problems);
@@ -12772,13 +12902,14 @@ async function readAssetRefsFile(filePath) {
12772
12902
  }
12773
12903
  return parsed.data;
12774
12904
  }
12775
- var ASSET_REFS_FILENAME, SCANNED_EXTENSIONS, URL_STOP, TRANSFORM_URL_RE, STATIC_URL_RE, STATIC_FILENAME_RE, HASH32_RE2, assetRefEntrySchema, assetRefsFileSchema, AssetRefsError, ENCODED_ASSETS, RUN_STOP, RefCollector;
12905
+ var ASSET_REFS_FILENAME, SCANNED_EXTENSIONS, URL_STOP, TRANSFORM_URL_RE, STATIC_URL_RE, STATIC_FILENAME_RE, HASH32_RE2, assetRefEntrySchema, assetRefsFileSchema, AssetRefsError, HTML_FILE_RE, ENCODED_ASSETS, RUN_STOP, RefCollector;
12776
12906
  var init_asset_refs = __esm({
12777
12907
  "dist/build/asset-refs.js"() {
12778
12908
  "use strict";
12779
12909
  init_asset_prefixes();
12780
12910
  init_transform_directives();
12781
12911
  init_atomic_write2();
12912
+ init_inline_flight();
12782
12913
  ASSET_REFS_FILENAME = "canopy-asset-refs.json";
12783
12914
  SCANNED_EXTENSIONS = /* @__PURE__ */ new Set([
12784
12915
  "",
@@ -12820,6 +12951,7 @@ var init_asset_refs = __esm({
12820
12951
  this.name = "AssetRefsError";
12821
12952
  }
12822
12953
  };
12954
+ HTML_FILE_RE = /\.html?$/;
12823
12955
  ENCODED_ASSETS = /%2[fF]assets%2[fF]/g;
12824
12956
  RUN_STOP = /[\s"'`<>&]/;
12825
12957
  RefCollector = class {
@@ -62,13 +62,83 @@ function unsupportedJsx(mdx, node) {
62
62
  }
63
63
  return null;
64
64
  }
65
+ // Blocks that run into the block before them in a list item, by that block's type.
66
+ const RUNS_INTO = {
67
+ paragraph: ['thematicBreak'],
68
+ blockquote: ['paragraph', 'blockquote', 'table'],
69
+ table: ['paragraph', 'table'],
70
+ };
71
+ const BLOCK_NAMES = {
72
+ paragraph: 'paragraph',
73
+ blockquote: 'quote',
74
+ table: 'table',
75
+ thematicBreak: 'horizontal rule',
76
+ };
77
+ /**
78
+ * Why MDXEditor would change what this list item says, or null. Its import moves a nested list into
79
+ * a new item after its own, so what follows the list comes out before it; its export writes an item
80
+ * tight, so `---` underlines a paragraph and a line continues a quote or table (`RUNS_INTO`).
81
+ */
82
+ function rearrangedBlocks(node) {
83
+ if (node.type !== 'listItem')
84
+ return null;
85
+ for (let i = 1; i < node.children.length; i++) {
86
+ const before = node.children[i - 1].type;
87
+ const after = node.children[i].type;
88
+ if (before === 'list')
89
+ return 'a list item with content after its nested list';
90
+ if (RUNS_INTO[before]?.includes(after)) {
91
+ return `a list item with a ${BLOCK_NAMES[before] ?? before} followed by a ${BLOCK_NAMES[after] ?? after}`;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ const isLexicalParent = (node) => 'getChildren' in node && typeof node.getChildren === 'function';
97
+ const hasBlockChild = (node) => isLexicalParent(node) && node.getChildren().some((child) => !child.isInline());
98
+ // Writes `node`'s children into `target`: a run of inline children as one paragraph, a block as itself.
99
+ function writeBlocks(node, target, actions) {
100
+ let paragraph = null;
101
+ for (const child of isLexicalParent(node) ? node.getChildren() : []) {
102
+ if (!child.isInline()) {
103
+ paragraph = null;
104
+ actions.visit(child, target);
105
+ continue;
106
+ }
107
+ if (paragraph === null) {
108
+ const created = { type: 'paragraph', children: [] };
109
+ actions.appendToParent(target, created);
110
+ paragraph = created;
111
+ }
112
+ actions.visit(child, paragraph);
113
+ }
114
+ }
115
+ // MDXEditor 4 exports each child of a quote as a block, so inline children the toolbar's Quote,
116
+ // the `> ` shortcut or a merge put in a quote would come out one paragraph each.
117
+ const quoteExportVisitor = {
118
+ priority: 100,
119
+ testLexicalNode: (node) => node.getType() === 'quote',
120
+ visitLexicalNode({ lexicalNode, mdastParent, actions }) {
121
+ const quote = { type: 'blockquote', children: [] };
122
+ actions.appendToParent(mdastParent, quote);
123
+ writeBlocks(lexicalNode, quote, actions);
124
+ },
125
+ };
126
+ // Backspace at the start of a quote moves its paragraphs and lists into one paragraph, which
127
+ // MDXEditor would export as one run of text; they are written as the blocks they show.
128
+ const paragraphOfBlocksExportVisitor = {
129
+ priority: 100,
130
+ testLexicalNode: (node) => node.getType() === 'paragraph' && hasBlockChild(node),
131
+ visitLexicalNode({ lexicalNode, mdastParent, actions }) {
132
+ writeBlocks(lexicalNode, mdastParent, actions);
133
+ },
134
+ };
65
135
  /** Typed `string` because the mdast node union this package resolves omits the ESM node. */
66
136
  const ESM_NODE_TYPE = 'mdxjsEsm';
67
137
  /** Node types inside a table, imported by its cell editors rather than by visitors. */
68
138
  const CONSUMED_BY_PARENT_VISITOR = new Set(['tableRow', 'tableCell']);
69
139
  /** Creates the JSX plugins from MDXEditor's exports; call once per load of MDXEditor. */
70
140
  export function createMdxJsxPlugins(mdx) {
71
- const { NestedLexicalEditor, UnrecognizedMarkdownConstructError, addImportVisitor$, importVisitors$, isMdastJsxNode, jsxPlugin, realmPlugin, } = mdx;
141
+ const { NestedLexicalEditor, UnrecognizedMarkdownConstructError, addExportVisitor$, addImportVisitor$, importVisitors$, isMdastJsxNode, jsxPlugin, realmPlugin, } = mdx;
72
142
  /**
73
143
  * Shows an element's tag and attributes read-only (they are edited as source)
74
144
  * and edits its children, inline or block per the parsed node: one `*`
@@ -92,27 +162,35 @@ export function createMdxJsxPlugins(mdx) {
92
162
  /**
93
163
  * Reports through `onError`, at import, what MDXEditor would otherwise lose or
94
164
  * break on without reporting it: `import`/`export` lines (its visitor for them
95
- * is a no-op), elements `unsupportedJsx` rejects, and content with no visitor
96
- * inside a JSX element or table, whose children nested editors import later,
97
- * only logging a failure and writing partial children back on edit. It throws
98
- * an error class MDXEditor's import reports; any other error crashes the editor.
165
+ * is a no-op), what `unsupportedJsx` and `rearrangedBlocks` reject, and content
166
+ * with no visitor inside a JSX element or table, whose children nested editors
167
+ * import later, only logging a failure and writing partial children back on
168
+ * edit. It throws an error class MDXEditor's import reports; any other error
169
+ * crashes the editor.
99
170
  */
100
171
  const roundTripGuardPlugin = realmPlugin({
101
172
  init(realm) {
102
173
  const guard = {
103
174
  priority: 100,
104
- testNode: (node) => node.type === ESM_NODE_TYPE || node.type === 'table' || isMdastJsxNode(node),
175
+ testNode: (node) => node.type === ESM_NODE_TYPE ||
176
+ node.type === 'table' ||
177
+ node.type === 'listItem' ||
178
+ isMdastJsxNode(node),
105
179
  visitNode({ mdastNode, descriptors, actions }) {
106
180
  if (mdastNode.type === ESM_NODE_TYPE) {
107
181
  throw new UnrecognizedMarkdownConstructError('import/export statements cannot be edited in the rich-text editor');
108
182
  }
109
183
  const reject = (node) => {
110
- const reason = unsupportedJsx(mdx, node);
184
+ const reason = unsupportedJsx(mdx, node) ?? rearrangedBlocks(node);
111
185
  if (reason !== null) {
112
186
  throw new UnrecognizedMarkdownConstructError(`${reason} cannot be edited in the rich-text editor`);
113
187
  }
114
188
  };
115
189
  reject(mdastNode);
190
+ if (mdastNode.type === 'listItem') {
191
+ actions.nextVisitor();
192
+ return;
193
+ }
116
194
  const visitors = realm.getValue(importVisitors$).filter((visitor) => visitor !== guard);
117
195
  const hasVisitor = (node) => visitors.some((visitor) => typeof visitor.testNode === 'string'
118
196
  ? visitor.testNode === node.type
@@ -134,8 +212,14 @@ export function createMdxJsxPlugins(mdx) {
134
212
  realm.pub(addImportVisitor$, guard);
135
213
  },
136
214
  });
215
+ const blockExportPlugin = realmPlugin({
216
+ init(realm) {
217
+ realm.pub(addExportVisitor$, [quoteExportVisitor, paragraphOfBlocksExportVisitor]);
218
+ },
219
+ });
137
220
  return () => [
138
221
  jsxPlugin({ jsxComponentDescriptors: [catchAllJsxDescriptor] }),
139
222
  roundTripGuardPlugin(),
223
+ blockExportPlugin(),
140
224
  ];
141
225
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.0.68-int.103";
1
+ export declare const CANOPYCMS_VERSION = "0.0.68-int.105";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.0.68-int.103';
3
+ export const CANOPYCMS_VERSION = '0.0.68-int.105';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.0.68-int.103",
4
+ "version": "0.0.68-int.105",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -114,7 +114,7 @@
114
114
  "@mantine/hooks": "^7.14.2",
115
115
  "@mantine/modals": "^7.14.2",
116
116
  "@mantine/notifications": "^7.14.2",
117
- "@mdxeditor/editor": "^3.52.4",
117
+ "@mdxeditor/editor": "^4.3.2",
118
118
  "@octokit/auth-token": "^4.0.0",
119
119
  "@octokit/plugin-throttling": "^8.2.0",
120
120
  "@octokit/rest": "^20.0.2",