canopycms 0.3.2 → 0.4.0

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.
@@ -1,4 +1,5 @@
1
1
  'use client';
2
+ import { autolinkLiteralAt, gfmAutolinkLiteral, gfmAutolinkLiteralFromMarkdown, } from './gfm-autolink-literal.js';
2
3
  function isListLike(node) {
3
4
  return (typeof node === 'object' &&
4
5
  node !== null &&
@@ -17,6 +18,26 @@ const FORMAT_CONTAINER_TYPES = ['emphasis', 'strong', 'delete'];
17
18
  function isFormatContainer(node) {
18
19
  return FORMAT_CONTAINER_TYPES.includes(node.type);
19
20
  }
21
+ /** Cuts `tail` off `nodes`' last text at any depth, returned in the spans it was cut from. */
22
+ function cutTail(nodes, tail) {
23
+ const last = nodes.at(-1);
24
+ if (last?.type === 'text') {
25
+ if (!last.value.endsWith(tail))
26
+ return undefined;
27
+ last.value = last.value.slice(0, last.value.length - tail.length);
28
+ if (last.value === '')
29
+ nodes.pop();
30
+ return { type: 'text', value: tail };
31
+ }
32
+ if (last === undefined || !isFormatContainer(last))
33
+ return undefined;
34
+ const cut = cutTail(last.children, tail);
35
+ if (cut === undefined)
36
+ return undefined;
37
+ if (last.children.length === 0)
38
+ nodes.pop();
39
+ return { ...last, children: [cut] };
40
+ }
20
41
  /** `nodes` with every container of a type in `types` replaced by its children, at any depth. */
21
42
  function withoutContainers(nodes, types) {
22
43
  return nodes.flatMap((node) => {
@@ -28,7 +49,7 @@ function withoutContainers(nodes, types) {
28
49
  }
29
50
  /** Creates the plugin from MDXEditor's exports; call once per load of MDXEditor. */
30
51
  export function createMarkdownFidelityPlugin(mdx) {
31
- const { IS_BOLD, IS_CODE, IS_ITALIC, IS_STRIKETHROUGH, addExportVisitor$, addImportVisitor$, lexical: { $isElementNode, $isTextNode }, realmPlugin, } = mdx;
52
+ const { IS_BOLD, IS_CODE, IS_ITALIC, IS_STRIKETHROUGH, addExportVisitor$, addImportVisitor$, addMdastExtension$, addSyntaxExtension$, lexical: { $getState, $isElementNode, $isTextNode, $setState, createState }, realmPlugin, } = mdx;
32
53
  const FORMATS = [
33
54
  { format: IS_ITALIC, type: 'emphasis' },
34
55
  { format: IS_BOLD, type: 'strong' },
@@ -39,6 +60,49 @@ export function createMarkdownFidelityPlugin(mdx) {
39
60
  const texts = $isElementNode(node) ? node.getChildren().filter($isTextNode) : [];
40
61
  return texts.length === 0 ? 0 : texts.reduce((all, text) => all & text.getFormat(), ~0);
41
62
  };
63
+ /**
64
+ * The href a bare URL was imported with. MDXEditor imports it as a LinkNode, whose href, unlike
65
+ * an AutoLinkNode's, never follows its text; while the href is still this one, the link is
66
+ * still the bare URL it was.
67
+ */
68
+ const bareUrlHref = createState('canopyBareUrlHref', {
69
+ parse: (value) => (typeof value === 'string' ? value : null),
70
+ });
71
+ /** A link written as a bare URL: an auto-link, or an imported one whose href is untouched. */
72
+ const isBareUrl = (link) => link.getType() === 'autolink' || $getState(link, bareUrlHref) === link.getURL();
73
+ /** The literal GFM reads at `link` in its paragraph's text, which may run on past `link`. */
74
+ const literalAt = (link) => {
75
+ let context = '';
76
+ let offset = 0;
77
+ for (const sibling of link.getParent()?.getChildren() ?? [link]) {
78
+ if (sibling.is(link))
79
+ offset = context.length;
80
+ context += sibling.getTextContent();
81
+ }
82
+ return autolinkLiteralAt(context, offset);
83
+ };
84
+ const bareUrlImportVisitor = {
85
+ priority: 100,
86
+ testNode: (node) => {
87
+ if (node.type !== 'link')
88
+ return false;
89
+ const [only, ...rest] = 'children' in node ? node.children : [];
90
+ if (rest.length > 0 || only?.type !== 'text')
91
+ return false;
92
+ const literal = autolinkLiteralAt(only.value, 0);
93
+ const start = node.position?.start.offset;
94
+ const end = node.position?.end.offset;
95
+ // A literal's source is its text; `[url](url)` is longer. GFM's own transform sets none.
96
+ const bare = start === undefined || end === undefined || end - start === only.value.length;
97
+ return bare && literal?.url === node.url && literal.length === only.value.length;
98
+ },
99
+ visitNode({ mdastNode, lexicalParent, actions }) {
100
+ actions.nextVisitor();
101
+ const link = $isElementNode(lexicalParent) ? lexicalParent.getLastChild() : null;
102
+ if (link !== null && isLinkLike(link))
103
+ $setState(link, bareUrlHref, mdastNode.url);
104
+ },
105
+ };
42
106
  // MDXEditor imports a list without its `start`, and exports one without it.
43
107
  const listStartImportVisitor = {
44
108
  priority: 100,
@@ -87,6 +151,10 @@ export function createMarkdownFidelityPlugin(mdx) {
87
151
  *
88
152
  * An auto-link holding code-formatted text is written as its text: lexical's auto-link matchers
89
153
  * see text, not formats, so a URL inside a code span becomes a link markdown never meant.
154
+ *
155
+ * A bare URL (`isBareUrl`) is written as GFM reads its text where it stands: GFM's link and the
156
+ * punctuation GFM leaves out. Where GFM links nothing, an auto-link is written as text and an
157
+ * imported one as the link the editor shows.
90
158
  */
91
159
  const linkExportVisitor = {
92
160
  priority: 100,
@@ -102,6 +170,16 @@ export function createMarkdownFidelityPlugin(mdx) {
102
170
  actions.visitChildren(lexicalNode, mdastParent);
103
171
  return;
104
172
  }
173
+ let literal;
174
+ if (isBareUrl(lexicalNode)) {
175
+ const text = lexicalNode.getTextContent();
176
+ const here = literalAt(lexicalNode);
177
+ if (here === undefined && lexicalNode.getType() === 'autolink') {
178
+ actions.visitChildren(lexicalNode, mdastParent);
179
+ return;
180
+ }
181
+ literal = here && (here.length <= text.length ? here : autolinkLiteralAt(text, 0));
182
+ }
105
183
  const format = sharedFormat(lexicalNode);
106
184
  const next = lexicalNode.getNextSibling();
107
185
  const carried = new Set();
@@ -127,7 +205,7 @@ export function createMarkdownFidelityPlugin(mdx) {
127
205
  }
128
206
  const link = {
129
207
  type: 'link',
130
- url: lexicalNode.getURL(),
208
+ url: literal?.url ?? lexicalNode.getURL(),
131
209
  title: lexicalNode.getTitle(),
132
210
  children: [],
133
211
  };
@@ -135,6 +213,10 @@ export function createMarkdownFidelityPlugin(mdx) {
135
213
  actions.visitChildren(lexicalNode, link);
136
214
  if (carried.size > 0)
137
215
  link.children = withoutContainers(link.children, carried);
216
+ const tail = literal ? lexicalNode.getTextContent().slice(literal.length) : '';
217
+ const cut = tail === '' ? undefined : cutTail(link.children, tail);
218
+ if (cut !== undefined)
219
+ actions.appendToParent(parent, cut);
138
220
  },
139
221
  };
140
222
  // Markdown has no way to write two code spans side by side, and an auto-link written as its
@@ -149,7 +231,10 @@ export function createMarkdownFidelityPlugin(mdx) {
149
231
  };
150
232
  return realmPlugin({
151
233
  init(realm) {
152
- realm.pub(addImportVisitor$, listStartImportVisitor);
234
+ // Without autolink literals a bare URL imports as text, for lexical's matcher to re-link.
235
+ realm.pub(addSyntaxExtension$, gfmAutolinkLiteral());
236
+ realm.pub(addMdastExtension$, gfmAutolinkLiteralFromMarkdown());
237
+ realm.pub(addImportVisitor$, [listStartImportVisitor, bareUrlImportVisitor]);
153
238
  realm.pub(addExportVisitor$, [
154
239
  listStartExportVisitor,
155
240
  linkExportVisitor,
@@ -48,7 +48,10 @@ type InferableField = {
48
48
  entryTypes?: readonly string[];
49
49
  /** For reference fields: collection paths to scope the search. */
50
50
  collections?: readonly string[];
51
- /** For reference fields: resolve the target's body too. See ReferenceFieldConfig.includeBody. */
51
+ /**
52
+ * For reference fields: resolve the target's body too. Without it the `resolvedSchema`'s
53
+ * `isBody` field is optional in the resolved type. See ReferenceFieldConfig.includeBody.
54
+ */
52
55
  includeBody?: boolean;
53
56
  };
54
57
  /**
@@ -79,7 +82,9 @@ export interface ResolvedReferenceMeta {
79
82
  }
80
83
  /**
81
84
  * Assemble a resolved reference: the target's own data, then its body if the field asked to
82
- * embed it, then the reserved metadata.
85
+ * embed it, then the reserved metadata. A link-only reference carries no body for any format:
86
+ * an md/mdx target passes none here, and a json/yaml target's `data` arrives without its
87
+ * `isBody` field (`linkOnlyReferenceData`), so both formats resolve alike.
83
88
  *
84
89
  * The ordering is the contract, not a detail. Metadata LAST means a target that models `id` as
85
90
  * a content field cannot shadow the real content ID, which the write boundary recovers from
@@ -272,7 +277,7 @@ type FieldValue<F extends InferableField> = F extends {
272
277
  } ? ScalarValue<F, SelectValue<F>> : F extends {
273
278
  type: 'reference';
274
279
  resolvedSchema: infer S;
275
- } ? ScalarValue<F, (InferContentShape<Extract<S, readonly InferableField[]>> & ResolvedReferenceMeta & {
280
+ } ? ScalarValue<F, (InferContentShape<Extract<S, readonly InferableField[]>, F['includeBody'] extends true ? false : true> & ResolvedReferenceMeta & {
276
281
  unavailable?: undefined;
277
282
  }) | UnavailableReference | null> : F extends {
278
283
  type: 'reference';
@@ -298,11 +303,16 @@ type FieldValue<F extends InferableField> = F extends {
298
303
  *
299
304
  * Works with any structurally compatible array; importing FieldConfig is not required.
300
305
  */
301
- type InferContentShape<Fields extends readonly InferableField[]> = Simplify<{
302
- [F in FlattenInlineGroups<Fields>[number] as F['required'] extends false ? never : F['name']]: FieldValue<F>;
306
+ type InferContentShape<Fields extends readonly InferableField[], BodyOptional extends boolean = false> = Simplify<{
307
+ [F in FlattenInlineGroups<Fields>[number] as IsOptionalKey<F, BodyOptional> extends true ? never : F['name']]: FieldValue<F>;
303
308
  } & {
304
- [F in FlattenInlineGroups<Fields>[number] as F['required'] extends false ? F['name'] : never]?: FieldValue<F>;
309
+ [F in FlattenInlineGroups<Fields>[number] as IsOptionalKey<F, BodyOptional> extends true ? F['name'] : never]?: FieldValue<F>;
305
310
  }>;
311
+ /**
312
+ * Whether InferContentShape gives a field `?:`: an explicit `required: false`, or, with
313
+ * `BodyOptional` (a reference target resolved without `includeBody`), the `isBody` field.
314
+ */
315
+ type IsOptionalKey<F extends InferableField, BodyOptional extends boolean> = F['required'] extends false ? true : BodyOptional extends true ? F['isBody'] extends true ? true : false : false;
306
316
  /** Define an entry schema's field array with literal inference, without sprinkling `as const`. */
307
317
  export declare const defineEntrySchema: <const T extends readonly InferableField[]>(fields: T) => T;
308
318
  /** Content shape derived from a `defineEntrySchema` result. */
@@ -12,7 +12,9 @@ import { DEFAULT_SEO_FIELD_NAMES } from './static/seo.js';
12
12
  export const RESOLVED_REFERENCE_KEYS = ['id', 'slug', 'collection', 'urlPath'];
13
13
  /**
14
14
  * Assemble a resolved reference: the target's own data, then its body if the field asked to
15
- * embed it, then the reserved metadata.
15
+ * embed it, then the reserved metadata. A link-only reference carries no body for any format:
16
+ * an md/mdx target passes none here, and a json/yaml target's `data` arrives without its
17
+ * `isBody` field (`linkOnlyReferenceData`), so both formats resolve alike.
16
18
  *
17
19
  * The ordering is the contract, not a detail. Metadata LAST means a target that models `id` as
18
20
  * a content field cannot shadow the real content ID, which the write boundary recovers from
@@ -1,4 +1,4 @@
1
- import type { FieldConfig } from '../config/index.js';
1
+ import type { ContentFormat, FieldConfig } from '../config/index.js';
2
2
  /**
3
3
  * Count top-level fields marked `isBody: true`. Does NOT recurse into objects — `isBody` maps
4
4
  * to the file's markdown content, which exists only at the top level.
@@ -6,6 +6,14 @@ import type { FieldConfig } from '../config/index.js';
6
6
  export declare function countBodyFields(fields: readonly FieldConfig[]): number;
7
7
  /** Name of the `isBody: true` field, or `'body'`; maps the file's markdown onto a data field. */
8
8
  export declare function findBodyFieldName(fields: readonly FieldConfig[]): string;
9
+ /** Name of the `isBody: true` field, or `undefined`: no `'body'` fallback. */
10
+ export declare function findExplicitBodyFieldName(fields: readonly FieldConfig[]): string | undefined;
11
+ /**
12
+ * The data a link-only reference (one without `includeBody: true`) carries for its target. A
13
+ * json/yaml entry's `isBody` field is its prose, so it is left out, as an md/mdx target's body
14
+ * is. Only an explicit `isBody` counts: a field merely named `body` is ordinary data.
15
+ */
16
+ export declare function linkOnlyReferenceData(data: Record<string, unknown>, format: ContentFormat, fields: readonly FieldConfig[] | undefined): Record<string, unknown>;
9
17
  /**
10
18
  * Find an isBody field whose NAME is reserved by reference resolution.
11
19
  *
@@ -1,4 +1,5 @@
1
1
  import { flattenGroupFields } from './flatten-group-fields.js';
2
+ import { isDataOnlyFormat } from './format.js';
2
3
  const BODY_FIELD_TYPES = new Set(['markdown', 'mdx']);
3
4
  /**
4
5
  * Count top-level fields marked `isBody: true`. Does NOT recurse into objects — `isBody` maps
@@ -14,11 +15,27 @@ export function countBodyFields(fields) {
14
15
  }
15
16
  /** Name of the `isBody: true` field, or `'body'`; maps the file's markdown onto a data field. */
16
17
  export function findBodyFieldName(fields) {
18
+ return findExplicitBodyFieldName(fields) ?? 'body';
19
+ }
20
+ /** Name of the `isBody: true` field, or `undefined`: no `'body'` fallback. */
21
+ export function findExplicitBodyFieldName(fields) {
17
22
  for (const field of flattenGroupFields(fields)) {
18
23
  if ('isBody' in field && field.isBody)
19
24
  return field.name;
20
25
  }
21
- return 'body';
26
+ return undefined;
27
+ }
28
+ /**
29
+ * The data a link-only reference (one without `includeBody: true`) carries for its target. A
30
+ * json/yaml entry's `isBody` field is its prose, so it is left out, as an md/mdx target's body
31
+ * is. Only an explicit `isBody` counts: a field merely named `body` is ordinary data.
32
+ */
33
+ export function linkOnlyReferenceData(data, format, fields) {
34
+ const bodyFieldName = isDataOnlyFormat(format) && fields ? findExplicitBodyFieldName(fields) : undefined;
35
+ if (bodyFieldName === undefined || !(bodyFieldName in data))
36
+ return data;
37
+ const { [bodyFieldName]: _body, ...rest } = data;
38
+ return rest;
22
39
  }
23
40
  /**
24
41
  * Find an isBody field whose NAME is reserved by reference resolution.
@@ -49,8 +49,9 @@ function parse(text, format) {
49
49
  * A node's meaning as a string: its tree without source positions. `data` goes too, because
50
50
  * the parser fills it only with derived information (an MDX expression's estree, which carries
51
51
  * its own offsets) and never with content. Line endings inside values are normalised, so a CRLF
52
- * file's blocks match the editor's LF ones. A bare URL and `[url](url)` share a tree, which keeps
53
- * a bare URL on disk when the editor (whose `linkPlugin()` autolinks, in `MarkdownField.tsx`) sends it back as a link.
52
+ * file's blocks match the editor's LF ones. A bare URL and the `[text](url)` the editor writes for
53
+ * it share a tree, since the editor imports it as GFM's link (`markdown-fidelity-visitors.ts`), so
54
+ * a bare URL stays on disk.
54
55
  */
55
56
  function canonical(node) {
56
57
  return JSON.stringify(node, (key, value) => {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.3.2";
1
+ export declare const CANOPYCMS_VERSION = "0.4.0";
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.3.2';
3
+ export const CANOPYCMS_VERSION = '0.4.0';
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.3.2",
4
+ "version": "0.4.0",
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": {
@@ -135,8 +135,10 @@
135
135
  "lru-cache": "^10.4.3",
136
136
  "mdast-util-from-markdown": "^2.0.3",
137
137
  "mdast-util-gfm": "^3.1.0",
138
+ "mdast-util-gfm-autolink-literal": "^2.0.1",
138
139
  "mdast-util-mdx": "^3.0.0",
139
140
  "micromark-extension-gfm": "^3.0.0",
141
+ "micromark-extension-gfm-autolink-literal": "^2.1.0",
140
142
  "micromark-extension-mdxjs": "^3.0.0",
141
143
  "minimatch": "^9.0.7",
142
144
  "minimist": "^1.2.8",