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.
- package/CHANGELOG.md +83 -41
- package/dist/ai/generate.js +11 -4
- package/dist/ai/types.d.ts +3 -2
- package/dist/cli/cli.js +46 -29
- package/dist/cli/generate-ai-content.js +47 -30
- package/dist/config/types.d.ts +8 -8
- package/dist/content-store.d.ts +5 -4
- package/dist/content-store.js +12 -7
- package/dist/editor/fields/gfm-autolink-literal.d.ts +11 -0
- package/dist/editor/fields/gfm-autolink-literal.js +45 -0
- package/dist/editor/fields/markdown-fidelity-visitors.d.ts +2 -3
- package/dist/editor/fields/markdown-fidelity-visitors.js +88 -3
- package/dist/entry-schema.d.ts +16 -6
- package/dist/entry-schema.js +3 -1
- package/dist/utils/body-field.d.ts +9 -1
- package/dist/utils/body-field.js +18 -1
- package/dist/utils/markdown-body-splice.js +3 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -1
|
@@ -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
|
-
|
|
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,
|
package/dist/entry-schema.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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[]
|
|
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
|
|
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
|
|
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. */
|
package/dist/entry-schema.js
CHANGED
|
@@ -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
|
*
|
package/dist/utils/body-field.js
CHANGED
|
@@ -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
|
|
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 `[
|
|
53
|
-
*
|
|
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.
|
|
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
|
+
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.
|
|
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",
|