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.
Files changed (112) hide show
  1. package/README.md +11 -21
  2. package/dist/ai/generate.d.ts +5 -0
  3. package/dist/ai/generate.js +137 -115
  4. package/dist/ai/index.d.ts +1 -1
  5. package/dist/ai/json-to-markdown.d.ts +18 -1
  6. package/dist/ai/json-to-markdown.js +118 -57
  7. package/dist/ai/references.d.ts +49 -0
  8. package/dist/ai/references.js +137 -0
  9. package/dist/ai/types.d.ts +42 -3
  10. package/dist/api/admin.d.ts +3 -0
  11. package/dist/api/admin.js +21 -1
  12. package/dist/api/content.d.ts +5 -5
  13. package/dist/api/content.js +42 -4
  14. package/dist/api/entries.d.ts +31 -0
  15. package/dist/api/entries.js +90 -2
  16. package/dist/api/resolve-references.js +7 -7
  17. package/dist/api/schema.d.ts +11 -9
  18. package/dist/api/schema.js +2 -0
  19. package/dist/api/types.d.ts +7 -0
  20. package/dist/branch-schema-cache.d.ts +29 -12
  21. package/dist/branch-schema-cache.js +64 -21
  22. package/dist/build/generate-ai-content.js +5 -0
  23. package/dist/cli/cli.js +729 -370
  24. package/dist/cli/generate-ai-content.js +738 -369
  25. package/dist/cli/init.js +1 -0
  26. package/dist/config/flatten.js +2 -0
  27. package/dist/config/index.d.ts +1 -1
  28. package/dist/config/schemas/config.d.ts +3 -0
  29. package/dist/config/schemas/config.js +1 -0
  30. package/dist/config/types.d.ts +27 -0
  31. package/dist/content-listing.d.ts +6 -0
  32. package/dist/content-listing.js +11 -2
  33. package/dist/content-reader.js +3 -1
  34. package/dist/content-store.d.ts +36 -9
  35. package/dist/content-store.js +85 -24
  36. package/dist/content-tree.js +2 -2
  37. package/dist/dangling-reference-log.d.ts +17 -0
  38. package/dist/dangling-reference-log.js +29 -0
  39. package/dist/editor/Editor.d.ts +5 -1
  40. package/dist/editor/Editor.js +101 -29
  41. package/dist/editor/EntryNavigator.d.ts +4 -0
  42. package/dist/editor/EntryNavigator.js +58 -43
  43. package/dist/editor/FormRenderer.d.ts +0 -2
  44. package/dist/editor/FormRenderer.js +1 -9
  45. package/dist/editor/admin/SystemHealthPanel.js +12 -1
  46. package/dist/editor/client-reference-resolver.d.ts +43 -16
  47. package/dist/editor/client-reference-resolver.js +155 -64
  48. package/dist/editor/components/ConfirmDeleteModal.d.ts +4 -1
  49. package/dist/editor/components/ConfirmDeleteModal.js +2 -2
  50. package/dist/editor/components/EntryCreateModal.d.ts +5 -2
  51. package/dist/editor/components/EntryCreateModal.js +10 -3
  52. package/dist/editor/components/ReferencedByList.d.ts +11 -0
  53. package/dist/editor/components/ReferencedByList.js +22 -0
  54. package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
  55. package/dist/editor/components/UnavailableEntryNotice.js +9 -0
  56. package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
  57. package/dist/editor/components/UnavailableTypeMessage.js +9 -0
  58. package/dist/editor/editor-config.js +1 -0
  59. package/dist/editor/editor-utils.js +3 -0
  60. package/dist/editor/fields/ReferenceField.js +21 -1
  61. package/dist/editor/hooks/useDraftManager.js +24 -4
  62. package/dist/editor/hooks/useEntryManager.d.ts +10 -1
  63. package/dist/editor/hooks/useEntryManager.js +18 -3
  64. package/dist/editor/hooks/useReferenceResolution.d.ts +10 -3
  65. package/dist/editor/hooks/useReferenceResolution.js +34 -177
  66. package/dist/editor/hooks/useSchemaManager.d.ts +13 -1
  67. package/dist/editor/hooks/useSchemaManager.js +6 -1
  68. package/dist/editor/preview-bridge.d.ts +16 -2
  69. package/dist/editor/preview-bridge.js +1 -0
  70. package/dist/editor/unavailable-entry-type.d.ts +20 -0
  71. package/dist/editor/unavailable-entry-type.js +28 -0
  72. package/dist/entry-schema.d.ts +40 -1
  73. package/dist/entry-schema.js +18 -0
  74. package/dist/http/handler.js +7 -1
  75. package/dist/http/worker-not-ready.d.ts +4 -4
  76. package/dist/http/worker-not-ready.js +14 -4
  77. package/dist/preview.d.ts +1 -1
  78. package/dist/reference-resolver.js +1 -0
  79. package/dist/schema/meta-loader.d.ts +18 -8
  80. package/dist/schema/meta-loader.js +42 -9
  81. package/dist/schema/registry-fingerprint.d.ts +14 -0
  82. package/dist/schema/registry-fingerprint.js +63 -0
  83. package/dist/schema/resolver.d.ts +6 -3
  84. package/dist/schema/resolver.js +6 -5
  85. package/dist/schema/schema-unavailable-error.d.ts +11 -0
  86. package/dist/schema/schema-unavailable-error.js +15 -0
  87. package/dist/schema/types.d.ts +22 -0
  88. package/dist/schema-registry-record.d.ts +35 -0
  89. package/dist/schema-registry-record.js +134 -0
  90. package/dist/server.d.ts +6 -0
  91. package/dist/server.js +5 -0
  92. package/dist/static/index.d.ts +40 -2
  93. package/dist/static/index.js +79 -3
  94. package/dist/types.d.ts +28 -0
  95. package/dist/validation/deletion-checker.d.ts +21 -29
  96. package/dist/validation/deletion-checker.js +41 -117
  97. package/dist/validation/entry-validator.d.ts +0 -8
  98. package/dist/validation/entry-validator.js +1 -15
  99. package/dist/validation/field-traversal.d.ts +20 -0
  100. package/dist/validation/field-traversal.js +37 -7
  101. package/dist/validation/reference-validator.d.ts +2 -0
  102. package/dist/validation/reference-validator.js +3 -0
  103. package/dist/version.d.ts +1 -1
  104. package/dist/version.js +1 -1
  105. package/dist/worker/cms-worker.d.ts +6 -0
  106. package/dist/worker/cms-worker.js +13 -0
  107. package/dist/worker/git-sync.d.ts +7 -5
  108. package/dist/worker/git-sync.js +44 -3
  109. package/dist/worker/schema-gate.d.ts +49 -0
  110. package/dist/worker/schema-gate.js +226 -0
  111. package/dist/worker/worker-context.d.ts +2 -0
  112. 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
- When using reference fields (foreign key relationships to other content), the editor resolves these references asynchronously. Use the `isLoading` object to show loading states for reference fields:
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: AuthorContent | null
472
- isLoading?: boolean // Optional - only needed if you want loading UI
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
- return <p>Loading author...</p>
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
- The `isLoading` object mirrors your data structure:
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 an array of `boolean[]` values
492
- - Non-reference fields: Always `false` (no loading state needed)
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
 
@@ -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>;
@@ -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
- // Track all entries for bundle filtering
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
- const collectionResult = await processCollection(store, collection, flatSchema, contentRoot, config, idIndex, entryLinkUrl);
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
- // Process root-level entries (entries in content root, not in any subcollection)
56
+ // Root-level entries (entries in content root, not in any subcollection)
53
57
  const rootCollection = collections.find((c) => c.logicalPath === contentRoot);
54
- if (rootCollection?.entries) {
55
- const rootResult = await processRootEntries(store, rootCollection, contentRoot, config, idIndex, entryLinkUrl);
56
- allEntries.push(...rootResult.entries);
57
- for (const [filePath, content] of rootResult.files) {
58
- files.set(filePath, content);
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
- rootEntries.push(...rootResult.manifestEntries);
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((entry) => matchesBundleFilter(entry, bundle.filter, contentRoot));
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: rootEntries,
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
- async function processCollection(store, collection, flatSchema, contentRoot, config, idIndex, entryLinkUrl) {
100
- const files = new Map();
101
- const entries = [];
102
- const cleanPath = stripContentRoot(collection.logicalPath, contentRoot);
103
- const manifestEntries = [];
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 && idIndex) {
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
- // Fold in adopter-supplied markdown (e.g. a colocated sibling artifact), once per entry
127
- await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path.basename(listEntry.relativePath)), config);
128
- entries.push(aiEntry);
129
- const entryFilePath = `${cleanPath}/${listEntry.slug}.md`;
130
- const entryMarkdown = entryToMarkdown(aiEntry, config);
131
- files.set(entryFilePath, entryMarkdown);
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
- // Write all.md for this collection (includes direct entries + subcollection entries)
156
- if (entries.length > 0) {
157
- const allContent = entries.map((e) => entryToMarkdown(e, config)).join('\n---\n\n');
158
- const allPath = `${cleanPath}/all.md`;
159
- files.set(allPath, allContent);
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
- const manifestCollection = {
162
- name: collection.name,
163
- label: collection.label,
164
- description: collection.description,
165
- path: cleanPath,
166
- allFile: entries.length > 0 ? `${cleanPath}/all.md` : undefined,
167
- entryCount: entries.length,
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 once per entry; the cached result is reused across the per-entry file, the
286
- * collection `all.md`, and any bundle that includes this entry. A throwing transform is logged and
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
  */
@@ -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;