canopycms 0.0.68-int.103 → 0.0.68-int.104

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,6 +62,43 @@ 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: ['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 or quote says, or null. Its import joins a list
79
+ * item's adjacent paragraphs and moves a nested list into a new item after its own, so what follows
80
+ * the list comes out before it; its export writes an item tight, so `---` underlines a paragraph and
81
+ * a line continues a quote or table (`RUNS_INTO`); and 3.53 exports a quote as one paragraph.
82
+ */
83
+ function rearrangedBlocks(node) {
84
+ if (node.type === 'blockquote') {
85
+ return node.children.length > 1 ? 'a quote with more than one paragraph or block' : null;
86
+ }
87
+ if (node.type !== 'listItem')
88
+ return null;
89
+ for (let i = 1; i < node.children.length; i++) {
90
+ const before = node.children[i - 1].type;
91
+ const after = node.children[i].type;
92
+ if (before === 'list')
93
+ return 'a list item with content after its nested list';
94
+ if (RUNS_INTO[before]?.includes(after)) {
95
+ return before === 'paragraph' && after === 'paragraph'
96
+ ? 'a list item with more than one paragraph'
97
+ : `a list item with a ${BLOCK_NAMES[before] ?? before} followed by a ${BLOCK_NAMES[after] ?? after}`;
98
+ }
99
+ }
100
+ return null;
101
+ }
65
102
  /** Typed `string` because the mdast node union this package resolves omits the ESM node. */
66
103
  const ESM_NODE_TYPE = 'mdxjsEsm';
67
104
  /** Node types inside a table, imported by its cell editors rather than by visitors. */
@@ -92,27 +129,36 @@ export function createMdxJsxPlugins(mdx) {
92
129
  /**
93
130
  * Reports through `onError`, at import, what MDXEditor would otherwise lose or
94
131
  * 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.
132
+ * is a no-op), what `unsupportedJsx` and `rearrangedBlocks` reject, and content
133
+ * with no visitor inside a JSX element or table, whose children nested editors
134
+ * import later, only logging a failure and writing partial children back on
135
+ * edit. It throws an error class MDXEditor's import reports; any other error
136
+ * crashes the editor.
99
137
  */
100
138
  const roundTripGuardPlugin = realmPlugin({
101
139
  init(realm) {
102
140
  const guard = {
103
141
  priority: 100,
104
- testNode: (node) => node.type === ESM_NODE_TYPE || node.type === 'table' || isMdastJsxNode(node),
142
+ testNode: (node) => node.type === ESM_NODE_TYPE ||
143
+ node.type === 'table' ||
144
+ node.type === 'listItem' ||
145
+ node.type === 'blockquote' ||
146
+ isMdastJsxNode(node),
105
147
  visitNode({ mdastNode, descriptors, actions }) {
106
148
  if (mdastNode.type === ESM_NODE_TYPE) {
107
149
  throw new UnrecognizedMarkdownConstructError('import/export statements cannot be edited in the rich-text editor');
108
150
  }
109
151
  const reject = (node) => {
110
- const reason = unsupportedJsx(mdx, node);
152
+ const reason = unsupportedJsx(mdx, node) ?? rearrangedBlocks(node);
111
153
  if (reason !== null) {
112
154
  throw new UnrecognizedMarkdownConstructError(`${reason} cannot be edited in the rich-text editor`);
113
155
  }
114
156
  };
115
157
  reject(mdastNode);
158
+ if (mdastNode.type === 'listItem' || mdastNode.type === 'blockquote') {
159
+ actions.nextVisitor();
160
+ return;
161
+ }
116
162
  const visitors = realm.getValue(importVisitors$).filter((visitor) => visitor !== guard);
117
163
  const hasVisitor = (node) => visitors.some((visitor) => typeof visitor.testNode === 'string'
118
164
  ? visitor.testNode === node.type
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.104";
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.104';
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.104",
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": {