canopycms 0.0.68-int.101 → 0.0.68-int.102

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.
@@ -32,7 +32,8 @@ export interface ApiClientOptions {
32
32
  */
33
33
  trailingSlash?: boolean;
34
34
  /**
35
- * Called whenever a response comes back 401: the credential is no longer accepted. A
35
+ * Called whenever a response says the credential is no longer accepted: HTTP 401, or a
36
+ * handler's body with `status: 401` under the HTTP status `unauthenticatedStatus` chose. A
36
37
  * notification, not a retry; the editor's auth gate uses it to show sign-in.
37
38
  */
38
39
  onUnauthorized?: () => void;
@@ -341,7 +341,8 @@ export class CanopyApiClient {
341
341
  response = await this.fetchFn(url, init);
342
342
  parsed = await response.json().catch(() => undefined);
343
343
  }
344
- if (response.status === 401) {
344
+ // The body's status is the signal: `unauthenticatedStatus` may send it under HTTP 419.
345
+ if (response.status === 401 || (isApiResponseBody(parsed) && parsed.status === 401)) {
345
346
  this.onUnauthorized?.();
346
347
  return { ok: false, status: 401, error: errorFromBody(parsed) ?? 'Unauthorized' };
347
348
  }
@@ -14,7 +14,7 @@ export interface AuthPlugin {
14
14
  * groups; core applies bootstrap admins.
15
15
  *
16
16
  * CREDENTIAL failures (missing, invalid or expired token) RESOLVE to
17
- * `{ success: false }` and map to a 401. CONFIGURATION errors (an absent
17
+ * `{ success: false }` and map to `unauthenticatedStatus` (default 401). CONFIGURATION errors (an absent
18
18
  * CLERK_SECRET_KEY, say) may THROW instead: they are operator mistakes, and
19
19
  * belong in a loud 500 rather than a quiet auth denial. A custom adapter
20
20
  * calling this directly must be ready for that rejection.
package/dist/cli/cli.js CHANGED
@@ -1488,6 +1488,7 @@ var init_init = __esm({
1488
1488
  contentRoot: contentRootSchema.default("content"),
1489
1489
  sourceRoot: sourceRootSchema.optional(),
1490
1490
  basePath: basePathSchema,
1491
+ unauthenticatedStatus: z5.union([z5.literal(401), z5.literal(419)]).optional(),
1491
1492
  editor: editorConfigSchema.optional(),
1492
1493
  authPlugin: z5.custom().optional(),
1493
1494
  entryLinkUrl: z5.custom().optional(),
@@ -6901,6 +6902,7 @@ var init_generate_ai_content = __esm({
6901
6902
  contentRoot: contentRootSchema2.default("content"),
6902
6903
  sourceRoot: sourceRootSchema2.optional(),
6903
6904
  basePath: basePathSchema2,
6905
+ unauthenticatedStatus: z62.union([z62.literal(401), z62.literal(419)]).optional(),
6904
6906
  editor: editorConfigSchema2.optional(),
6905
6907
  authPlugin: z62.custom().optional(),
6906
6908
  entryLinkUrl: z62.custom().optional(),
@@ -8713,6 +8715,8 @@ var init_generate_ai_content = __esm({
8713
8715
  absolutePath: resolved,
8714
8716
  relativePath: path11.relative(this.root, resolved),
8715
8717
  logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
8718
+ collectionPath: schemaItem.logicalPath,
8719
+ slug: safeSlug,
8716
8720
  id,
8717
8721
  entryTypeName: finalEntryTypeName,
8718
8722
  existed
@@ -13318,6 +13322,7 @@ var init_config4 = __esm({
13318
13322
  contentRoot: contentRootSchema3.default("content"),
13319
13323
  sourceRoot: sourceRootSchema3.optional(),
13320
13324
  basePath: basePathSchema3,
13325
+ unauthenticatedStatus: z14.union([z14.literal(401), z14.literal(419)]).optional(),
13321
13326
  editor: editorConfigSchema3.optional(),
13322
13327
  authPlugin: z14.custom().optional(),
13323
13328
  entryLinkUrl: z14.custom().optional(),
@@ -529,6 +529,7 @@ var init_config = __esm({
529
529
  contentRoot: contentRootSchema.default("content"),
530
530
  sourceRoot: sourceRootSchema.optional(),
531
531
  basePath: basePathSchema,
532
+ unauthenticatedStatus: z6.union([z6.literal(401), z6.literal(419)]).optional(),
532
533
  editor: editorConfigSchema.optional(),
533
534
  authPlugin: z6.custom().optional(),
534
535
  entryLinkUrl: z6.custom().optional(),
@@ -3761,6 +3762,8 @@ var ContentStore = class {
3761
3762
  absolutePath: resolved,
3762
3763
  relativePath: path11.relative(this.root, resolved),
3763
3764
  logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
3765
+ collectionPath: schemaItem.logicalPath,
3766
+ slug: safeSlug,
3764
3767
  id,
3765
3768
  entryTypeName: finalEntryTypeName,
3766
3769
  existed
package/dist/cli/init.js CHANGED
@@ -379,6 +379,7 @@ var init_config = __esm({
379
379
  contentRoot: contentRootSchema.default("content"),
380
380
  sourceRoot: sourceRootSchema.optional(),
381
381
  basePath: basePathSchema,
382
+ unauthenticatedStatus: z5.union([z5.literal(401), z5.literal(419)]).optional(),
382
383
  editor: editorConfigSchema.optional(),
383
384
  authPlugin: z5.custom().optional(),
384
385
  entryLinkUrl: z5.custom().optional(),
@@ -71,6 +71,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
71
71
  contentRoot: z.ZodDefault<z.ZodDefault<z.ZodEffects<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>, string, string>>>;
72
72
  sourceRoot: z.ZodOptional<z.ZodOptional<z.ZodString>>;
73
73
  basePath: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
74
+ unauthenticatedStatus: z.ZodOptional<z.ZodUnion<[z.ZodLiteral<401>, z.ZodLiteral<419>]>>;
74
75
  editor: z.ZodOptional<z.ZodObject<{
75
76
  title: z.ZodOptional<z.ZodString>;
76
77
  subtitle: z.ZodOptional<z.ZodString>;
@@ -151,6 +152,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
151
152
  deploymentName?: string | undefined;
152
153
  sourceRoot?: string | undefined;
153
154
  basePath?: string | undefined;
155
+ unauthenticatedStatus?: 401 | 419 | undefined;
154
156
  editor?: {
155
157
  title?: string | undefined;
156
158
  subtitle?: string | undefined;
@@ -204,6 +206,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
204
206
  contentRoot?: string | undefined;
205
207
  sourceRoot?: string | undefined;
206
208
  basePath?: string | undefined;
209
+ unauthenticatedStatus?: 401 | 419 | undefined;
207
210
  editor?: {
208
211
  title?: string | undefined;
209
212
  subtitle?: string | undefined;
@@ -103,6 +103,7 @@ export const CanopyConfigSchema = z
103
103
  contentRoot: contentRootSchema.default('content'),
104
104
  sourceRoot: sourceRootSchema.optional(),
105
105
  basePath: basePathSchema,
106
+ unauthenticatedStatus: z.union([z.literal(401), z.literal(419)]).optional(),
106
107
  editor: editorConfigSchema.optional(),
107
108
  authPlugin: z.custom().optional(),
108
109
  entryLinkUrl: z.custom().optional(),
@@ -374,6 +374,8 @@ export interface CanopyConfig {
374
374
  sourceRoot?: SourceRoot;
375
375
  /** Deployment prefix the host app is served under — see {@link CanopyConfigInput.basePath}. */
376
376
  basePath?: string;
377
+ /** HTTP status of an unauthenticated API response — see {@link CanopyConfigInput.unauthenticatedStatus}. */
378
+ unauthenticatedStatus?: 401 | 419;
377
379
  editor?: CanopyEditorConfig;
378
380
  authPlugin?: AuthPlugin;
379
381
  /** Custom URL resolver for entry links — see {@link CanopyConfigInput.entryLinkUrl}. */
@@ -439,6 +441,19 @@ export interface CanopyConfigInput {
439
441
  * silently filter out every entry (zero static params, a build that goes green with no pages).
440
442
  */
441
443
  basePath?: string;
444
+ /**
445
+ * HTTP status of an API response to a request with no accepted session. Default 401.
446
+ *
447
+ * Set 419 when the site's own pages sit behind HTTP Basic auth on the same origin as the
448
+ * editor. Chrome, holding a Basic credential entered at the site root, sends it with the
449
+ * editor's API requests and drops it when one answers 401; the next gated request, such as a
450
+ * preview image, then waits on a Basic prompt. 419 rather than 403
451
+ * because a CloudFront custom error response cannot match 419, and one mapping 403 to an error
452
+ * page would replace the body below.
453
+ *
454
+ * The body says `status: 401` either way, and that is what the editor detects sign-out from.
455
+ */
456
+ unauthenticatedStatus?: 401 | 419;
442
457
  editor?: CanopyEditorConfig;
443
458
  authPlugin?: AuthPlugin;
444
459
  /** Custom URL resolver for entry links. Overrides the default URL computation. */
@@ -84,10 +84,19 @@ export interface ContentReadMeta {
84
84
  * `{type}.{slug}.{id}.{ext}`) -- see the `entryType` caveat above for what that implies.
85
85
  */
86
86
  entryId?: ContentId;
87
+ /** The entry's slug within its collection, by the rule `listEntries` uses. */
88
+ slug: Slug;
89
+ /** The entry's URL path with no query, by the rule `listEntries` uses. */
90
+ urlPath: string;
87
91
  }
88
92
  export interface ContentReader {
89
93
  read: <T = unknown>(input: ReadContentInput, message?: string) => Promise<{
90
94
  data: T;
95
+ /**
96
+ * The requested `entryPath` and slug as a percent-encoded URL path, plus `?branch=<name>`.
97
+ * Through an entry-type `entryPath` it is not the entry's URL. Compare entries by
98
+ * `meta.urlPath`.
99
+ */
91
100
  path: string;
92
101
  meta: ContentReadMeta;
93
102
  }>;
@@ -2,7 +2,7 @@ import { loadBranchContext, loadOrCreateBranchContext } from './branch-workspace
2
2
  import { ContentStore, ContentStoreError } from './content-store.js';
3
3
  import { namesNoWorkspace, resolveBranchPaths, } from './paths/index.js';
4
4
  import { trimSlashes } from './paths/normalize.js';
5
- import { isIndexSlug } from './utils/entry-url.js';
5
+ import { computeEntryUrl, isIndexSlug } from './utils/entry-url.js';
6
6
  import { isDeployedStatic, isBuildMode, readsFromCheckout } from './build-mode.js';
7
7
  import { isNotFoundError } from './utils/error.js';
8
8
  import { resolveEntryLinksInData } from './entry-link-resolver.js';
@@ -141,6 +141,8 @@ export const createContentReader = (options) => {
141
141
  // key by them without a separate listEntries lookup or filename parse.
142
142
  let entryType;
143
143
  let entryId;
144
+ let urlPath;
145
+ let resolvedSlug;
144
146
  try {
145
147
  const resolved = await store.resolveDocumentPath(entryPath, slug ?? '');
146
148
  logicalPath = resolved.logicalPath;
@@ -150,6 +152,8 @@ export const createContentReader = (options) => {
150
152
  // absent, for legacy entry files without an embedded ID (see ContentReadMeta).
151
153
  entryType = resolved.entryTypeName;
152
154
  entryId = resolved.id;
155
+ resolvedSlug = resolved.slug;
156
+ urlPath = computeEntryUrl(resolved.collectionPath, resolved.slug, contentRoot);
153
157
  }
154
158
  catch (err) {
155
159
  const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
@@ -196,7 +200,7 @@ export const createContentReader = (options) => {
196
200
  resolveReferences: input.resolveReferences ?? true,
197
201
  referenceAccess,
198
202
  });
199
- return { doc, store, physicalPath, entryType, entryId };
203
+ return { doc, store, physicalPath, entryType, entryId, slug: resolvedSlug, urlPath };
200
204
  }
201
205
  catch (err) {
202
206
  if (isNotFoundError(err))
@@ -211,7 +215,7 @@ export const createContentReader = (options) => {
211
215
  const defaultMessage = `Content not found for ${entryPath}${slug ? `/${slug}` : ''} on branch ${branchName}`;
212
216
  throw new ContentStoreError(message ?? defaultMessage, 'NOT_FOUND');
213
217
  }
214
- const { doc, store, physicalPath, entryType, entryId } = result;
218
+ const { doc, store, physicalPath, entryType, entryId, slug: entrySlug, urlPath } = result;
215
219
  // For md/mdx format, merge the body into the data so callers get a complete object.
216
220
  // The field name comes from the schema's isBody flag (defaults to 'body').
217
221
  const docRecord = doc;
@@ -228,7 +232,7 @@ export const createContentReader = (options) => {
228
232
  slug,
229
233
  branch: branchName,
230
234
  });
231
- return { data, path, meta: { physicalPath, entryType, entryId } };
235
+ return { data, path, meta: { physicalPath, entryType, entryId, slug: entrySlug, urlPath } };
232
236
  };
233
237
  return { read };
234
238
  };
@@ -365,6 +365,10 @@ export declare class ContentStore {
365
365
  relativePath: PhysicalPath;
366
366
  /** The entry's logical path, the form path-permission rules match (`entryLogicalPath`). */
367
367
  logicalPath: LogicalPath;
368
+ /** The collection the entry file lives in: the parent, when an entry-type item delegated. */
369
+ collectionPath: LogicalPath;
370
+ /** The entry's slug within that collection, lowercased as listings report it. */
371
+ slug: Slug;
368
372
  id?: string;
369
373
  /**
370
374
  * Always populated for a valid schema item: the collection branch below resolves a name
@@ -604,6 +604,8 @@ export class ContentStore {
604
604
  absolutePath: resolved,
605
605
  relativePath: path.relative(this.root, resolved),
606
606
  logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
607
+ collectionPath: schemaItem.logicalPath,
608
+ slug: safeSlug,
607
609
  id,
608
610
  entryTypeName: finalEntryTypeName,
609
611
  existed,
@@ -24,7 +24,8 @@ export interface ApiClientProviderProps {
24
24
  */
25
25
  export declare function ApiClientProvider({ children, client, basePath }: ApiClientProviderProps): import("react/jsx-runtime").JSX.Element;
26
26
  /**
27
- * Subscribe to every 401 the provider's client receives, from any request. `listener` should be
27
+ * Subscribe to every unauthenticated response (`ApiClientOptions.onUnauthorized`) the provider's
28
+ * client receives, from any request. `listener` should be
28
29
  * stable (e.g. from `useCallback`): a new function re-subscribes. No-op outside a provider.
29
30
  */
30
31
  export declare function useOnUnauthorized(listener: () => void): void;
@@ -48,7 +48,8 @@ export function ApiClientProvider({ children, client, basePath }) {
48
48
  return (_jsx(UnauthorizedSignalContext.Provider, { value: unauthorized, children: _jsx(ApiClientContext.Provider, { value: apiClient, children: children }) }));
49
49
  }
50
50
  /**
51
- * Subscribe to every 401 the provider's client receives, from any request. `listener` should be
51
+ * Subscribe to every unauthenticated response (`ApiClientOptions.onUnauthorized`) the provider's
52
+ * client receives, from any request. `listener` should be
52
53
  * stable (e.g. from `useCallback`): a new function re-subscribes. No-op outside a provider.
53
54
  */
54
55
  export function useOnUnauthorized(listener) {
@@ -4,15 +4,19 @@ import React, { Suspense, useId, useRef, useCallback, useEffect, useState } from
4
4
  import { Alert, Button, Group, Text, Textarea } from '@mantine/core';
5
5
  import { InsertEntryLink } from './entry-link/index.js';
6
6
  import { MdxImageDialog } from './MdxImageDialog.js';
7
+ import { createMdxJsxPlugins } from './mdx-jsx-support.js';
7
8
  import { useApiClient, useAssetContext } from '../context/index.js';
8
9
  import { editorImageSrc } from '../media/editor-image-src.js';
9
10
  import { uploadAsset } from '../media/upload-asset.js';
10
11
  import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
11
12
  const MDXEditorLazy = React.lazy(async () => {
12
- const [{ MDXEditor, headingsPlugin, listsPlugin, quotePlugin, thematicBreakPlugin, markdownShortcutPlugin, linkPlugin, linkDialogPlugin, imagePlugin, tablePlugin, toolbarPlugin, codeBlockPlugin, codeMirrorPlugin, BoldItalicUnderlineToggles, BlockTypeSelect, ListsToggle, CreateLink, InsertImage, InsertTable, InsertThematicBreak, CodeToggle, InsertCodeBlock, UndoRedo, Separator, insertMarkdown$, usePublisher, useCellValues, saveImage$, closeImageDialog$, imageDialogState$, activeEditor$, $isImageNode,
13
+ // MDXEditor's only code import (theme.tsx loads its CSS); see mdx-jsx-support for why.
14
+ const mdx = await import('@mdxeditor/editor');
15
+ const { MDXEditor, headingsPlugin, listsPlugin, quotePlugin, thematicBreakPlugin, markdownShortcutPlugin, linkPlugin, linkDialogPlugin, imagePlugin, tablePlugin, toolbarPlugin, codeBlockPlugin, codeMirrorPlugin, BoldItalicUnderlineToggles, BlockTypeSelect, ListsToggle, CreateLink, InsertImage, InsertTable, InsertThematicBreak, CodeToggle, InsertCodeBlock, UndoRedo, Separator, insertMarkdown$, usePublisher, useCellValues, saveImage$, closeImageDialog$, imageDialogState$, activeEditor$, $isImageNode,
13
16
  // MDXEditor's own lexical instance: lexical keeps the active editor state per module, so
14
17
  // a separately resolved copy would throw inside this editor's `read()`.
15
- lexical: { $getNodeByKey }, }, { mdxJsxPlugins },] = await Promise.all([import('@mdxeditor/editor'), import('./mdx-jsx-support.js')]);
18
+ lexical: { $getNodeByKey }, } = mdx;
19
+ const mdxJsxPlugins = createMdxJsxPlugins(mdx);
16
20
  const EntryLinkToolbarButton = ({ onInsert }) => {
17
21
  const insertMarkdown = usePublisher(insertMarkdown$);
18
22
  return (_jsx(InsertEntryLink, { onInsert: (markdown) => onInsert(() => insertMarkdown(markdown), markdown) }));
@@ -1 +1,5 @@
1
- export declare function mdxJsxPlugins(): import("@mdxeditor/editor").RealmPlugin[];
1
+ import type * as MdxEditor from '@mdxeditor/editor';
2
+ type MdxEditorModule = typeof MdxEditor;
3
+ /** Creates the JSX plugins from MDXEditor's exports; call once per load of MDXEditor. */
4
+ export declare function createMdxJsxPlugins(mdx: MdxEditorModule): () => MdxEditor.RealmPlugin[];
5
+ export {};
@@ -1,6 +1,5 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { NestedLexicalEditor, UnrecognizedMarkdownConstructError, addImportVisitor$, importVisitors$, isMdastHTMLNode, isMdastJsxNode, jsxPlugin, realmPlugin, } from '@mdxeditor/editor';
4
3
  function childrenOf(node) {
5
4
  return 'children' in node && Array.isArray(node.children) ? node.children : [];
6
5
  }
@@ -13,26 +12,6 @@ function describeAttribute(attribute) {
13
12
  return `${attribute.name}="${attribute.value}"`;
14
13
  return `${attribute.name}={…}`;
15
14
  }
16
- /**
17
- * Shows an element's tag and attributes read-only (they are edited as source)
18
- * and edits its children, inline or block per the parsed node: one `*`
19
- * descriptor serves both, and a block editor rejects inline children.
20
- */
21
- const CatchAllJsxEditor = ({ mdastNode }) => {
22
- const inline = mdastNode.type === 'mdxJsxTextElement';
23
- const tag = [mdastNode.name ?? '', ...mdastNode.attributes.map(describeAttribute)]
24
- .join(' ')
25
- .trim();
26
- const Wrapper = inline ? 'span' : 'div';
27
- return (_jsxs(Wrapper, { className: inline ? 'canopy-mdx-jsx canopy-mdx-jsx-inline' : 'canopy-mdx-jsx', children: [_jsx("span", { className: "canopy-mdx-jsx-tag", "data-testid": "mdx-jsx-tag", title: tag, children: `<${tag}>` }), mdastNode.children.length > 0 ? (_jsx(NestedLexicalEditor, { block: !inline, getContent: (node) => node.children, getUpdatedMdastNode: (node, children) => ({ ...node, children }) })) : null] }));
28
- };
29
- const catchAllJsxDescriptor = {
30
- name: '*',
31
- kind: 'flow',
32
- props: [],
33
- hasChildren: true,
34
- Editor: CatchAllJsxEditor,
35
- };
36
15
  const isStringAttribute = (attribute, name) => attribute.type === 'mdxJsxAttribute' &&
37
16
  attribute.name === name &&
38
17
  typeof attribute.value === 'string';
@@ -70,68 +49,93 @@ function roundTripsImage(node) {
70
49
  }
71
50
  // Why MDXEditor cannot round-trip this JSX element, or null if it can. A
72
51
  // fragment has no name, which MDXEditor's HTML handling throws on.
73
- function unsupportedJsx(node) {
74
- if (!isMdastJsxNode(node))
52
+ function unsupportedJsx(mdx, node) {
53
+ if (!mdx.isMdastJsxNode(node))
75
54
  return null;
76
55
  if (node.name === null)
77
56
  return 'fragments (<>…</>)';
78
57
  // `img` is the image plugin's, not in MDXEditor's HTML tag list.
79
58
  if (node.name === 'img')
80
59
  return roundTripsImage(node) ? null : 'this <img>';
81
- if (isMdastHTMLNode(node) && breaksSpanCollapse(node)) {
60
+ if (mdx.isMdastHTMLNode(node) && breaksSpanCollapse(node)) {
82
61
  return `<${node.name}> wrapping a <span>, with an {expression} class or style`;
83
62
  }
84
63
  return null;
85
64
  }
86
65
  /** Typed `string` because the mdast node union this package resolves omits the ESM node. */
87
66
  const ESM_NODE_TYPE = 'mdxjsEsm';
88
- /** Node types the table visitor imports itself, so they have no visitor of their own. */
67
+ /** Node types inside a table, imported by its cell editors rather than by visitors. */
89
68
  const CONSUMED_BY_PARENT_VISITOR = new Set(['tableRow', 'tableCell']);
90
- /**
91
- * Reports through `onError`, at import, what MDXEditor would otherwise lose
92
- * silently: `import`/`export` lines (its visitor for them is a no-op), elements
93
- * `unsupportedJsx` rejects, and content with no visitor inside a JSX element or
94
- * table, whose children nested editors import later, only logging a failure and
95
- * writing partial children back on edit. It throws an error class MDXEditor's
96
- * import reports; any other error would crash the editor.
97
- */
98
- const roundTripGuardPlugin = realmPlugin({
99
- init(realm) {
100
- const guard = {
101
- priority: 100,
102
- testNode: (node) => node.type === ESM_NODE_TYPE || node.type === 'table' || isMdastJsxNode(node),
103
- visitNode({ mdastNode, descriptors, actions }) {
104
- if (mdastNode.type === ESM_NODE_TYPE) {
105
- throw new UnrecognizedMarkdownConstructError('import/export statements cannot be edited in the rich-text editor');
106
- }
107
- const reject = (node) => {
108
- const reason = unsupportedJsx(node);
109
- if (reason !== null) {
110
- throw new UnrecognizedMarkdownConstructError(`${reason} cannot be edited in the rich-text editor`);
69
+ /** Creates the JSX plugins from MDXEditor's exports; call once per load of MDXEditor. */
70
+ export function createMdxJsxPlugins(mdx) {
71
+ const { NestedLexicalEditor, UnrecognizedMarkdownConstructError, addImportVisitor$, importVisitors$, isMdastJsxNode, jsxPlugin, realmPlugin, } = mdx;
72
+ /**
73
+ * Shows an element's tag and attributes read-only (they are edited as source)
74
+ * and edits its children, inline or block per the parsed node: one `*`
75
+ * descriptor serves both, and a block editor rejects inline children.
76
+ */
77
+ const CatchAllJsxEditor = ({ mdastNode }) => {
78
+ const inline = mdastNode.type === 'mdxJsxTextElement';
79
+ const tag = [mdastNode.name ?? '', ...mdastNode.attributes.map(describeAttribute)]
80
+ .join(' ')
81
+ .trim();
82
+ const Wrapper = inline ? 'span' : 'div';
83
+ return (_jsxs(Wrapper, { className: inline ? 'canopy-mdx-jsx canopy-mdx-jsx-inline' : 'canopy-mdx-jsx', children: [_jsx("span", { className: "canopy-mdx-jsx-tag", "data-testid": "mdx-jsx-tag", title: tag, children: `<${tag}>` }), mdastNode.children.length > 0 ? (_jsx(NestedLexicalEditor, { block: !inline, getContent: (node) => node.children, getUpdatedMdastNode: (node, children) => ({ ...node, children }) })) : null] }));
84
+ };
85
+ const catchAllJsxDescriptor = {
86
+ name: '*',
87
+ kind: 'flow',
88
+ props: [],
89
+ hasChildren: true,
90
+ Editor: CatchAllJsxEditor,
91
+ };
92
+ /**
93
+ * Reports through `onError`, at import, what MDXEditor would otherwise lose or
94
+ * 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.
99
+ */
100
+ const roundTripGuardPlugin = realmPlugin({
101
+ init(realm) {
102
+ const guard = {
103
+ priority: 100,
104
+ testNode: (node) => node.type === ESM_NODE_TYPE || node.type === 'table' || isMdastJsxNode(node),
105
+ visitNode({ mdastNode, descriptors, actions }) {
106
+ if (mdastNode.type === ESM_NODE_TYPE) {
107
+ throw new UnrecognizedMarkdownConstructError('import/export statements cannot be edited in the rich-text editor');
111
108
  }
112
- };
113
- reject(mdastNode);
114
- const visitors = realm.getValue(importVisitors$).filter((visitor) => visitor !== guard);
115
- const hasVisitor = (node) => visitors.some((visitor) => typeof visitor.testNode === 'string'
116
- ? visitor.testNode === node.type
117
- : visitor.testNode(node, descriptors));
118
- const check = (node) => {
119
- for (const child of childrenOf(node)) {
120
- reject(child);
121
- if (!CONSUMED_BY_PARENT_VISITOR.has(child.type) && !hasVisitor(child)) {
122
- const where = isMdastJsxNode(mdastNode) ? `<${mdastNode.name ?? ''}>` : 'A table';
123
- throw new UnrecognizedMarkdownConstructError(`${where} contains ${child.type} content the rich-text editor cannot edit`);
109
+ const reject = (node) => {
110
+ const reason = unsupportedJsx(mdx, node);
111
+ if (reason !== null) {
112
+ throw new UnrecognizedMarkdownConstructError(`${reason} cannot be edited in the rich-text editor`);
124
113
  }
125
- check(child);
126
- }
127
- };
128
- check(mdastNode);
129
- actions.nextVisitor();
130
- },
131
- };
132
- realm.pub(addImportVisitor$, guard);
133
- },
134
- });
135
- export function mdxJsxPlugins() {
136
- return [jsxPlugin({ jsxComponentDescriptors: [catchAllJsxDescriptor] }), roundTripGuardPlugin()];
114
+ };
115
+ reject(mdastNode);
116
+ const visitors = realm.getValue(importVisitors$).filter((visitor) => visitor !== guard);
117
+ const hasVisitor = (node) => visitors.some((visitor) => typeof visitor.testNode === 'string'
118
+ ? visitor.testNode === node.type
119
+ : visitor.testNode(node, descriptors));
120
+ const check = (node) => {
121
+ for (const child of childrenOf(node)) {
122
+ reject(child);
123
+ if (!CONSUMED_BY_PARENT_VISITOR.has(child.type) && !hasVisitor(child)) {
124
+ const where = isMdastJsxNode(mdastNode) ? `<${mdastNode.name ?? ''}>` : 'A table';
125
+ throw new UnrecognizedMarkdownConstructError(`${where} contains ${child.type} content the rich-text editor cannot edit`);
126
+ }
127
+ check(child);
128
+ }
129
+ };
130
+ check(mdastNode);
131
+ actions.nextVisitor();
132
+ },
133
+ };
134
+ realm.pub(addImportVisitor$, guard);
135
+ },
136
+ });
137
+ return () => [
138
+ jsxPlugin({ jsxComponentDescriptors: [catchAllJsxDescriptor] }),
139
+ roundTripGuardPlugin(),
140
+ ];
137
141
  }
@@ -86,6 +86,11 @@ const parseQueryParams = (url) => {
86
86
  return {};
87
87
  }
88
88
  };
89
+ /**
90
+ * The answer to a request with no accepted session. The body's `status: 401` is what the client
91
+ * detects sign-out from, so it stays 401 whatever HTTP status `unauthenticatedStatus` picks.
92
+ */
93
+ const unauthenticatedResponse = (config, error) => jsonResponse({ ok: false, status: 401, error: error ?? 'Unauthorized' }, config.unauthenticatedStatus ?? 401);
89
94
  /**
90
95
  * The core handler behind every Canopy API request. A framework adapter
91
96
  * (Next.js, Express, Hono) converts its request to a CanopyRequest, extracts
@@ -129,7 +134,7 @@ export function createCanopyRequestHandler(options) {
129
134
  // any workspace provisioning below, so they can neither trigger expensive
130
135
  // git operations nor read provisioning error details.
131
136
  if (!authResult.success || !authResult.user) {
132
- return jsonResponse({ ok: false, status: 401, error: authResult.error ?? 'Unauthorized' }, 401);
137
+ return unauthenticatedResponse(apiCtx.services.config, authResult.error);
133
138
  }
134
139
  // Provision the base/active branch workspace on first request, so the many
135
140
  // endpoints that assume it exists (registry reads and the like) don't return
@@ -222,7 +227,7 @@ export function createCanopyRequestHandler(options) {
222
227
  }
223
228
  }
224
229
  if (user.type === 'anonymous') {
225
- return jsonResponse({ ok: false, status: 401, error: authResult.error ?? 'Unauthorized' }, 401);
230
+ return unauthenticatedResponse(apiCtx.services.config, authResult.error);
226
231
  }
227
232
  const queryParams = parseQueryParams(req.url);
228
233
  const mergedParams = { ...queryParams, ...match.params };
@@ -54,6 +54,13 @@ export interface UrlExclusivityReport {
54
54
  expectedEntryId?: string;
55
55
  actualEntryId?: string;
56
56
  }>;
57
+ /** Published URLs whose read reports a `meta.slug` or `meta.urlPath` other than the listing's. */
58
+ misreported: Array<{
59
+ urlPath: string;
60
+ slug: string;
61
+ readSlug: string;
62
+ readUrlPath: string;
63
+ }>;
57
64
  /** Every adjacent URL probed, sorted. */
58
65
  probes: string[];
59
66
  /** Probes that resolved despite not being published -- the phantom URLs. Must be empty. */
@@ -80,12 +80,21 @@ export const collectUrlExclusivityReport = async (ctx, schema, contentRoot = 'co
80
80
  const items = await ctx.listEntries();
81
81
  const unresolved = [];
82
82
  const mismatched = [];
83
+ const misreported = [];
83
84
  for (const item of items) {
84
85
  const result = await ctx.readByUrlPath(item.urlPath);
85
86
  if (!result) {
86
87
  unresolved.push(item.urlPath);
87
88
  continue;
88
89
  }
90
+ if (result.meta.slug !== item.slug || result.meta.urlPath !== item.urlPath) {
91
+ misreported.push({
92
+ urlPath: item.urlPath,
93
+ slug: item.slug,
94
+ readSlug: result.meta.slug,
95
+ readUrlPath: result.meta.urlPath,
96
+ });
97
+ }
89
98
  if (result.meta.entryId !== item.entryId) {
90
99
  mismatched.push({
91
100
  urlPath: item.urlPath,
@@ -108,6 +117,7 @@ export const collectUrlExclusivityReport = async (ctx, schema, contentRoot = 'co
108
117
  duplicates: findDuplicateUrlPaths(items),
109
118
  unresolved: unresolved.sort(),
110
119
  mismatched,
120
+ misreported,
111
121
  probes: [...probes].sort(),
112
122
  phantoms: phantoms.sort(),
113
123
  };
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.0.68-int.101";
1
+ export declare const CANOPYCMS_VERSION = "0.0.68-int.102";
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.101';
3
+ export const CANOPYCMS_VERSION = '0.0.68-int.102';
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.101",
4
+ "version": "0.0.68-int.102",
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": {