canopycms 0.0.68-int.105 → 0.0.68-int.107
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/README.md +11 -21
- package/dist/ai/generate.d.ts +5 -0
- package/dist/ai/generate.js +137 -115
- package/dist/ai/index.d.ts +1 -1
- package/dist/ai/json-to-markdown.d.ts +18 -1
- package/dist/ai/json-to-markdown.js +118 -57
- package/dist/ai/references.d.ts +49 -0
- package/dist/ai/references.js +137 -0
- package/dist/ai/types.d.ts +42 -3
- package/dist/api/admin.d.ts +3 -0
- package/dist/api/admin.js +21 -1
- package/dist/api/content.d.ts +5 -5
- package/dist/api/content.js +42 -4
- package/dist/api/entries.d.ts +31 -0
- package/dist/api/entries.js +90 -2
- package/dist/api/resolve-references.js +7 -7
- package/dist/api/schema.d.ts +11 -9
- package/dist/api/schema.js +2 -0
- package/dist/api/types.d.ts +7 -0
- package/dist/branch-schema-cache.d.ts +29 -12
- package/dist/branch-schema-cache.js +64 -21
- package/dist/build/generate-ai-content.js +5 -0
- package/dist/cli/cli.js +729 -370
- package/dist/cli/generate-ai-content.js +738 -369
- package/dist/cli/init.js +1 -0
- package/dist/config/flatten.js +2 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +1 -0
- package/dist/config/types.d.ts +27 -0
- package/dist/content-listing.d.ts +6 -0
- package/dist/content-listing.js +11 -2
- package/dist/content-reader.js +3 -1
- package/dist/content-store.d.ts +36 -9
- package/dist/content-store.js +85 -24
- package/dist/content-tree.js +2 -2
- package/dist/dangling-reference-log.d.ts +17 -0
- package/dist/dangling-reference-log.js +29 -0
- package/dist/editor/Editor.d.ts +5 -1
- package/dist/editor/Editor.js +101 -29
- package/dist/editor/EntryNavigator.d.ts +4 -0
- package/dist/editor/EntryNavigator.js +58 -43
- package/dist/editor/FormRenderer.d.ts +0 -2
- package/dist/editor/FormRenderer.js +1 -9
- package/dist/editor/admin/SystemHealthPanel.js +12 -1
- package/dist/editor/client-reference-resolver.d.ts +43 -16
- package/dist/editor/client-reference-resolver.js +155 -64
- package/dist/editor/components/ConfirmDeleteModal.d.ts +4 -1
- package/dist/editor/components/ConfirmDeleteModal.js +2 -2
- package/dist/editor/components/EntryCreateModal.d.ts +5 -2
- package/dist/editor/components/EntryCreateModal.js +10 -3
- package/dist/editor/components/ReferencedByList.d.ts +11 -0
- package/dist/editor/components/ReferencedByList.js +22 -0
- package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
- package/dist/editor/components/UnavailableEntryNotice.js +9 -0
- package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
- package/dist/editor/components/UnavailableTypeMessage.js +9 -0
- package/dist/editor/editor-config.js +1 -0
- package/dist/editor/editor-utils.js +3 -0
- package/dist/editor/fields/ReferenceField.js +21 -1
- package/dist/editor/hooks/useDraftManager.js +24 -4
- package/dist/editor/hooks/useEntryManager.d.ts +10 -1
- package/dist/editor/hooks/useEntryManager.js +18 -3
- package/dist/editor/hooks/useReferenceResolution.d.ts +10 -3
- package/dist/editor/hooks/useReferenceResolution.js +34 -177
- package/dist/editor/hooks/useSchemaManager.d.ts +13 -1
- package/dist/editor/hooks/useSchemaManager.js +6 -1
- package/dist/editor/preview-bridge.d.ts +16 -2
- package/dist/editor/preview-bridge.js +1 -0
- package/dist/editor/unavailable-entry-type.d.ts +20 -0
- package/dist/editor/unavailable-entry-type.js +28 -0
- package/dist/entry-schema.d.ts +40 -1
- package/dist/entry-schema.js +18 -0
- package/dist/http/handler.js +7 -1
- package/dist/http/worker-not-ready.d.ts +4 -4
- package/dist/http/worker-not-ready.js +14 -4
- package/dist/preview.d.ts +1 -1
- package/dist/reference-resolver.js +1 -0
- package/dist/schema/meta-loader.d.ts +18 -8
- package/dist/schema/meta-loader.js +42 -9
- package/dist/schema/registry-fingerprint.d.ts +14 -0
- package/dist/schema/registry-fingerprint.js +63 -0
- package/dist/schema/resolver.d.ts +6 -3
- package/dist/schema/resolver.js +6 -5
- package/dist/schema/schema-unavailable-error.d.ts +11 -0
- package/dist/schema/schema-unavailable-error.js +15 -0
- package/dist/schema/types.d.ts +22 -0
- package/dist/schema-registry-record.d.ts +35 -0
- package/dist/schema-registry-record.js +134 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +5 -0
- package/dist/static/index.d.ts +40 -2
- package/dist/static/index.js +79 -3
- package/dist/types.d.ts +28 -0
- package/dist/validation/deletion-checker.d.ts +21 -29
- package/dist/validation/deletion-checker.js +41 -117
- package/dist/validation/entry-validator.d.ts +0 -8
- package/dist/validation/entry-validator.js +1 -15
- package/dist/validation/field-traversal.d.ts +20 -0
- package/dist/validation/field-traversal.js +37 -7
- package/dist/validation/reference-validator.d.ts +2 -0
- package/dist/validation/reference-validator.js +3 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +6 -0
- package/dist/worker/cms-worker.js +13 -0
- package/dist/worker/git-sync.d.ts +7 -5
- package/dist/worker/git-sync.js +44 -3
- package/dist/worker/schema-gate.d.ts +49 -0
- package/dist/worker/schema-gate.js +226 -0
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -446,11 +446,12 @@ export function PostView({ data }: { data: PostContent }) {
|
|
|
446
446
|
|
|
447
447
|
**Reference fields and loading states:**
|
|
448
448
|
|
|
449
|
-
|
|
449
|
+
In the preview, a reference field holds what `read()` would give it: the referenced entry's data, or a reference marked `unavailable`: title and URL when this editor may not read it, the id alone when the entry no longer exists. The editor resolves each reference an edit adds, at every position `read()` resolves them: top-level fields, inline groups, objects, lists of objects and block templates, nested to any depth. A reference the editor has not resolved yet is `null`, never its id, with `isLoading` `true` at the same path. So a view can receive three things where a reference sits: the resolved entry, `null`, or an `unavailable` reference. Narrow with `isResolvedReference` from `canopycms`:
|
|
450
450
|
|
|
451
451
|
```tsx
|
|
452
452
|
'use client'
|
|
453
453
|
|
|
454
|
+
import { isResolvedReference } from 'canopycms'
|
|
454
455
|
import { useCanopyPreview } from 'canopycms/preview'
|
|
455
456
|
|
|
456
457
|
export function PostView({ data }: { data: PostContent }) {
|
|
@@ -461,42 +462,31 @@ export function PostView({ data }: { data: PostContent }) {
|
|
|
461
462
|
return (
|
|
462
463
|
<article>
|
|
463
464
|
<h1>{liveData.title}</h1>
|
|
464
|
-
<AuthorCard author={liveData.author} isLoading={isLoading.author} />
|
|
465
|
+
<AuthorCard author={liveData.byline?.author} isLoading={isLoading.byline?.author} />
|
|
465
466
|
</article>
|
|
466
467
|
)
|
|
467
468
|
}
|
|
468
469
|
|
|
469
|
-
// Component receives clean types - no framework coupling
|
|
470
470
|
interface AuthorCardProps {
|
|
471
|
-
author:
|
|
472
|
-
isLoading?: boolean
|
|
471
|
+
author: NonNullable<PostContent['byline']>['author'] // typed by its `resolvedSchema`
|
|
472
|
+
isLoading?: boolean
|
|
473
473
|
}
|
|
474
474
|
|
|
475
475
|
function AuthorCard({ author, isLoading }: AuthorCardProps) {
|
|
476
|
-
if (isLoading)
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
if (!author) {
|
|
480
|
-
return null // Render nothing when no author
|
|
481
|
-
}
|
|
476
|
+
if (isLoading) return <p>Loading author...</p>
|
|
477
|
+
if (!isResolvedReference(author)) return null // empty, or an unavailable reference
|
|
482
478
|
return <p>By {author.name}</p>
|
|
483
479
|
}
|
|
484
480
|
```
|
|
485
481
|
|
|
486
482
|
**Loading state structure:**
|
|
487
483
|
|
|
488
|
-
|
|
484
|
+
`isLoading` (typed `PreviewLoadingState<T>`) holds a boolean only at reference positions, at the same path as the reference:
|
|
489
485
|
|
|
490
486
|
- Single reference field: `isLoading.author` is a `boolean`
|
|
491
|
-
- Array of references: `isLoading.relatedPosts` is
|
|
492
|
-
-
|
|
493
|
-
|
|
494
|
-
**Benefits:**
|
|
495
|
-
|
|
496
|
-
- ✅ Zero framework types in your components - just your content types + optional `isLoading: boolean`
|
|
497
|
-
- ✅ Works for nested reference fields at any depth
|
|
498
|
-
- ✅ Optional - only check loading state if you want to show loading UI
|
|
499
|
-
- ✅ Familiar pattern - mirrors React Query's `{ data, isLoading }` API
|
|
487
|
+
- Array of references: `isLoading.relatedPosts` is a `boolean[]`
|
|
488
|
+
- Inside an object, an object list or a block: `isLoading.byline.author`, `isLoading.credits[0].people`, `isLoading.blocks[2].value.speaker`
|
|
489
|
+
- Anything that is not a reference: absent
|
|
500
490
|
|
|
501
491
|
## Modes (pick per environment)
|
|
502
492
|
|
package/dist/ai/generate.d.ts
CHANGED
|
@@ -42,5 +42,10 @@ export interface GenerateResult {
|
|
|
42
42
|
* Walks the schema tree, reads entries, converts to markdown,
|
|
43
43
|
* and produces per-entry files, per-collection all.md files,
|
|
44
44
|
* bundle files, and a manifest.
|
|
45
|
+
*
|
|
46
|
+
* A reference may only show a target that is itself exported, so every entry is read and
|
|
47
|
+
* filtered first, which settles the exported set. Only then are references to anything outside
|
|
48
|
+
* it masked, entry transforms run, and markdown rendered, in rounds that repeat while a render
|
|
49
|
+
* fails (see the loop below).
|
|
45
50
|
*/
|
|
46
51
|
export declare function generateAIContent(options: GenerateOptions): Promise<GenerateResult>;
|
package/dist/ai/generate.js
CHANGED
|
@@ -12,7 +12,8 @@ import { isDataOnlyFormat } from '../utils/format.js';
|
|
|
12
12
|
import { extractEntryTypeFromFilename, extractIdFromFilename, } from '../content-id-index.js';
|
|
13
13
|
import { hasTraversalSequence } from '../paths/index.js';
|
|
14
14
|
import { getErrorMessage, isNodeError } from '../utils/error.js';
|
|
15
|
-
import { entryToMarkdown } from './json-to-markdown.js';
|
|
15
|
+
import { entryToMarkdown, frontmatterTitle } from './json-to-markdown.js';
|
|
16
|
+
import { createReferenceRendering, createReferenceTargetResolver, maskUnexportedTargets, resolveReferenceFields, } from './references.js';
|
|
16
17
|
import { resolveEntryLinksInText } from '../entry-link-resolver.js';
|
|
17
18
|
/**
|
|
18
19
|
* Generate all AI content from the content store.
|
|
@@ -20,18 +21,26 @@ import { resolveEntryLinksInText } from '../entry-link-resolver.js';
|
|
|
20
21
|
* Walks the schema tree, reads entries, converts to markdown,
|
|
21
22
|
* and produces per-entry files, per-collection all.md files,
|
|
22
23
|
* bundle files, and a manifest.
|
|
24
|
+
*
|
|
25
|
+
* A reference may only show a target that is itself exported, so every entry is read and
|
|
26
|
+
* filtered first, which settles the exported set. Only then are references to anything outside
|
|
27
|
+
* it masked, entry transforms run, and markdown rendered, in rounds that repeat while a render
|
|
28
|
+
* fails (see the loop below).
|
|
23
29
|
*/
|
|
24
30
|
export async function generateAIContent(options) {
|
|
25
31
|
const { store, flatSchema, contentRoot, config, entryLinkUrl, generatedAt, buildId } = options;
|
|
26
32
|
const files = new Map();
|
|
27
33
|
const idIndex = await store.idIndex();
|
|
34
|
+
const read = {
|
|
35
|
+
store,
|
|
36
|
+
contentRoot,
|
|
37
|
+
config,
|
|
38
|
+
idIndex,
|
|
39
|
+
entryLinkUrl,
|
|
40
|
+
resolveTarget: createReferenceTargetResolver(store),
|
|
41
|
+
};
|
|
28
42
|
const collections = flatSchema.filter((item) => item.type === 'collection');
|
|
29
|
-
|
|
30
|
-
const allEntries = [];
|
|
31
|
-
// Track manifest collections (tree structure)
|
|
32
|
-
const manifestCollections = [];
|
|
33
|
-
// Track root-level entries (entries in the content root, not in a subcollection)
|
|
34
|
-
const rootEntries = [];
|
|
43
|
+
const collectionNodes = [];
|
|
35
44
|
for (const collection of collections) {
|
|
36
45
|
// Skip the content root itself — we process its children
|
|
37
46
|
if (collection.logicalPath === contentRoot)
|
|
@@ -42,23 +51,48 @@ export async function generateAIContent(options) {
|
|
|
42
51
|
// (subcollections are handled recursively via their parent)
|
|
43
52
|
if (collection.parentPath && collection.parentPath !== contentRoot)
|
|
44
53
|
continue;
|
|
45
|
-
|
|
46
|
-
allEntries.push(...collectionResult.entries);
|
|
47
|
-
for (const [filePath, content] of collectionResult.files) {
|
|
48
|
-
files.set(filePath, content);
|
|
49
|
-
}
|
|
50
|
-
manifestCollections.push(collectionResult.manifestCollection);
|
|
54
|
+
collectionNodes.push(await collectCollection(read, collection, flatSchema));
|
|
51
55
|
}
|
|
52
|
-
//
|
|
56
|
+
// Root-level entries (entries in content root, not in any subcollection)
|
|
53
57
|
const rootCollection = collections.find((c) => c.logicalPath === contentRoot);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
const rootEntries = rootCollection?.entries ? await collectEntries(read, rootCollection, '') : [];
|
|
59
|
+
// Collection entries first, then root entries: the order bundles list them in
|
|
60
|
+
const allPending = [...collectionNodes.flatMap(subtreeEntries), ...rootEntries];
|
|
61
|
+
// Content id -> output file of every exported entry: the export set, and where each
|
|
62
|
+
// reference's markdown copy lives
|
|
63
|
+
const exported = new Map();
|
|
64
|
+
for (const pending of allPending) {
|
|
65
|
+
if (pending.contentId !== null)
|
|
66
|
+
exported.set(pending.contentId, pending.filePath);
|
|
67
|
+
}
|
|
68
|
+
const references = createReferenceRendering(idIndex, flatSchema, entryLinkUrl, {
|
|
69
|
+
mountPath: config?.mountPath ?? '/ai',
|
|
70
|
+
files: exported,
|
|
71
|
+
});
|
|
72
|
+
// An entry whose render throws is not in the export after all, so references to it are masked
|
|
73
|
+
// like any other left-out target, and the entries that changed run their transforms and render
|
|
74
|
+
// again. That can fail another entry, so this repeats until a round fails none; each round
|
|
75
|
+
// removes at least one entry, so it ends.
|
|
76
|
+
let round = allPending;
|
|
77
|
+
for (;;) {
|
|
78
|
+
for (const pending of round) {
|
|
79
|
+
maskUnexportedTargets(pending.entry, exported);
|
|
80
|
+
// Fold in adopter-supplied markdown (e.g. a colocated sibling artifact), on data as masked
|
|
81
|
+
pending.entry.appendedSections = undefined;
|
|
82
|
+
await runEntryTransform(pending.entry, pending.absolutePath, pending.contentId, config);
|
|
83
|
+
}
|
|
84
|
+
const failed = renderAll(round, config, references);
|
|
85
|
+
if (failed.length === 0)
|
|
86
|
+
break;
|
|
87
|
+
for (const pending of failed) {
|
|
88
|
+
if (pending.contentId !== null)
|
|
89
|
+
exported.delete(pending.contentId);
|
|
59
90
|
}
|
|
60
|
-
|
|
91
|
+
round = allPending.filter((pending) => isRendered(pending) && maskUnexportedTargets(pending.entry, exported));
|
|
61
92
|
}
|
|
93
|
+
const manifestCollections = collectionNodes.map((node) => emitCollection(node, files));
|
|
94
|
+
const manifestRootEntries = emitEntries(rootEntries, files);
|
|
95
|
+
const allEntries = allPending.filter(isRendered);
|
|
62
96
|
const manifestBundles = [];
|
|
63
97
|
if (config?.bundles) {
|
|
64
98
|
for (const bundle of config.bundles) {
|
|
@@ -66,11 +100,9 @@ export async function generateAIContent(options) {
|
|
|
66
100
|
if (/[/\\]|\.\./.test(bundle.name)) {
|
|
67
101
|
throw new Error(`Invalid bundle name "${bundle.name}": must not contain slashes or ".."`);
|
|
68
102
|
}
|
|
69
|
-
const matchingEntries = allEntries.filter((
|
|
103
|
+
const matchingEntries = allEntries.filter((pending) => matchesBundleFilter(pending.entry, bundle.filter, contentRoot));
|
|
70
104
|
if (matchingEntries.length > 0) {
|
|
71
|
-
const bundleContent = matchingEntries
|
|
72
|
-
.map((e) => entryToMarkdown(e, config))
|
|
73
|
-
.join('\n---\n\n');
|
|
105
|
+
const bundleContent = matchingEntries.map((p) => p.markdown).join('\n---\n\n');
|
|
74
106
|
const bundlePath = `bundles/${bundle.name}.md`;
|
|
75
107
|
files.set(bundlePath, bundleContent);
|
|
76
108
|
manifestBundles.push({
|
|
@@ -89,18 +121,52 @@ export async function generateAIContent(options) {
|
|
|
89
121
|
const manifest = {
|
|
90
122
|
...(buildId ? { buildId } : {}),
|
|
91
123
|
...(generatedAt || !buildId ? { generated: generatedAt || new Date().toISOString() } : {}),
|
|
92
|
-
entries:
|
|
124
|
+
entries: manifestRootEntries,
|
|
93
125
|
collections: manifestCollections,
|
|
94
126
|
bundles: manifestBundles,
|
|
95
127
|
};
|
|
96
128
|
files.set('manifest.json', JSON.stringify(manifest, null, 2));
|
|
97
129
|
return { manifest, files };
|
|
98
130
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
131
|
+
/** Render each entry's markdown, returning those whose render threw (left without markdown). */
|
|
132
|
+
function renderAll(entries, config, references) {
|
|
133
|
+
const failed = [];
|
|
134
|
+
for (const pending of entries) {
|
|
135
|
+
try {
|
|
136
|
+
pending.markdown = entryToMarkdown(pending.entry, config, references);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
pending.markdown = undefined;
|
|
140
|
+
failed.push(pending);
|
|
141
|
+
console.warn(`AI content: skipping entry "${pending.entry.slug}" in ${pending.collectionPath}:`, getErrorMessage(err));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return failed;
|
|
145
|
+
}
|
|
146
|
+
function isRendered(pending) {
|
|
147
|
+
return pending.markdown !== undefined;
|
|
148
|
+
}
|
|
149
|
+
/** A collection's direct entries, then each subcollection's, in the order all.md lists them. */
|
|
150
|
+
function subtreeEntries(node) {
|
|
151
|
+
return [...node.entries, ...node.subcollections.flatMap(subtreeEntries)];
|
|
152
|
+
}
|
|
153
|
+
async function collectCollection(read, collection, flatSchema) {
|
|
154
|
+
const cleanPath = stripContentRoot(collection.logicalPath, read.contentRoot);
|
|
155
|
+
const entries = await collectEntries(read, collection, cleanPath);
|
|
156
|
+
const subcollections = [];
|
|
157
|
+
for (const sub of flatSchema) {
|
|
158
|
+
if (sub.type !== 'collection' || sub.parentPath !== collection.logicalPath)
|
|
159
|
+
continue;
|
|
160
|
+
if (isCollectionExcluded(sub.logicalPath, read.contentRoot, read.config))
|
|
161
|
+
continue;
|
|
162
|
+
subcollections.push(await collectCollection(read, sub, flatSchema));
|
|
163
|
+
}
|
|
164
|
+
return { collection, cleanPath, entries, subcollections };
|
|
165
|
+
}
|
|
166
|
+
/** Read, resolve and filter the entries directly in `collection` (not its subcollections). */
|
|
167
|
+
async function collectEntries(read, collection, cleanPath) {
|
|
168
|
+
const { store, contentRoot, config, idIndex, entryLinkUrl, resolveTarget } = read;
|
|
169
|
+
const pending = [];
|
|
104
170
|
const listed = await store.getCollectionEntryPaths(collection.logicalPath);
|
|
105
171
|
// Filter to only entries in this exact collection (not subcollections)
|
|
106
172
|
const directEntries = listed.filter((e) => e.collection === collection.logicalPath);
|
|
@@ -111,109 +177,64 @@ async function processCollection(store, collection, flatSchema, contentRoot, con
|
|
|
111
177
|
if (config?.exclude?.entryTypes?.includes(entryTypeName))
|
|
112
178
|
continue;
|
|
113
179
|
const entryTypeConfig = findEntryType(collection, entryTypeName);
|
|
114
|
-
if (!entryTypeConfig)
|
|
180
|
+
if (!entryTypeConfig || entryTypeConfig.unavailable)
|
|
115
181
|
continue;
|
|
116
182
|
try {
|
|
117
183
|
const doc = await store.read(listEntry.collection, listEntry.slug, {
|
|
118
184
|
resolveReferences: false,
|
|
119
185
|
});
|
|
186
|
+
doc.data = await resolveReferenceFields(doc.data, entryTypeConfig.schema, resolveTarget);
|
|
120
187
|
const aiEntry = docToAIEntry(doc, listEntry.slug, entryTypeName, entryTypeConfig, cleanPath);
|
|
121
|
-
if (aiEntry.body
|
|
188
|
+
if (aiEntry.body) {
|
|
122
189
|
aiEntry.body = resolveEntryLinksInText(aiEntry.body, idIndex, contentRoot, entryLinkUrl);
|
|
123
190
|
}
|
|
124
191
|
if (config?.exclude?.where?.(aiEntry))
|
|
125
192
|
continue;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
manifestEntries.push({
|
|
133
|
-
slug: listEntry.slug,
|
|
134
|
-
title: aiEntry.data.title ? String(aiEntry.data.title) : undefined,
|
|
135
|
-
file: entryFilePath,
|
|
193
|
+
pending.push({
|
|
194
|
+
entry: aiEntry,
|
|
195
|
+
contentId: extractIdFromFilename(path.basename(listEntry.relativePath)),
|
|
196
|
+
absolutePath: doc.absolutePath,
|
|
197
|
+
collectionPath: collection.logicalPath,
|
|
198
|
+
filePath: cleanPath ? `${cleanPath}/${listEntry.slug}.md` : `${listEntry.slug}.md`,
|
|
136
199
|
});
|
|
137
200
|
}
|
|
138
201
|
catch (err) {
|
|
139
202
|
console.warn(`AI content: skipping entry "${listEntry.slug}" in ${collection.logicalPath}:`, getErrorMessage(err));
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const subcollections = flatSchema.filter((item) => item.type === 'collection' && item.parentPath === collection.logicalPath);
|
|
144
|
-
const manifestSubcollections = [];
|
|
145
|
-
for (const sub of subcollections) {
|
|
146
|
-
if (isCollectionExcluded(sub.logicalPath, contentRoot, config))
|
|
147
|
-
continue;
|
|
148
|
-
const subResult = await processCollection(store, sub, flatSchema, contentRoot, config, idIndex, entryLinkUrl);
|
|
149
|
-
entries.push(...subResult.entries);
|
|
150
|
-
for (const [filePath, content] of subResult.files) {
|
|
151
|
-
files.set(filePath, content);
|
|
152
203
|
}
|
|
153
|
-
manifestSubcollections.push(subResult.manifestCollection);
|
|
154
204
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
205
|
+
return pending;
|
|
206
|
+
}
|
|
207
|
+
/** Write each rendered entry's file and return its manifest rows. */
|
|
208
|
+
function emitEntries(entries, files) {
|
|
209
|
+
return entries.filter(isRendered).map((pending) => {
|
|
210
|
+
files.set(pending.filePath, pending.markdown);
|
|
211
|
+
return {
|
|
212
|
+
slug: pending.entry.slug,
|
|
213
|
+
title: frontmatterTitle(pending.entry.data),
|
|
214
|
+
file: pending.filePath,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Write a collection's entry files and all.md, recursively, and return its manifest node. */
|
|
219
|
+
function emitCollection(node, files) {
|
|
220
|
+
const manifestEntries = emitEntries(node.entries, files);
|
|
221
|
+
const manifestSubcollections = node.subcollections.map((sub) => emitCollection(sub, files));
|
|
222
|
+
// all.md covers direct entries + subcollection entries
|
|
223
|
+
const rendered = subtreeEntries(node).filter(isRendered);
|
|
224
|
+
const allPath = `${node.cleanPath}/all.md`;
|
|
225
|
+
if (rendered.length > 0) {
|
|
226
|
+
files.set(allPath, rendered.map((p) => p.markdown).join('\n---\n\n'));
|
|
160
227
|
}
|
|
161
|
-
|
|
162
|
-
name: collection.name,
|
|
163
|
-
label: collection.label,
|
|
164
|
-
description: collection.description,
|
|
165
|
-
path: cleanPath,
|
|
166
|
-
allFile:
|
|
167
|
-
entryCount:
|
|
228
|
+
return {
|
|
229
|
+
name: node.collection.name,
|
|
230
|
+
label: node.collection.label,
|
|
231
|
+
description: node.collection.description,
|
|
232
|
+
path: node.cleanPath,
|
|
233
|
+
allFile: rendered.length > 0 ? allPath : undefined,
|
|
234
|
+
entryCount: rendered.length,
|
|
168
235
|
entries: manifestEntries,
|
|
169
236
|
subcollections: manifestSubcollections.length > 0 ? manifestSubcollections : undefined,
|
|
170
237
|
};
|
|
171
|
-
return { entries, files, manifestCollection };
|
|
172
|
-
}
|
|
173
|
-
async function processRootEntries(store, rootCollection, contentRoot, config, idIndex, entryLinkUrl) {
|
|
174
|
-
const files = new Map();
|
|
175
|
-
const entries = [];
|
|
176
|
-
const manifestEntries = [];
|
|
177
|
-
const listed = await store.getCollectionEntryPaths(rootCollection.logicalPath);
|
|
178
|
-
// Only direct entries in root (not in subcollections)
|
|
179
|
-
const directEntries = listed.filter((e) => e.collection === rootCollection.logicalPath);
|
|
180
|
-
for (const listEntry of directEntries) {
|
|
181
|
-
const entryTypeName = extractEntryTypeFromFilename(path.basename(listEntry.relativePath));
|
|
182
|
-
if (!entryTypeName)
|
|
183
|
-
continue;
|
|
184
|
-
if (config?.exclude?.entryTypes?.includes(entryTypeName))
|
|
185
|
-
continue;
|
|
186
|
-
const entryTypeConfig = findEntryType(rootCollection, entryTypeName);
|
|
187
|
-
if (!entryTypeConfig)
|
|
188
|
-
continue;
|
|
189
|
-
try {
|
|
190
|
-
const doc = await store.read(listEntry.collection, listEntry.slug, {
|
|
191
|
-
resolveReferences: false,
|
|
192
|
-
});
|
|
193
|
-
const aiEntry = docToAIEntry(doc, listEntry.slug, entryTypeName, entryTypeConfig, '');
|
|
194
|
-
if (aiEntry.body && idIndex) {
|
|
195
|
-
aiEntry.body = resolveEntryLinksInText(aiEntry.body, idIndex, contentRoot, entryLinkUrl);
|
|
196
|
-
}
|
|
197
|
-
if (config?.exclude?.where?.(aiEntry))
|
|
198
|
-
continue;
|
|
199
|
-
// Fold in adopter-supplied markdown (e.g. a colocated sibling artifact), once per entry
|
|
200
|
-
await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path.basename(listEntry.relativePath)), config);
|
|
201
|
-
entries.push(aiEntry);
|
|
202
|
-
const entryFilePath = `${listEntry.slug}.md`;
|
|
203
|
-
const entryMarkdown = entryToMarkdown(aiEntry, config);
|
|
204
|
-
files.set(entryFilePath, entryMarkdown);
|
|
205
|
-
manifestEntries.push({
|
|
206
|
-
slug: listEntry.slug,
|
|
207
|
-
title: aiEntry.data.title ? String(aiEntry.data.title) : undefined,
|
|
208
|
-
file: entryFilePath,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
catch (err) {
|
|
212
|
-
console.warn(`AI content: skipping root entry "${listEntry.slug}":`, getErrorMessage(err));
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return { entries, files, manifestEntries };
|
|
217
238
|
}
|
|
218
239
|
function stripContentRoot(logicalPath, contentRoot) {
|
|
219
240
|
if (logicalPath.startsWith(contentRoot + '/')) {
|
|
@@ -282,8 +303,9 @@ function makeReadSibling(dir) {
|
|
|
282
303
|
/**
|
|
283
304
|
* Run the configured entry transform (if any), caching its returned markdown on
|
|
284
305
|
* `entry.appendedSections`. The transform receives the entry's content ID and a directory-bound
|
|
285
|
-
* `readSibling`. Runs
|
|
286
|
-
*
|
|
306
|
+
* `readSibling`. Runs for every entry in the first round, then again in any later round whose
|
|
307
|
+
* masking changed the entry, always on the data as masked; the cached result is reused across the per-entry file, the collection `all.md`, and any bundle that
|
|
308
|
+
* includes this entry. A throwing transform is logged and
|
|
287
309
|
* skipped — the entry still renders without the appended section (distinct from an unreadable
|
|
288
310
|
* entry, which is skipped entirely upstream).
|
|
289
311
|
*/
|
package/dist/ai/index.d.ts
CHANGED
|
@@ -9,6 +9,6 @@ export { createAIContentHandler } from './handler.js';
|
|
|
9
9
|
export { generateAIContent } from './generate.js';
|
|
10
10
|
/** See `to-plain-text.ts`'s module doc for the pipeline and why it differs from the AI-content path above. */
|
|
11
11
|
export { toPlainText } from './to-plain-text.js';
|
|
12
|
-
export type { AIContentConfig, ExcludeConfig, BundleConfig, BundleFilter, FieldTransformFn, FieldTransforms, ComponentProps, ComponentTransformFn, ComponentTransforms, BodyTransformFn, BodyTransforms, EntryTransformFn, EntryTransforms, EntryTransformContext, AIManifest, AIManifestCollection, AIManifestEntry, AIManifestBundle, AIEntry, AIEntryMeta, } from './types.js';
|
|
12
|
+
export type { AIContentConfig, ExcludeConfig, BundleConfig, BundleFilter, FieldTransformFn, FieldTransforms, ComponentProps, ComponentTransformFn, ComponentTransforms, BodyTransformFn, BodyTransforms, EntryTransformFn, EntryTransforms, EntryTransformContext, AIManifest, AIManifestCollection, AIManifestEntry, AIManifestBundle, AIEntry, AIEntryMeta, AIExcludedReference, AIReferenceValue, AIResolvedReference, AIUnavailableReference, } from './types.js';
|
|
13
13
|
export type { AIContentHandlerOptions } from './handler.js';
|
|
14
14
|
export type { GenerateOptions, GenerateResult } from './generate.js';
|
|
@@ -5,10 +5,27 @@
|
|
|
5
5
|
* Handles all CanopyCMS field types including nested objects and blocks.
|
|
6
6
|
*/
|
|
7
7
|
import type { AIEntry, AIContentConfig } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* How a resolved reference becomes link text and a link target. generate.ts supplies one that
|
|
10
|
+
* knows every target's schema and the adopter's `entryLinkUrl`; the default needs neither.
|
|
11
|
+
*/
|
|
12
|
+
export interface ReferenceRendering {
|
|
13
|
+
/** Display title of a resolved target, used unless the field's `displayField` holds text. */
|
|
14
|
+
title(target: Record<string, unknown>): string;
|
|
15
|
+
/** Where the link points, or undefined to render the title as plain text. */
|
|
16
|
+
url(target: Record<string, unknown>): string | undefined;
|
|
17
|
+
/** URL of the target's own file in this export, linked after the page as its markdown copy. */
|
|
18
|
+
markdownUrl?(target: Record<string, unknown>): string | undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The entry's `title` as frontmatter carries it. Only a scalar qualifies: a `reference` field
|
|
22
|
+
* named `title` holds an object and renders as a field instead.
|
|
23
|
+
*/
|
|
24
|
+
export declare function frontmatterTitle(data: Record<string, unknown>): string | undefined;
|
|
8
25
|
/**
|
|
9
26
|
* Convert an entry to clean markdown suitable for AI consumption.
|
|
10
27
|
*
|
|
11
28
|
* For MD/MDX entries: renders frontmatter fields as metadata, appends body verbatim.
|
|
12
29
|
* For JSON entries: full schema-driven conversion of all fields.
|
|
13
30
|
*/
|
|
14
|
-
export declare function entryToMarkdown(entry: AIEntry, config?: AIContentConfig): string;
|
|
31
|
+
export declare function entryToMarkdown(entry: AIEntry, config?: AIContentConfig, references?: ReferenceRendering): string;
|