@nuasite/cms 0.46.4 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { createCmsCore, createNodeFs } from '@nuasite/cms-core'
1
+ import { createCmsCore, createNodeFs, resolvePathnameFromSpec } from '@nuasite/cms-core'
2
2
  import fs from 'node:fs/promises'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import path from 'node:path'
@@ -193,23 +193,33 @@ export function createDevMiddleware(
193
193
 
194
194
  for (const [name, def] of Object.entries(collectionDefs)) {
195
195
  const routeInfo = collectionRoutes.get(def.name)
196
-
197
- if (routeInfo === undefined) {
198
- // No page renders this collection at all leave entries alone rather
199
- // than fabricating a pathname from an unrelated frontmatter field
200
- // (e.g. a data collection's own `url` field pointing elsewhere).
201
- responseCollectionDefs[name] = def
202
- } else {
203
- const routePrefix = typeof routeInfo === 'string' ? routeInfo : undefined
204
- const resolvePathname = (entry: CollectionEntryInfo): string | undefined =>
205
- declaredSitePathFromData(entry.data) ?? (routePrefix ? `${routePrefix}${entry.slug}` : undefined)
206
- const needsPatching = def.entries?.some(e => !e.pathname && resolvePathname(e))
207
-
208
- responseCollectionDefs[name] = !needsPatching ? def : {
209
- ...def,
210
- entries: def.entries!.map(e => e.pathname ? e : { ...e, pathname: resolvePathname(e) ?? e.pathname }),
211
- }
212
- }
196
+ const routePrefix = typeof routeInfo === 'string' ? routeInfo : undefined
197
+ // Whether any page route renders this collection. The heuristic fallbacks
198
+ // below (a frontmatter-declared site path, or the discovered route prefix)
199
+ // only apply to routed collections otherwise a declared `cms.pathname`
200
+ // spec whose fields are missing for an entry would fabricate a URL from an
201
+ // unrelated frontmatter field (e.g. a data collection's own `url`).
202
+ const routed = routeInfo !== undefined
203
+
204
+ // A declarative `cms.pathname` rule is the highest-priority source: it wins
205
+ // over the rendered-route pathname (addPage set entry.pathname to the page
206
+ // URL). resolvePathnameFromSpec is the same resolver manifest-writer uses at
207
+ // build, so dev-preview and the built manifest agree on the entry's URL.
208
+ const resolvePathname = (entry: CollectionEntryInfo): string | undefined =>
209
+ resolvePathnameFromSpec(def, entry.data)
210
+ ?? entry.pathname
211
+ ?? (routed ? declaredSitePathFromData(entry.data) : undefined)
212
+ ?? (routePrefix ? `${routePrefix}${entry.slug}` : undefined)
213
+
214
+ // Build patched copies rather than mutating the originals so heuristic
215
+ // pathnames don't persist if the route file is later removed. Only patch
216
+ // entries whose resolved pathname differs from what they already have.
217
+ const patchedEntries = def.entries?.map(e => {
218
+ const pathname = resolvePathname(e)
219
+ return pathname && pathname !== e.pathname ? { ...e, pathname } : e
220
+ })
221
+ const changed = patchedEntries?.some((e, i) => e !== def.entries![i]) ?? false
222
+ responseCollectionDefs[name] = changed ? { ...def, entries: patchedEntries! } : def
213
223
 
214
224
  const entries = responseCollectionDefs[name].entries
215
225
  if (entries) {
@@ -440,7 +450,7 @@ async function markHtmlForDev(
440
450
  const idGenerator = () => `cms-${pageCounter++}`
441
451
 
442
452
  // Check if this is a collection page
443
- const collectionInfo = await findCollectionSource(pagePath, config.contentDir)
453
+ const collectionInfo = await findCollectionSource(pagePath, config.contentDir, manifestWriter.getCollectionDefinitions())
444
454
  const isCollectionPage = !!collectionInfo
445
455
 
446
456
  let mdContent: Awaited<ReturnType<typeof parseMarkdownContent>> | undefined
@@ -2,11 +2,11 @@ import type { CmsCore, CmsFileSystem } from '@nuasite/cms-core'
2
2
  import { listProjectImages } from '@nuasite/cms-core'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import path from 'node:path'
5
- import { scanCollections } from '../collection-scanner'
6
5
  import { getProjectRoot } from '../config'
7
6
  import { expectedDeletions } from '../dev-middleware'
8
7
  import type { ManifestWriter } from '../manifest-writer'
9
8
  import type { MediaStorageAdapter } from '../media/types'
9
+ import { scanCollections } from '../scan-cache'
10
10
  import { handleAddArrayItem, handleRemoveArrayItem } from './array-ops'
11
11
  import { tryAstroImageUpload } from './astro-image-upload'
12
12
  import { handleInsertComponent, handleRemoveComponent } from './component-ops'
package/src/index.ts CHANGED
@@ -7,9 +7,8 @@ import { fileURLToPath } from 'node:url'
7
7
  import { createLocalStorageAdapter } from '@nuasite/cms-core'
8
8
  import remarkDirective from 'remark-directive'
9
9
  import { processBuildOutput } from './build-processor'
10
- import { scanCollections } from './collection-scanner'
11
10
  import { ComponentRegistry } from './component-registry'
12
- import { resetProjectRoot } from './config'
11
+ import { getProjectRoot, resetProjectRoot } from './config'
13
12
  import { createDevMiddleware } from './dev-middleware'
14
13
  import { getErrorCollector, resetErrorCollector } from './error-collector'
15
14
  import { ADMIN_ROUTE, createLocalAdminMiddleware } from './local-admin'
@@ -18,6 +17,7 @@ import type { MediaStorageAdapter } from './media/types'
18
17
  import { type CmsMode, resolveCmsMode } from './mode'
19
18
  import { rehypeCmsMarker } from './rehype-cms-marker'
20
19
  import { remarkListDirective } from './remark-list-directive'
20
+ import { scanCollections } from './scan-cache'
21
21
  import type { CmsFeatures, CmsMarkerOptions, ComponentDefinition } from './types'
22
22
  import { createPublicStaticFileChecker } from './utils'
23
23
  import { createVitePlugin } from './vite-plugin'
@@ -482,14 +482,14 @@ export {
482
482
  createS3StorageAdapter as s3Media,
483
483
  } from '@nuasite/cms-core'
484
484
  export { FIELD_TYPES, isFieldType } from '@nuasite/cms-types'
485
- export type { CollectionLayout, CollectionLayoutSection } from '@nuasite/cms-types'
485
+ export type { CollectionLayout, CollectionLayoutSection, PathnameSegment, PathnameSpec } from '@nuasite/cms-types'
486
486
  export { defineCmsCollection, n } from './field-types'
487
487
  export type { DateHints, FileHints, ImageHints, LayoutHints, NumberHints, TextareaHints, TextHints } from './field-types'
488
488
  export type { MediaFolderItem, MediaItem, MediaListOptions, MediaListResult, MediaStorageAdapter, MediaTypeFilter } from './media/types'
489
489
  export { type CmsMode, detectHostedFromEnv, resolveCmsMode } from './mode'
490
490
  export type { Color, Date, DateTime, Email, Image, Reference, Textarea, Time, Url } from './prop-types'
491
491
 
492
- export { scanCollections } from './collection-scanner'
492
+ export { scanCollections } from '@nuasite/cms-core'
493
493
  export { getProjectRoot, resetProjectRoot, setProjectRoot } from './config'
494
494
  export { type FieldMigration, migrateAstroImages, type MigrationOptions, type MigrationResult } from './migrate-astro-image'
495
495
  export { rehypeCmsMarker } from './rehype-cms-marker'
@@ -1,3 +1,4 @@
1
+ import { resolvePathnameFromSpec } from '@nuasite/cms-core'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import { getProjectRoot } from './config'
@@ -279,12 +280,19 @@ export class ManifestWriter {
279
280
 
280
281
  // Populate pathname on collection definition entries
281
282
  for (const def of Object.values(this.collectionDefinitions)) {
282
- if (def.entries) {
283
- for (const entry of def.entries) {
284
- const pathname = collectionPathMap.get(`${def.name}/${entry.slug}`)
285
- if (pathname) {
286
- entry.pathname = pathname
287
- }
283
+ if (!def.entries) continue
284
+ for (const entry of def.entries) {
285
+ // A declarative `cms.pathname` rule is the highest-priority source: it
286
+ // wins over the rendered-page pathname. resolvePathnameFromSpec is the
287
+ // same resolver the dev middleware uses, so dev and build agree.
288
+ const fromSpec = resolvePathnameFromSpec(def, entry.data)
289
+ if (fromSpec) {
290
+ entry.pathname = fromSpec
291
+ continue
292
+ }
293
+ const pathname = collectionPathMap.get(`${def.name}/${entry.slug}`)
294
+ if (pathname) {
295
+ entry.pathname = pathname
288
296
  }
289
297
  }
290
298
  }
@@ -1,8 +1,8 @@
1
+ import { createNodeFs, scanCollections } from '@nuasite/cms-core'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import { parseDocument } from 'yaml'
4
5
  import { pickAstroImageTarget } from './astro-image-paths'
5
- import { scanCollections } from './collection-scanner'
6
6
  import { getProjectRoot } from './config'
7
7
 
8
8
  export interface MigrationOptions {
@@ -33,7 +33,7 @@ export async function migrateAstroImages(options: MigrationOptions = {}): Promis
33
33
  const projectRoot = options.projectRoot ?? getProjectRoot()
34
34
  const dryRun = options.dryRun ?? false
35
35
 
36
- const collections = await scanCollections()
36
+ const collections = await scanCollections(createNodeFs(projectRoot))
37
37
  const migrations: FieldMigration[] = []
38
38
  const skipped: MigrationResult['skipped'] = []
39
39
 
@@ -0,0 +1,22 @@
1
+ import { createNodeFs, type ParseCache, scanCollections as coreScanCollections } from '@nuasite/cms-core'
2
+ import type { CollectionDefinition } from '@nuasite/cms-types'
3
+ import { getProjectRoot } from './config'
4
+
5
+ /**
6
+ * Process-persistent content-config parse cache shared across every standalone
7
+ * collection scan (server setup + each entry create/delete). cms-core's
8
+ * `parseContentConfig` is mtime-keyed, so threading one cache here lets repeated
9
+ * scans skip re-reading and re-Babel-parsing `content.config.ts` when it hasn't
10
+ * changed — the CmsCore instance keeps its own cache the same way, but these
11
+ * top-level call sites bypass it.
12
+ */
13
+ const parseCache: ParseCache = new Map()
14
+
15
+ /**
16
+ * Scan all content collections against the current project root, reusing the
17
+ * shared {@link parseCache}. Drop-in replacement for cms-core's `scanCollections`
18
+ * for the dev-server call sites that would otherwise each allocate a fresh cache.
19
+ */
20
+ export function scanCollections(contentDir?: string): Promise<Record<string, CollectionDefinition>> {
21
+ return coreScanCollections(createNodeFs(getProjectRoot()), contentDir, parseCache)
22
+ }
@@ -1,8 +1,10 @@
1
+ import { resolvePathnameFromSpec } from '@nuasite/cms-core'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import { isMap, isPair, isScalar, isSeq, LineCounter, parseDocument } from 'yaml'
4
5
 
5
6
  import { getProjectRoot } from '../config'
7
+ import { scanCollections } from '../scan-cache'
6
8
  import type { CollectionDefinition } from '../types'
7
9
  import { getCollectionTextIndex, getDeclaredUrlIndexCache, getMarkdownFileCache, setCollectionTextIndex } from './cache'
8
10
  import { normalizeText } from './snippet-utils'
@@ -249,6 +251,7 @@ const DECLARED_URL_FIELDS = ['urlpath', 'permalink', 'pathname', 'route', 'url']
249
251
  export async function findCollectionSource(
250
252
  pagePath: string,
251
253
  contentDir: string = 'src/content',
254
+ collections?: Record<string, CollectionDefinition>,
252
255
  ): Promise<CollectionInfo | undefined> {
253
256
  // Remove leading/trailing slashes
254
257
  const cleanPath = pagePath.replace(/^\/+|\/+$/g, '')
@@ -259,6 +262,18 @@ export async function findCollectionSource(
259
262
  }
260
263
 
261
264
  const requestedUrl = normalizeSitePath(`/${cleanPath}`)
265
+
266
+ // Highest priority: derive each entry's canonical URL from its collection's
267
+ // declarative `cms.pathname` rule (the same spec the forward resolver/manifest
268
+ // use) and match. This keeps reverse resolution (URL → source file) consistent
269
+ // with forward resolution without the entry needing a redundant `urlPath`
270
+ // frontmatter field, and correctly resolves entries whose filename differs
271
+ // from their URL slug (e.g. people stored as `<role>__<slug>.md` served at
272
+ // `/<family>/<slug>`). Collections without a `cms.pathname` rule contribute
273
+ // nothing here and fall through to the filename/declared-URL logic below.
274
+ const bySpec = await resolveBySpec(requestedUrl, contentDir, collections)
275
+ if (bySpec) return bySpec
276
+
262
277
  const contentPath = path.join(getProjectRoot(), contentDir)
263
278
 
264
279
  try {
@@ -357,6 +372,67 @@ export async function findCollectionSource(
357
372
  return undefined
358
373
  }
359
374
 
375
+ // Reverse index (computed spec URL → source) cached per scanned-collections
376
+ // object. The scan cache hands back a fresh object identity whenever the
377
+ // content config or any entry changes, so a WeakMap keyed on it rebuilds the
378
+ // index exactly when the underlying data changes and never goes stale.
379
+ const specPathnameIndexCache = new WeakMap<object, Map<string, CollectionInfo>>()
380
+
381
+ /**
382
+ * Resolve `requestedUrl` to a source file by applying each collection's
383
+ * declarative `cms.pathname` rule to its entries' frontmatter data. Returns
384
+ * undefined when no collection declares a rule or none produces `requestedUrl`.
385
+ *
386
+ * `collections` should be the definitions already scanned once at plugin setup
387
+ * (`manifestWriter.getCollectionDefinitions()`) — passing them keeps this off the
388
+ * hot per-page path with no rescans and guarantees the reverse index matches the
389
+ * forward resolver's specs exactly. When omitted (tests, misc callers) it falls
390
+ * back to a self-contained scan.
391
+ */
392
+ async function resolveBySpec(
393
+ requestedUrl: string,
394
+ contentDir: string,
395
+ collections: Record<string, CollectionDefinition> | undefined,
396
+ ): Promise<CollectionInfo | undefined> {
397
+ let defs = collections
398
+ if (!defs) {
399
+ try {
400
+ defs = await scanCollections(contentDir)
401
+ } catch {
402
+ return undefined
403
+ }
404
+ }
405
+
406
+ let index = specPathnameIndexCache.get(defs)
407
+ if (!index) {
408
+ index = buildSpecPathnameIndex(defs)
409
+ specPathnameIndexCache.set(defs, index)
410
+ }
411
+ return index.get(requestedUrl)
412
+ }
413
+
414
+ /**
415
+ * Build a `computed-URL → CollectionInfo` map from every collection that
416
+ * declares a `cms.pathname` rule. Collections and entries are visited in sorted
417
+ * order and the first writer of a URL wins, so a URL collision (a content bug)
418
+ * resolves deterministically rather than depending on scan/readdir order.
419
+ */
420
+ function buildSpecPathnameIndex(collections: Record<string, CollectionDefinition>): Map<string, CollectionInfo> {
421
+ const index = new Map<string, CollectionInfo>()
422
+ for (const name of Object.keys(collections).sort()) {
423
+ const def = collections[name]
424
+ if (!def?.pathname || !def.entries) continue
425
+ const entries = [...def.entries].sort((a, b) => a.slug.localeCompare(b.slug))
426
+ for (const entry of entries) {
427
+ if (!entry.data) continue
428
+ const url = resolvePathnameFromSpec(def, entry.data)
429
+ if (!url || index.has(url)) continue
430
+ index.set(url, { name: def.name, slug: entry.slug, file: entry.sourcePath })
431
+ }
432
+ }
433
+ return index
434
+ }
435
+
360
436
  /** Normalize a site-absolute path: ensure a leading slash, drop query/hash and any trailing slash. */
361
437
  function normalizeSitePath(p: string): string {
362
438
  let s = p.split('?')[0]?.split('#')[0] ?? p
package/src/types.ts CHANGED
@@ -1,4 +1,16 @@
1
- import type { CollectionDefinition, CollectionEntry, ComponentDefinition } from '@nuasite/cms-types'
1
+ import type {
2
+ Attribute,
3
+ AvailableColors,
4
+ AvailableTextStyles,
5
+ BackgroundImageMetadata,
6
+ CollectionDefinition,
7
+ CollectionEntry,
8
+ ComponentDefinition,
9
+ ContentConstraints,
10
+ ImageMetadata,
11
+ ManifestMetadata,
12
+ PageEntry,
13
+ } from '@nuasite/cms-types'
2
14
 
3
15
  // Structural contract types live in @nuasite/cms-types (the shared wire model).
4
16
  // Re-exported here so existing `@nuasite/cms` imports keep working unchanged.
@@ -12,6 +24,8 @@ export type {
12
24
  FieldHints,
13
25
  FieldType,
14
26
  MutationResult,
27
+ PathnameSegment,
28
+ PathnameSpec,
15
29
  } from '@nuasite/cms-types'
16
30
  export type {
17
31
  AddRedirectRequest,
@@ -26,6 +40,23 @@ export type {
26
40
  RedirectRule,
27
41
  UpdateRedirectRequest,
28
42
  } from '@nuasite/cms-types'
43
+ // Render/manifest model types now live in @nuasite/cms-types (the shared, DOM-free
44
+ // wire model). Re-exported here so existing `@nuasite/cms` imports keep working.
45
+ export type {
46
+ Attribute,
47
+ AvailableColors,
48
+ AvailableTextStyles,
49
+ BackgroundImageMetadata,
50
+ CmsManifest,
51
+ ComponentInstance,
52
+ ContentConstraints,
53
+ ImageMetadata,
54
+ ManifestEntry,
55
+ ManifestMetadata,
56
+ PageEntry,
57
+ TailwindColor,
58
+ TextStyleValue,
59
+ } from '@nuasite/cms-types'
29
60
 
30
61
  /** SEO tracking options */
31
62
  export interface SeoOptions {
@@ -52,40 +83,6 @@ export interface CmsMarkerOptions {
52
83
  seo?: SeoOptions
53
84
  }
54
85
 
55
- /** Background image metadata for elements using bg-[url()] */
56
- export interface BackgroundImageMetadata {
57
- /** Full Tailwind class, e.g. bg-[url('/path.png')] */
58
- bgImageClass: string
59
- /** Extracted image URL, e.g. /path.png */
60
- imageUrl: string
61
- /** Background size class: bg-auto | bg-cover | bg-contain */
62
- bgSize?: string
63
- /** Background position class: bg-center | bg-top | bg-bottom-left | ... */
64
- bgPosition?: string
65
- /** Background repeat class: bg-repeat | bg-no-repeat | bg-repeat-x | bg-repeat-y */
66
- bgRepeat?: string
67
- }
68
-
69
- /** Image metadata for better tracking and integrity */
70
- export interface ImageMetadata {
71
- /** Image source URL */
72
- src: string
73
- /** Alt text */
74
- alt: string
75
- /** SHA256 hash of image content (for integrity checking) */
76
- hash?: string
77
- /** Image dimensions */
78
- dimensions?: { width: number; height: number }
79
- /** Responsive image srcset */
80
- srcSet?: string
81
- /** Image sizes attribute */
82
- sizes?: string
83
- /** 0-based DOM-order index of this `<img>` among same-(src, sourceFile)
84
- * occurrences on the page. Disambiguates source location when the same
85
- * image URL appears multiple times in the same source file. */
86
- srcOccurrence?: number
87
- }
88
-
89
86
  /** Identifies the (collection, entry, field) destination for an editor upload. */
90
87
  export interface MediaUploadContext {
91
88
  collection: string
@@ -93,186 +90,6 @@ export interface MediaUploadContext {
93
90
  field: string
94
91
  }
95
92
 
96
- /** Content constraints for validation */
97
- export interface ContentConstraints {
98
- /** Maximum content length */
99
- maxLength?: number
100
- /** Minimum content length */
101
- minLength?: number
102
- /** Regex pattern for validation */
103
- pattern?: string
104
- /** Allowed HTML tags for rich text content */
105
- allowedTags?: string[]
106
- }
107
-
108
- /** Represents a single Tailwind color with its shades and values */
109
- export interface TailwindColor {
110
- /** Color name (e.g., 'red', 'blue', 'primary') */
111
- name: string
112
- /** Map of shade to CSS color value (e.g., { '500': '#ef4444', '600': '#dc2626' }) */
113
- values: Record<string, string>
114
- /** Whether this is a custom/theme color vs default Tailwind */
115
- isCustom?: boolean
116
- }
117
-
118
- /** Attribute with source information for git diff tracking */
119
- export interface Attribute {
120
- /** The resolved attribute value (from rendered HTML) */
121
- value: string
122
- /** The expression text if dynamic (e.g., "component.githubUrl") */
123
- sourceExpression?: string
124
- /** Path to the source file where the value is defined */
125
- sourcePath?: string
126
- /** Line number where the value is defined in source (1-indexed) */
127
- sourceLine?: number
128
- /** The exact source snippet that can be replaced for git diff */
129
- sourceSnippet?: string
130
- }
131
-
132
- /** Available colors palette from Tailwind config */
133
- export interface AvailableColors {
134
- /** All available colors with their shades */
135
- colors: TailwindColor[]
136
- /** Default Tailwind color names */
137
- defaultColors: string[]
138
- /** Custom/theme color names */
139
- customColors: string[]
140
- }
141
-
142
- /** Text style value with class name and CSS properties */
143
- export interface TextStyleValue {
144
- /** Tailwind class name (e.g., 'font-bold', 'text-xl') */
145
- class: string
146
- /** Display label for UI */
147
- label: string
148
- /** CSS properties to apply (e.g., { fontWeight: '700' }) */
149
- css: Record<string, string>
150
- }
151
-
152
- /** Available text styles from Tailwind config */
153
- export interface AvailableTextStyles {
154
- /** Font weight options (font-normal, font-bold, etc.) */
155
- fontWeight: TextStyleValue[]
156
- /** Font size options (text-xs, text-sm, text-base, etc.) */
157
- fontSize: TextStyleValue[]
158
- /** Text decoration options (underline, line-through, etc.) */
159
- textDecoration: TextStyleValue[]
160
- /** Font style options (italic, not-italic) */
161
- fontStyle: TextStyleValue[]
162
- }
163
-
164
- export interface ManifestEntry {
165
- id: string
166
- tag: string
167
- /** Plain text content (for display/search) */
168
- text: string
169
- /** HTML content when element contains inline styling (strong, em, etc.) */
170
- html?: string
171
- sourcePath?: string
172
- sourceLine?: number
173
- /** Full element snippet from opening to closing tag (for text content updates) */
174
- sourceSnippet?: string
175
- variableName?: string
176
- childCmsIds?: string[]
177
- parentComponentId?: string
178
- /** Collection name for collection entries (e.g., 'services', 'blog') */
179
- collectionName?: string
180
- /** Entry slug for collection entries (e.g., '3d-tisk') */
181
- collectionSlug?: string
182
- /** Schema field name when this entry was resolved to a specific collection field (e.g., 'image', 'cover'). */
183
- collectionFieldName?: string
184
- /** Path to the markdown content file (e.g., 'src/content/blog/my-post.md') */
185
- contentPath?: string
186
-
187
- // === Robustness fields ===
188
-
189
- /** Stable ID derived from content + context hash, survives rebuilds */
190
- stableId?: string
191
- /** SHA256 hash of sourceSnippet at generation time for conflict detection */
192
- sourceHash?: string
193
- /** Image metadata for img elements (replaces imageSrc/imageAlt) */
194
- imageMetadata?: ImageMetadata
195
- /** Background image metadata for elements using bg-[url()] */
196
- backgroundImage?: BackgroundImageMetadata
197
- /** Content validation constraints */
198
- constraints?: ContentConstraints
199
- /** Color classes applied to this element (for buttons, etc.) */
200
- colorClasses?: Record<string, Attribute>
201
- /** All HTML attributes with source information */
202
- attributes?: Record<string, Attribute>
203
- /** Whether inline text styling (bold, italic, etc.) can be applied.
204
- * False when text comes from a string variable/prop that cannot contain HTML markup. */
205
- allowStyling?: boolean
206
-
207
- // === Reference field metadata ===
208
-
209
- /** Collection the text was found in when it came through a reference (e.g., 'authors') */
210
- referenceCollection?: string
211
- /** Collections that have reference fields pointing to referenceCollection */
212
- referencedBy?: Array<{ collection: string; fieldName: string; isArray?: boolean }>
213
- }
214
-
215
- export interface ComponentInstance {
216
- id: string
217
- componentName: string
218
- file: string
219
- sourcePath: string
220
- sourceLine: number
221
- props: Record<string, any>
222
- slots?: Record<string, string>
223
- parentId?: string
224
- /** File where this component is invoked (parent page/layout) */
225
- invocationSourcePath?: string
226
- /** 0-based index among same-name component invocations in the parent file */
227
- invocationIndex?: number
228
- /** Whether this component represents an inline HTML element inside a .map() array */
229
- isInlineArray?: boolean
230
- }
231
-
232
- /** Manifest metadata for versioning and conflict detection */
233
- export interface ManifestMetadata {
234
- /** Manifest schema version */
235
- version: string
236
- /** ISO timestamp when manifest was generated */
237
- generatedAt: string
238
- /** Build system that generated the manifest (e.g., 'astro', 'vite') */
239
- generatedBy?: string
240
- /** Build ID for correlation */
241
- buildId?: string
242
- /** SHA256 hash of all entry content for quick drift detection */
243
- contentHash?: string
244
- /** Per-source-file hashes for granular conflict detection */
245
- sourceFileHashes?: Record<string, string>
246
- }
247
-
248
- /** Page entry for the global manifest */
249
- export interface PageEntry {
250
- /** Page URL pathname (e.g., '/', '/about') */
251
- pathname: string
252
- /** Page title from SEO data */
253
- title?: string
254
- }
255
-
256
- export interface CmsManifest {
257
- /** Manifest metadata for versioning and conflict detection */
258
- metadata?: ManifestMetadata
259
- entries: Record<string, ManifestEntry>
260
- components: Record<string, ComponentInstance>
261
- componentDefinitions: Record<string, ComponentDefinition>
262
- /** Content collection entries indexed by "collectionName/slug" */
263
- collections?: Record<string, CollectionEntry>
264
- /** Collection definitions with inferred schemas */
265
- collectionDefinitions?: Record<string, CollectionDefinition>
266
- /** Available Tailwind colors from the project's config */
267
- availableColors?: AvailableColors
268
- /** Available text styles from the project's Tailwind config */
269
- availableTextStyles?: AvailableTextStyles
270
- /** All pages in the site with pathname and title */
271
- pages?: PageEntry[]
272
- /** Component names allowed in the MDX component picker (undefined = all) */
273
- mdxComponents?: string[]
274
- }
275
-
276
93
  // === SEO Types ===
277
94
 
278
95
  /** Source tracking information for SEO elements */