canopycms 0.3.3 → 0.5.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 +91 -41
- package/dist/ai/generate.d.ts +3 -0
- package/dist/ai/generate.js +23 -33
- package/dist/ai/handler.js +1 -0
- package/dist/ai/types.d.ts +6 -11
- package/dist/api/reference-options.d.ts +2 -5
- package/dist/api/reference-options.js +21 -2
- package/dist/api/schema.d.ts +4 -4
- package/dist/build/generate-ai-content.js +1 -0
- package/dist/cli/cli.js +176 -66
- package/dist/cli/generate-ai-content.js +122 -62
- package/dist/cli/init.js +29 -4
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/config.d.ts +74 -0
- package/dist/config/schemas/config.js +21 -0
- package/dist/config/schemas/field.d.ts +30 -0
- package/dist/config/schemas/field.js +22 -0
- package/dist/config/types.d.ts +47 -8
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +33 -0
- package/dist/content-store.d.ts +5 -4
- package/dist/content-store.js +12 -7
- package/dist/editor/FormRenderer.js +15 -13
- package/dist/editor/copy.d.ts +5 -0
- package/dist/editor/copy.js +5 -0
- package/dist/editor/fields/BlockField.d.ts +2 -0
- package/dist/editor/fields/BlockField.js +15 -8
- package/dist/editor/fields/ItemList.d.ts +21 -3
- package/dist/editor/fields/ItemList.js +16 -8
- package/dist/editor/fields/NumberListField.d.ts +2 -0
- package/dist/editor/fields/NumberListField.js +6 -2
- package/dist/editor/fields/ObjectListField.js +9 -3
- package/dist/editor/fields/ReferenceField.d.ts +6 -7
- package/dist/editor/fields/ReferenceField.js +28 -122
- package/dist/editor/fields/ReferenceListField.d.ts +41 -0
- package/dist/editor/fields/ReferenceListField.js +139 -0
- package/dist/editor/fields/SelectField.d.ts +2 -0
- package/dist/editor/fields/SelectField.js +6 -3
- package/dist/editor/fields/StringListField.d.ts +10 -1
- package/dist/editor/fields/StringListField.js +18 -8
- package/dist/editor/fields/item-expansion.d.ts +7 -3
- package/dist/editor/fields/item-expansion.js +6 -4
- package/dist/editor/fields/list-item-keys.d.ts +6 -5
- package/dist/editor/fields/list-item-keys.js +45 -13
- package/dist/editor/fields/reference-noindex.d.ts +37 -0
- package/dist/editor/fields/reference-noindex.js +48 -0
- package/dist/editor/fields/use-reference-options.d.ts +36 -0
- package/dist/editor/fields/use-reference-options.js +121 -0
- package/dist/editor/form-outline.d.ts +2 -2
- package/dist/editor/form-outline.js +4 -2
- package/dist/editor/theme.js +5 -0
- package/dist/entry-schema-registry.d.ts +1 -1
- package/dist/entry-schema-registry.js +3 -2
- package/dist/entry-schema.d.ts +21 -9
- package/dist/entry-schema.js +5 -2
- package/dist/reference-resolver.d.ts +18 -1
- package/dist/reference-resolver.js +175 -8
- package/dist/schema/schema-store.d.ts +2 -2
- package/dist/static/index.d.ts +8 -4
- package/dist/static/index.js +38 -13
- package/dist/static/seo.d.ts +7 -5
- package/dist/static/seo.js +13 -6
- package/dist/utils/body-field.d.ts +9 -1
- package/dist/utils/body-field.js +18 -1
- package/dist/validation/entry-validator.d.ts +5 -0
- package/dist/validation/entry-validator.js +12 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,10 +1,26 @@
|
|
|
1
|
+
import type { ReferenceOptionSort } from './config/index.js';
|
|
1
2
|
import type { ContentStore } from './content-store.js';
|
|
2
3
|
import type { ContentIdIndex } from './content-id-index.js';
|
|
3
4
|
import { type LogicalPath } from './paths/index.js';
|
|
5
|
+
import { type SeoFieldLocation } from './static/seo.js';
|
|
4
6
|
export interface ReferenceOption {
|
|
5
7
|
id: string;
|
|
6
8
|
label: string;
|
|
7
9
|
collection: string;
|
|
10
|
+
/**
|
|
11
|
+
* Present when the target is `noindex` at `config.seo`'s location, so a page that lists only
|
|
12
|
+
* indexed entries won't show it. `fieldLabel` is the editor label of the target's own noindex
|
|
13
|
+
* field, so the picker can name the setting in the words the editor sees on that entry.
|
|
14
|
+
*/
|
|
15
|
+
noindex?: {
|
|
16
|
+
fieldLabel: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface LoadReferenceOptionsSettings {
|
|
20
|
+
/** Where entries keep their SEO fields: `CanopyConfig.seo`. */
|
|
21
|
+
seo?: SeoFieldLocation;
|
|
22
|
+
/** Default `'label'`. */
|
|
23
|
+
sort?: ReferenceOptionSort;
|
|
8
24
|
}
|
|
9
25
|
/**
|
|
10
26
|
* Loads the options a reference field's picker offers. Resolving a stored reference to its
|
|
@@ -24,6 +40,7 @@ export declare class ReferenceResolver {
|
|
|
24
40
|
* logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
|
|
25
41
|
* no file I/O, no label, no option -- so a caller who can't read a path never
|
|
26
42
|
* triggers a read for content they won't be allowed to see anyway.
|
|
43
|
+
* @param settings - The SEO location the `noindex` marker reads, and the option order.
|
|
27
44
|
*/
|
|
28
|
-
loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (logicalPath: LogicalPath) => boolean): Promise<ReferenceOption[]>;
|
|
45
|
+
loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (logicalPath: LogicalPath) => boolean, settings?: LoadReferenceOptionsSettings): Promise<ReferenceOption[]>;
|
|
29
46
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import nodePath from 'node:path';
|
|
2
2
|
import { extractSlugFromFilename, extractEntryTypeFromFilename } from './content-id-index.js';
|
|
3
|
-
import {
|
|
3
|
+
import { sortByOrder } from './content-listing.js';
|
|
4
|
+
import { entryLogicalPath, } from './paths/index.js';
|
|
5
|
+
import { DEFAULT_SEO_FIELD_NAMES, isNoindexEntry } from './static/seo.js';
|
|
4
6
|
/**
|
|
5
7
|
* Loads the options a reference field's picker offers. Resolving a stored reference to its
|
|
6
8
|
* target is `ContentStore`'s job (`read()`, `resolveReferenceTarget`).
|
|
@@ -20,9 +22,13 @@ export class ReferenceResolver {
|
|
|
20
22
|
* logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
|
|
21
23
|
* no file I/O, no label, no option -- so a caller who can't read a path never
|
|
22
24
|
* triggers a read for content they won't be allowed to see anyway.
|
|
25
|
+
* @param settings - The SEO location the `noindex` marker reads, and the option order.
|
|
23
26
|
*/
|
|
24
|
-
async loadReferenceOptions(collections, displayField = 'title', search, entryTypes, canAccess) {
|
|
25
|
-
const
|
|
27
|
+
async loadReferenceOptions(collections, displayField = 'title', search, entryTypes, canAccess, settings = {}) {
|
|
28
|
+
const loaded = [];
|
|
29
|
+
const sort = settings.sort ?? 'label';
|
|
30
|
+
const sortField = typeof sort === 'object' ? sort.field : undefined;
|
|
31
|
+
const noindexLabels = new NoindexFieldLabels(this.store.getSchemaItems(), settings.seo);
|
|
26
32
|
let candidates;
|
|
27
33
|
if (collections && collections.length > 0) {
|
|
28
34
|
const results = await Promise.all(collections.map((col) => this.store.getCollectionEntryPaths(col)));
|
|
@@ -60,10 +66,15 @@ export class ReferenceResolver {
|
|
|
60
66
|
if (search && !label.toLowerCase().includes(search.toLowerCase())) {
|
|
61
67
|
continue;
|
|
62
68
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
const option = { id, label, collection: location.collection };
|
|
70
|
+
if (isNoindexEntry(doc.data, settings.seo)) {
|
|
71
|
+
const entryType = extractEntryTypeFromFilename(filename) ?? '';
|
|
72
|
+
option.noindex = { fieldLabel: noindexLabels.get(location.collection, entryType) };
|
|
73
|
+
}
|
|
74
|
+
loaded.push({
|
|
75
|
+
option,
|
|
76
|
+
contentId: id,
|
|
77
|
+
sortValue: sortField === undefined ? undefined : sortValueAt(doc.data, sortField),
|
|
67
78
|
});
|
|
68
79
|
}
|
|
69
80
|
catch (error) {
|
|
@@ -75,6 +86,162 @@ export class ReferenceResolver {
|
|
|
75
86
|
continue;
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
|
-
|
|
89
|
+
if (sort === 'collection')
|
|
90
|
+
return sortByCollection(loaded, this.store.getSchemaItems());
|
|
91
|
+
if (typeof sort === 'object')
|
|
92
|
+
return sortByField(loaded, sort.direction ?? 'asc');
|
|
93
|
+
return loaded.map((l) => l.option).sort(byLabel);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const byLabel = (a, b) => a.label.localeCompare(b.label);
|
|
97
|
+
/**
|
|
98
|
+
* The value at a dotted path, read through own properties only so a path like `__proto__`
|
|
99
|
+
* reads nothing. A non-blank string, a finite number or a valid date counts as a value; a date
|
|
100
|
+
* becomes its ISO string, since md/mdx frontmatter parses an unquoted `YYYY-MM-DD` as a `Date`.
|
|
101
|
+
*/
|
|
102
|
+
function sortValueAt(data, path) {
|
|
103
|
+
let current = data;
|
|
104
|
+
for (const segment of path.split('.')) {
|
|
105
|
+
if (typeof current !== 'object' || current === null || Array.isArray(current))
|
|
106
|
+
return undefined;
|
|
107
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment))
|
|
108
|
+
return undefined;
|
|
109
|
+
current = current[segment];
|
|
110
|
+
}
|
|
111
|
+
if (typeof current === 'number')
|
|
112
|
+
return Number.isFinite(current) ? current : undefined;
|
|
113
|
+
if (typeof current === 'string')
|
|
114
|
+
return current.trim() || undefined;
|
|
115
|
+
if (current instanceof Date) {
|
|
116
|
+
return Number.isNaN(current.getTime()) ? undefined : current.toISOString();
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Numbers before strings, so a field typed differently across entry types still sorts in one
|
|
122
|
+
* consistent order; numbers compare numerically, strings as plain text, which orders ISO dates.
|
|
123
|
+
*/
|
|
124
|
+
function compareValues(a, b) {
|
|
125
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
126
|
+
return a - b;
|
|
127
|
+
if (typeof a === 'number')
|
|
128
|
+
return -1;
|
|
129
|
+
if (typeof b === 'number')
|
|
130
|
+
return 1;
|
|
131
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Entries with no value go first under `desc` and last under `asc`: newest-first then puts an
|
|
135
|
+
* entry whose date is not yet stamped at the top, where an editor queueing it looks.
|
|
136
|
+
*/
|
|
137
|
+
function sortByField(loaded, direction) {
|
|
138
|
+
const sign = direction === 'desc' ? -1 : 1;
|
|
139
|
+
return loaded
|
|
140
|
+
.sort((a, b) => {
|
|
141
|
+
if (a.sortValue === undefined || b.sortValue === undefined) {
|
|
142
|
+
if (a.sortValue !== b.sortValue) {
|
|
143
|
+
const aMissingFirst = direction === 'desc';
|
|
144
|
+
return (a.sortValue === undefined) === aMissingFirst ? -1 : 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const byValue = compareValues(a.sortValue, b.sortValue) * sign;
|
|
149
|
+
if (byValue !== 0)
|
|
150
|
+
return byValue;
|
|
151
|
+
}
|
|
152
|
+
return byLabel(a.option, b.option);
|
|
153
|
+
})
|
|
154
|
+
.map((l) => l.option);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* One group per collection in content-tree order: a collection, then each subcollection in its
|
|
158
|
+
* parent's `order`. Within a group, options follow the collection's own `order`, then label.
|
|
159
|
+
*/
|
|
160
|
+
function sortByCollection(loaded, schemaItems) {
|
|
161
|
+
const collections = [...schemaItems].filter((item) => item.type === 'collection');
|
|
162
|
+
const byPath = new Map(collections.map((c) => [c.logicalPath, c]));
|
|
163
|
+
const children = new Map();
|
|
164
|
+
const roots = [];
|
|
165
|
+
for (const collection of collections) {
|
|
166
|
+
const parent = collection.parentPath;
|
|
167
|
+
if (parent === undefined || !byPath.has(parent)) {
|
|
168
|
+
roots.push(collection);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
children.set(parent, [...(children.get(parent) ?? []), collection]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const treeOrder = [];
|
|
175
|
+
const visit = (siblings, order) => {
|
|
176
|
+
for (const collection of sortByOrder([...siblings], order, (c) => c.name)) {
|
|
177
|
+
treeOrder.push(collection);
|
|
178
|
+
visit(children.get(collection.logicalPath) ?? [], collection.order);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
visit(roots, undefined);
|
|
182
|
+
const groups = new Map();
|
|
183
|
+
for (const item of loaded) {
|
|
184
|
+
const path = item.option.collection;
|
|
185
|
+
groups.set(path, [...(groups.get(path) ?? []), item]);
|
|
186
|
+
}
|
|
187
|
+
const result = [];
|
|
188
|
+
const appendGroup = (path, order) => {
|
|
189
|
+
const group = groups.get(path);
|
|
190
|
+
if (!group)
|
|
191
|
+
return;
|
|
192
|
+
groups.delete(path);
|
|
193
|
+
result.push(...sortByOrder(group, order, (l) => l.option.label).map((l) => l.option));
|
|
194
|
+
};
|
|
195
|
+
for (const collection of treeOrder)
|
|
196
|
+
appendGroup(collection.logicalPath, collection.order);
|
|
197
|
+
// A collection missing from the schema still gets a group, after the known ones.
|
|
198
|
+
for (const path of [...groups.keys()].sort())
|
|
199
|
+
appendGroup(path, undefined);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The editor label of each entry type's noindex field, found where `seo` says the flag lives:
|
|
204
|
+
* a top-level field (inline groups are transparent), or a field inside the object named by
|
|
205
|
+
* `seo.group`. Falls back to the field's name when the schema has no such field.
|
|
206
|
+
*/
|
|
207
|
+
class NoindexFieldLabels {
|
|
208
|
+
constructor(schemaItems, seo) {
|
|
209
|
+
this.seo = seo;
|
|
210
|
+
this.cache = new Map();
|
|
211
|
+
this.entryTypes = [...schemaItems].filter((item) => item.type === 'entry-type');
|
|
212
|
+
this.fieldName = seo?.fields?.noindex || DEFAULT_SEO_FIELD_NAMES.noindex;
|
|
213
|
+
}
|
|
214
|
+
get(collection, entryType) {
|
|
215
|
+
const key = `${collection}\0${entryType}`;
|
|
216
|
+
const cached = this.cache.get(key);
|
|
217
|
+
if (cached !== undefined)
|
|
218
|
+
return cached;
|
|
219
|
+
const schema = this.entryTypes.find((item) => item.parentPath === collection && item.name === entryType)?.schema;
|
|
220
|
+
const label = (schema && this.findLabel(schema)) ?? this.fieldName;
|
|
221
|
+
this.cache.set(key, label);
|
|
222
|
+
return label;
|
|
223
|
+
}
|
|
224
|
+
findLabel(schema) {
|
|
225
|
+
let fields = schema;
|
|
226
|
+
if (this.seo?.group) {
|
|
227
|
+
const group = findField(schema, this.seo.group);
|
|
228
|
+
fields = group?.type === 'object' ? group.fields : undefined;
|
|
229
|
+
}
|
|
230
|
+
const field = fields && findField(fields, this.fieldName);
|
|
231
|
+
return field ? (field.label ?? field.name) : undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** A field by data name, looking through inline groups, which store no key of their own. */
|
|
235
|
+
function findField(fields, name) {
|
|
236
|
+
for (const field of fields) {
|
|
237
|
+
if (field.type === 'group') {
|
|
238
|
+
const inner = findField(field.fields, name);
|
|
239
|
+
if (inner)
|
|
240
|
+
return inner;
|
|
241
|
+
}
|
|
242
|
+
else if (field.name === name) {
|
|
243
|
+
return field;
|
|
244
|
+
}
|
|
79
245
|
}
|
|
246
|
+
return undefined;
|
|
80
247
|
}
|
|
@@ -164,14 +164,14 @@ declare const updateEntryTypeInputSchema: z.ZodObject<{
|
|
|
164
164
|
default?: boolean | undefined;
|
|
165
165
|
format?: "mdx" | "md" | "json" | "yaml" | undefined;
|
|
166
166
|
label?: string | undefined;
|
|
167
|
-
schema?: string | undefined;
|
|
168
167
|
maxItems?: number | undefined;
|
|
168
|
+
schema?: string | undefined;
|
|
169
169
|
}, {
|
|
170
170
|
default?: boolean | undefined;
|
|
171
171
|
format?: "mdx" | "md" | "json" | "yaml" | undefined;
|
|
172
172
|
label?: string | undefined;
|
|
173
|
-
schema?: string | undefined;
|
|
174
173
|
maxItems?: number | undefined;
|
|
174
|
+
schema?: string | undefined;
|
|
175
175
|
}>;
|
|
176
176
|
/**
|
|
177
177
|
* Thrown when a schema mutation cannot proceed because the per-branch
|
package/dist/static/index.d.ts
CHANGED
|
@@ -101,11 +101,13 @@ type StaticBuildContext = Pick<CanopyBuildContext, 'listEntries'> & Partial<Pick
|
|
|
101
101
|
* when you actually mean to — omission should be a decision, not an oversight.
|
|
102
102
|
*
|
|
103
103
|
* `noindex` is NOT applied here: an entry that must not be advertised must still be built. Apply
|
|
104
|
-
* `isNoindexEntry` at the advertising surface (as `generateContentSitemap` does)
|
|
104
|
+
* `isNoindexEntry` at the advertising surface (as `generateContentSitemap` does), at the SEO
|
|
105
|
+
* location in `CanopyConfig.seo`.
|
|
105
106
|
*
|
|
106
107
|
* @example
|
|
107
|
-
* const
|
|
108
|
-
* const
|
|
108
|
+
* const ctx = await getCanopyForBuild()
|
|
109
|
+
* const entries = await collectRoutableEntries(ctx)
|
|
110
|
+
* const published = entries.filter((e) => !isNoindexEntry(e.data, ctx.services.config.seo))
|
|
109
111
|
*/
|
|
110
112
|
export declare function collectRoutableEntries<T = Record<string, unknown>>(ctx: StaticBuildContext, options?: CollectRoutableEntriesOptions<T>): Promise<RoutableEntry<T>[]>;
|
|
111
113
|
/**
|
|
@@ -206,7 +208,9 @@ export declare function warnUnsafeMarkdown(items: readonly BuildScanItem[], phas
|
|
|
206
208
|
*
|
|
207
209
|
* Fails the build rather than silently skipping the offending entry: a page that silently
|
|
208
210
|
* disappears from a static build is a worse failure mode than a red build. Lists every
|
|
209
|
-
* offending entry so one build catches every abandoned scaffold, not just the first.
|
|
211
|
+
* offending entry so one build catches every abandoned scaffold, not just the first. An
|
|
212
|
+
* over-`maxItems` list gets its own line per list, with the fix; the abandoned-scaffold advice
|
|
213
|
+
* appears only when some entry has another kind of error.
|
|
210
214
|
*/
|
|
211
215
|
export declare function assertBuildEntriesValid(items: readonly BuildScanItem[], phaseLabel: string): void;
|
|
212
216
|
/**
|
package/dist/static/index.js
CHANGED
|
@@ -68,11 +68,13 @@ async function enumerateRoutableEntries(ctx, rootPath, phaseLabel, resolveRefere
|
|
|
68
68
|
* when you actually mean to — omission should be a decision, not an oversight.
|
|
69
69
|
*
|
|
70
70
|
* `noindex` is NOT applied here: an entry that must not be advertised must still be built. Apply
|
|
71
|
-
* `isNoindexEntry` at the advertising surface (as `generateContentSitemap` does)
|
|
71
|
+
* `isNoindexEntry` at the advertising surface (as `generateContentSitemap` does), at the SEO
|
|
72
|
+
* location in `CanopyConfig.seo`.
|
|
72
73
|
*
|
|
73
74
|
* @example
|
|
74
|
-
* const
|
|
75
|
-
* const
|
|
75
|
+
* const ctx = await getCanopyForBuild()
|
|
76
|
+
* const entries = await collectRoutableEntries(ctx)
|
|
77
|
+
* const published = entries.filter((e) => !isNoindexEntry(e.data, ctx.services.config.seo))
|
|
76
78
|
*/
|
|
77
79
|
export async function collectRoutableEntries(ctx, options = {}) {
|
|
78
80
|
const entries = await enumerateRoutableEntries(ctx, options.rootPath, 'routable entry enumeration', options.resolveReferences);
|
|
@@ -283,22 +285,45 @@ export function warnUnsafeMarkdown(items, phaseLabel, siteAllow) {
|
|
|
283
285
|
*
|
|
284
286
|
* Fails the build rather than silently skipping the offending entry: a page that silently
|
|
285
287
|
* disappears from a static build is a worse failure mode than a red build. Lists every
|
|
286
|
-
* offending entry so one build catches every abandoned scaffold, not just the first.
|
|
288
|
+
* offending entry so one build catches every abandoned scaffold, not just the first. An
|
|
289
|
+
* over-`maxItems` list gets its own line per list, with the fix; the abandoned-scaffold advice
|
|
290
|
+
* appears only when some entry has another kind of error.
|
|
287
291
|
*/
|
|
288
292
|
export function assertBuildEntriesValid(items, phaseLabel) {
|
|
289
293
|
const invalid = findInvalidEntries(items);
|
|
290
294
|
if (invalid.length === 0)
|
|
291
295
|
return;
|
|
292
|
-
const lines =
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
296
|
+
const lines = [];
|
|
297
|
+
let hasOverCap = false;
|
|
298
|
+
let hasOrdinary = false;
|
|
299
|
+
for (const { entryPath, errors } of invalid) {
|
|
300
|
+
const ordinary = errors.filter((e) => e.maxItems === undefined);
|
|
301
|
+
if (ordinary.length > 0) {
|
|
302
|
+
hasOrdinary = true;
|
|
303
|
+
const [first, ...rest] = ordinary;
|
|
304
|
+
const summary = rest.length > 0
|
|
305
|
+
? `${first.fieldPath}: ${first.message} (+${rest.length} more)`
|
|
306
|
+
: `${first.fieldPath}: ${first.message}`;
|
|
307
|
+
lines.push(` - ${entryPath} — ${summary}`);
|
|
308
|
+
}
|
|
309
|
+
for (const { fieldPath, maxItems } of errors) {
|
|
310
|
+
if (maxItems === undefined)
|
|
311
|
+
continue;
|
|
312
|
+
hasOverCap = true;
|
|
313
|
+
lines.push(` - ${entryPath} — ${fieldPath}: ${maxItems.count} items, but maxItems is ${maxItems.cap}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const advice = [];
|
|
317
|
+
if (hasOverCap) {
|
|
318
|
+
advice.push(`A list over its maxItems: open the entry in the editor and remove a pick from that list (or remove items) until it holds at most maxItems, then rebuild. ` +
|
|
319
|
+
`This usually happens when two branches each add one item to the same list and are merged one after the other.`);
|
|
320
|
+
}
|
|
321
|
+
if (hasOrdinary) {
|
|
322
|
+
advice.push(`${hasOverCap ? 'The other errors are' : 'These are'} likely abandoned create-scaffolds (empty entries left behind when a create was started but never finished). ` +
|
|
323
|
+
`Finish editing the entry (fill in its required fields) or delete the abandoned draft, then rebuild.`);
|
|
324
|
+
}
|
|
299
325
|
throw new Error(`CanopyCMS static build: found ${invalid.length} schema-invalid ${invalid.length === 1 ? 'entry' : 'entries'} during ${phaseLabel}:\n${lines.join('\n')}\n` +
|
|
300
|
-
|
|
301
|
-
`Finish editing the entry (fill in its required fields) or delete the abandoned draft, then rebuild.`);
|
|
326
|
+
advice.join('\n'));
|
|
302
327
|
}
|
|
303
328
|
/**
|
|
304
329
|
* Find every `urlPath` claimed by two or more entries.
|
package/dist/static/seo.d.ts
CHANGED
|
@@ -72,14 +72,16 @@ export type SeoFieldLocation = Pick<ExtractSeoFieldsOptions, 'fields' | 'group'>
|
|
|
72
72
|
*/
|
|
73
73
|
export declare function extractSeoFields(entryData: unknown, opts?: ExtractSeoFieldsOptions): SeoFields;
|
|
74
74
|
/**
|
|
75
|
-
* THE `noindex` predicate. Every surface that
|
|
76
|
-
*
|
|
75
|
+
* THE `noindex` predicate. Every surface that reads the flag routes through this one function,
|
|
76
|
+
* with `opts` taken from `CanopyConfig.seo` (canopycms-next's free helpers take it as an
|
|
77
|
+
* argument instead), rather than re-deriving it:
|
|
77
78
|
*
|
|
78
79
|
* - the page's own `robots: { index: false }` (canopycms-next's `entryToMetadata`),
|
|
79
|
-
* - exclusion from the sitemap (canopycms-next's `generateContentSitemap`),
|
|
80
|
-
* - exclusion from AI content (`ai/generate.ts`, unless `exclude.noindex: false`)
|
|
80
|
+
* - exclusion from the sitemap (canopycms-next's `generateContentSitemap`),
|
|
81
|
+
* - exclusion from AI content (`ai/generate.ts`, unless `exclude.noindex: false`), and
|
|
82
|
+
* - the marker on a reference picker's option (`ReferenceResolver.loadReferenceOptions`).
|
|
81
83
|
*
|
|
82
|
-
*
|
|
84
|
+
* One predicate at one configured location is the point. Derived separately, a `noindex` entry
|
|
83
85
|
* stays advertised in one surface while correctly absent from another, and nothing warns.
|
|
84
86
|
*
|
|
85
87
|
* NOT applied to enumeration: `noindex` means "don't advertise this", not "don't build this".
|
package/dist/static/seo.js
CHANGED
|
@@ -75,7 +75,12 @@ const asTwitterCard = (value) => {
|
|
|
75
75
|
* rather than throwing.
|
|
76
76
|
*/
|
|
77
77
|
export function extractSeoFields(entryData, opts = {}) {
|
|
78
|
-
const names = { ...DEFAULT_SEO_FIELD_NAMES
|
|
78
|
+
const names = { ...DEFAULT_SEO_FIELD_NAMES };
|
|
79
|
+
// Only a real name overrides: `{ noindex: undefined }` (an unset env var, say) keeps the default.
|
|
80
|
+
for (const [key, name] of Object.entries(opts.fields ?? {})) {
|
|
81
|
+
if (typeof name === 'string' && name !== '')
|
|
82
|
+
names[key] = name;
|
|
83
|
+
}
|
|
79
84
|
const entry = asRecord(entryData);
|
|
80
85
|
const source = opts.group ? asRecord(entry?.[opts.group]) : entry;
|
|
81
86
|
return {
|
|
@@ -89,14 +94,16 @@ export function extractSeoFields(entryData, opts = {}) {
|
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
/**
|
|
92
|
-
* THE `noindex` predicate. Every surface that
|
|
93
|
-
*
|
|
97
|
+
* THE `noindex` predicate. Every surface that reads the flag routes through this one function,
|
|
98
|
+
* with `opts` taken from `CanopyConfig.seo` (canopycms-next's free helpers take it as an
|
|
99
|
+
* argument instead), rather than re-deriving it:
|
|
94
100
|
*
|
|
95
101
|
* - the page's own `robots: { index: false }` (canopycms-next's `entryToMetadata`),
|
|
96
|
-
* - exclusion from the sitemap (canopycms-next's `generateContentSitemap`),
|
|
97
|
-
* - exclusion from AI content (`ai/generate.ts`, unless `exclude.noindex: false`)
|
|
102
|
+
* - exclusion from the sitemap (canopycms-next's `generateContentSitemap`),
|
|
103
|
+
* - exclusion from AI content (`ai/generate.ts`, unless `exclude.noindex: false`), and
|
|
104
|
+
* - the marker on a reference picker's option (`ReferenceResolver.loadReferenceOptions`).
|
|
98
105
|
*
|
|
99
|
-
*
|
|
106
|
+
* One predicate at one configured location is the point. Derived separately, a `noindex` entry
|
|
100
107
|
* stays advertised in one surface while correctly absent from another, and nothing warns.
|
|
101
108
|
*
|
|
102
109
|
* NOT applied to enumeration: `noindex` means "don't advertise this", not "don't build this".
|
|
@@ -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.
|
|
@@ -20,6 +20,11 @@ import type { ContentFormat, EntrySchema } from '../config/index.js';
|
|
|
20
20
|
export interface EntryFieldError {
|
|
21
21
|
fieldPath: string;
|
|
22
22
|
message: string;
|
|
23
|
+
/** Set on an over-cap list's error, so a caller can say how to fix it. */
|
|
24
|
+
maxItems?: {
|
|
25
|
+
count: number;
|
|
26
|
+
cap: number;
|
|
27
|
+
};
|
|
23
28
|
}
|
|
24
29
|
/**
|
|
25
30
|
* Merge an md/mdx body string into the data record under the schema's body
|
|
@@ -173,6 +173,18 @@ export function validateEntryData(fields, data, pathPrefix = '', options = {}) {
|
|
|
173
173
|
}
|
|
174
174
|
if (value === undefined || value === null)
|
|
175
175
|
continue;
|
|
176
|
+
// No `continue`: the items inside an over-cap list are still checked and reported.
|
|
177
|
+
if (Array.isArray(value) &&
|
|
178
|
+
'maxItems' in field &&
|
|
179
|
+
typeof field.maxItems === 'number' &&
|
|
180
|
+
value.length > field.maxItems) {
|
|
181
|
+
const cap = field.maxItems;
|
|
182
|
+
errors.push({
|
|
183
|
+
fieldPath: path,
|
|
184
|
+
message: `At most ${cap} ${cap === 1 ? 'item' : 'items'}; this list has ${value.length}`,
|
|
185
|
+
maxItems: { count: value.length, cap },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
176
188
|
if (field.type === 'block') {
|
|
177
189
|
const blockField = field;
|
|
178
190
|
if (!Array.isArray(value)) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CANOPYCMS_VERSION = "0.
|
|
1
|
+
export declare const CANOPYCMS_VERSION = "0.5.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.5.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.5.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": {
|