@nuasite/cms 0.18.1 → 0.19.1

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 (61) hide show
  1. package/dist/editor.js +52746 -36711
  2. package/package.json +16 -14
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +78 -14
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/types.ts +111 -2
  61. package/src/utils.ts +40 -4
@@ -1,13 +1,17 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
3
4
 
4
5
  import { getProjectRoot } from '../config'
5
- import type { Attribute, ManifestEntry } from '../types'
6
+ import type { Attribute, CollectionDefinition, ManifestEntry } from '../types'
6
7
  import { escapeRegex, generateSourceHash } from '../utils'
7
8
  import { buildDefinitionPath } from './ast-extractors'
8
9
  import { getCachedParsedFile } from './ast-parser'
10
+ import { findFieldInCollectionEntry, findTextInAnyCollectionFrontmatter } from './collection-finder'
9
11
  import { findAttributeSourceLocation, searchForExpressionProp, searchForPropInParents } from './cross-file-tracker'
10
12
  import { findImageElementNearLine, findImageSourceLocation } from './image-finder'
13
+ import { initializeSearchIndex } from './search-index'
14
+ import type { CachedParsedFile, ImageMatch, SourceLocation } from './types'
11
15
 
12
16
  // ============================================================================
13
17
  // Text Normalization
@@ -468,13 +472,86 @@ export async function extractSourceSnippet(
468
472
  */
469
473
  export async function enhanceManifestWithSourceSnippets(
470
474
  entries: Record<string, ManifestEntry>,
475
+ collectionDefinitions?: Record<string, CollectionDefinition>,
471
476
  ): Promise<Record<string, ManifestEntry>> {
477
+ // Ensure the search index is ready (returns immediately if already built,
478
+ // otherwise waits for the in-flight initialization or triggers a new one).
479
+ await initializeSearchIndex()
480
+
472
481
  const enhanced: Record<string, ManifestEntry> = {}
473
482
 
483
+ // Build a reverse-reference index once so we don't recompute per entry
484
+ const referenceIndex = new Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>()
485
+ if (collectionDefinitions) {
486
+ for (const [colName, colDef] of Object.entries(collectionDefinitions)) {
487
+ for (const field of colDef.fields) {
488
+ const target = field.type === 'reference'
489
+ ? field.collection
490
+ : (field.type === 'array' && field.itemType === 'reference')
491
+ ? field.collection
492
+ : undefined
493
+ if (target) {
494
+ let arr = referenceIndex.get(target)
495
+ if (!arr) {
496
+ arr = []
497
+ referenceIndex.set(target, arr)
498
+ }
499
+ arr.push({ collection: colName, fieldName: field.name, ...(field.type === 'array' && { isArray: true }) })
500
+ }
501
+ }
502
+ }
503
+ }
504
+
505
+ // Propagate collectionName/collectionSlug from wrapper entries to their children.
506
+ // The HTML processor only sets collection info on the wrapper element itself;
507
+ // child entries (images, text) need it for direct data-file resolution.
508
+ // Build parent→children lookup, then propagate down the tree.
509
+ const childrenOf = new Map<string, ManifestEntry[]>()
510
+ for (const entry of Object.values(entries)) {
511
+ if (entry.parentComponentId) {
512
+ const siblings = childrenOf.get(entry.parentComponentId)
513
+ if (siblings) siblings.push(entry)
514
+ else childrenOf.set(entry.parentComponentId, [entry])
515
+ }
516
+ }
517
+ const propagateCollection = (parentId: string, name: string, slug: string) => {
518
+ const children = childrenOf.get(parentId)
519
+ if (!children) return
520
+ for (const child of children) {
521
+ if (!child.collectionName) {
522
+ child.collectionName = name
523
+ child.collectionSlug = slug
524
+ propagateCollection(child.id, name, slug)
525
+ }
526
+ }
527
+ }
528
+ for (const entry of Object.values(entries)) {
529
+ if (entry.collectionName && entry.collectionSlug) {
530
+ propagateCollection(entry.id, entry.collectionName, entry.collectionSlug)
531
+ }
532
+ }
533
+
474
534
  // Process entries in parallel for better performance
475
535
  const entryPromises = Object.entries(entries).map(async ([id, entry]) => {
476
536
  // Handle image entries specially - find the line with src attribute
477
537
  if (entry.imageMetadata?.src) {
538
+ // ── Collection images: resolve directly from the data file ──
539
+ // When an image belongs to a known collection entry, bypass the search index
540
+ // entirely. Astro hashes image filenames (e.g. ./photo.jpg → /assets/a1b2c3.webp),
541
+ // making reverse URL lookup unreliable. Instead, look up the image field(s)
542
+ // directly in the collection entry's data file.
543
+ if (entry.collectionName && entry.collectionSlug && collectionDefinitions) {
544
+ const imageLocation = await resolveCollectionImageField(
545
+ entry,
546
+ collectionDefinitions,
547
+ referenceIndex,
548
+ )
549
+ if (imageLocation) {
550
+ return [id, imageLocation] as const
551
+ }
552
+ }
553
+
554
+ // ── Non-collection images: find via search index / AST ──
478
555
  const imageLocation = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
479
556
  if (imageLocation) {
480
557
  const sourceHash = generateSourceHash(imageLocation.snippet || entry.imageMetadata.src)
@@ -524,8 +601,6 @@ export async function enhanceManifestWithSourceSnippets(
524
601
  }
525
602
 
526
603
  // Fallback for expression-based src attributes (src={variable})
527
- // Use the entry's existing sourcePath/sourceLine to find the img tag
528
- // by its position in the AST rather than by src value
529
604
  if (entry.sourcePath && entry.sourceLine) {
530
605
  try {
531
606
  const filePath = path.isAbsolute(entry.sourcePath)
@@ -535,6 +610,18 @@ export async function enhanceManifestWithSourceSnippets(
535
610
  if (cached) {
536
611
  const nearbyImg = findImageElementNearLine(cached.ast, entry.sourceLine, cached.lines)
537
612
  if (nearbyImg) {
613
+ const resolvedEntry = await resolveImageExpression(
614
+ entry,
615
+ nearbyImg,
616
+ cached,
617
+ filePath,
618
+ collectionDefinitions,
619
+ referenceIndex,
620
+ )
621
+ if (resolvedEntry) {
622
+ return [id, resolvedEntry] as const
623
+ }
624
+
538
625
  const sourceHash = generateSourceHash(nearbyImg.snippet || entry.imageMetadata.src)
539
626
  return [id, {
540
627
  ...entry,
@@ -549,6 +636,12 @@ export async function enhanceManifestWithSourceSnippets(
549
636
  }
550
637
  }
551
638
 
639
+ // Final fallback: search collection frontmatter directly for the image URL
640
+ const collectionResult = await searchCollectionWithDecodedFallback(entry.imageMetadata.src, collectionDefinitions)
641
+ if (collectionResult) {
642
+ return [id, applyCollectionSource(entry, collectionResult, referenceIndex)] as const
643
+ }
644
+
552
645
  return [id, entry] as const
553
646
  }
554
647
 
@@ -708,6 +801,23 @@ export async function enhanceManifestWithSourceSnippets(
708
801
  }
709
802
  }
710
803
  }
804
+
805
+ // Search collection frontmatter — text rendered on listing pages
806
+ // from collection entries (e.g. {post.data.title}) won't be found
807
+ // through AST or prop lookups since the value lives in a .md file
808
+ if (collectionDefinitions && Object.keys(collectionDefinitions).length > 0) {
809
+ const mdSource = await findTextInAnyCollectionFrontmatter(trimmedText, collectionDefinitions)
810
+ if (mdSource) {
811
+ return [
812
+ id,
813
+ applyCollectionSource(entry, mdSource, referenceIndex, {
814
+ allowStyling: false,
815
+ attributes,
816
+ colorClasses,
817
+ }),
818
+ ] as const
819
+ }
820
+ }
711
821
  }
712
822
 
713
823
  // Original static content path
@@ -732,5 +842,315 @@ export async function enhanceManifestWithSourceSnippets(
732
842
  enhanced[id] = entry
733
843
  }
734
844
 
845
+ // Post-processing: augment entries with collection and reference metadata.
846
+ // Source resolution may find text via prop/expression tracking (pointing to a parent
847
+ // component) before the collection frontmatter search runs. In that case the source
848
+ // location is correct for editing, but collection identity and reference metadata are
849
+ // missing. This pass adds both by checking if text exists in any collection.
850
+ // collectionName/collectionSlug are needed on owning entries (e.g. news titles) so
851
+ // that findOwnerEntry in the editor can locate them as siblings of reference elements.
852
+ if (collectionDefinitions && Object.keys(collectionDefinitions).length > 0) {
853
+ // Cache text→result to avoid redundant file reads when the same text appears
854
+ // in multiple manifest entries (e.g., author names repeated on listing pages)
855
+ const textLookupCache = new Map<
856
+ string,
857
+ { source: SourceLocation; referencedBy?: Array<{ collection: string; fieldName: string; isArray?: boolean }> } | null
858
+ >()
859
+
860
+ async function resolveCollectionText(trimmed: string) {
861
+ const cached = textLookupCache.get(trimmed)
862
+ if (cached !== undefined) return cached
863
+
864
+ // Search each collection individually so we can prefer referenced collections
865
+ // (findTextInAnyCollectionFrontmatter returns the first match, which may be wrong
866
+ // when the same text exists in multiple collections like "team" and "authors")
867
+ let bestSource: SourceLocation | undefined
868
+ let bestReferencedBy: Array<{ collection: string; fieldName: string; isArray?: boolean }> | undefined
869
+ for (const def of Object.values(collectionDefinitions!)) {
870
+ if (!def.entries || def.entries.length === 0) continue
871
+ const singleCol = { [def.name]: def }
872
+ const source = await findTextInAnyCollectionFrontmatter(trimmed, singleCol)
873
+ if (!source?.collectionName) continue
874
+ const refs = referenceIndex.get(source.collectionName)
875
+ if (refs && refs.length > 0) {
876
+ bestSource = source
877
+ bestReferencedBy = refs
878
+ break
879
+ }
880
+ if (!bestSource) {
881
+ bestSource = source
882
+ }
883
+ }
884
+
885
+ const result = bestSource ? { source: bestSource, referencedBy: bestReferencedBy } : null
886
+ textLookupCache.set(trimmed, result)
887
+ return result
888
+ }
889
+
890
+ const augmentPromises = Object.entries(enhanced).map(async ([id, entry]) => {
891
+ if (!entry.text?.trim()) return
892
+ // Skip if already fully resolved (has collection identity + reference metadata or no references exist)
893
+ if (entry.collectionName && (entry.referenceCollection || referenceIndex.size === 0)) return
894
+ const trimmed = entry.text.trim()
895
+
896
+ const resolved = await resolveCollectionText(trimmed)
897
+ if (!resolved) return
898
+
899
+ const refMeta = resolved.referencedBy
900
+ ? { referenceCollection: resolved.source.collectionName, referencedBy: resolved.referencedBy }
901
+ : {}
902
+ enhanced[id] = {
903
+ ...entry,
904
+ collectionName: entry.collectionName ?? resolved.source.collectionName,
905
+ collectionSlug: entry.collectionSlug ?? resolved.source.collectionSlug,
906
+ ...refMeta,
907
+ }
908
+ })
909
+ await Promise.all(augmentPromises)
910
+ }
911
+
735
912
  return enhanced
736
913
  }
914
+
915
+ // ============================================================================
916
+ // Collection Source Helpers
917
+ // ============================================================================
918
+
919
+ /** Search collection frontmatter for a value, falling back to the decoded Astro Image URL */
920
+ async function searchCollectionWithDecodedFallback(
921
+ src: string,
922
+ collectionDefinitions?: Record<string, CollectionDefinition>,
923
+ ): Promise<SourceLocation | undefined> {
924
+ if (!collectionDefinitions || Object.keys(collectionDefinitions).length === 0) return undefined
925
+
926
+ const mdSource = await findTextInAnyCollectionFrontmatter(src, collectionDefinitions)
927
+ if (mdSource) return mdSource
928
+
929
+ const decodedSrc = extractAstroImageOriginalUrl(src)
930
+ if (decodedSrc) {
931
+ return await findTextInAnyCollectionFrontmatter(decodedSrc, collectionDefinitions)
932
+ }
933
+ return undefined
934
+ }
935
+
936
+ /** Build a ManifestEntry from a collection frontmatter match */
937
+ function applyCollectionSource(
938
+ entry: ManifestEntry,
939
+ mdSource: SourceLocation,
940
+ referenceIndex?: Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>,
941
+ extra?: Partial<ManifestEntry>,
942
+ ): ManifestEntry {
943
+ const sourceHash = generateSourceHash(mdSource.snippet ?? '')
944
+ const referencedBy = mdSource.collectionName
945
+ ? referenceIndex?.get(mdSource.collectionName)
946
+ : undefined
947
+ return {
948
+ ...entry,
949
+ sourcePath: mdSource.file,
950
+ sourceLine: mdSource.line,
951
+ sourceSnippet: mdSource.snippet,
952
+ variableName: mdSource.variableName,
953
+ collectionName: mdSource.collectionName,
954
+ collectionSlug: mdSource.collectionSlug,
955
+ sourceHash,
956
+ ...(referencedBy && referencedBy.length > 0 && {
957
+ referenceCollection: mdSource.collectionName,
958
+ referencedBy,
959
+ }),
960
+ ...extra,
961
+ }
962
+ }
963
+
964
+ // ============================================================================
965
+ // Collection Image Resolution
966
+ // ============================================================================
967
+
968
+ /**
969
+ * Resolve a collection image entry directly from the data file.
970
+ * Uses the collection definition's image fields to find the source location
971
+ * without relying on URL matching (which fails when Astro hashes filenames).
972
+ *
973
+ * For entries with a single image field, the resolution is unambiguous.
974
+ * For multiple image fields, tries to match by value (exact or suffix).
975
+ */
976
+ async function resolveCollectionImageField(
977
+ entry: ManifestEntry,
978
+ collectionDefinitions: Record<string, CollectionDefinition>,
979
+ referenceIndex?: Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>,
980
+ ): Promise<ManifestEntry | undefined> {
981
+ const colDef = collectionDefinitions[entry.collectionName!]
982
+ if (!colDef) return undefined
983
+
984
+ const imageFields = colDef.fields.filter((f) => f.type === 'image')
985
+ if (imageFields.length === 0) return undefined
986
+
987
+ // Single image field — unambiguous
988
+ if (imageFields.length === 1) {
989
+ const fieldResult = await findFieldInCollectionEntry(
990
+ imageFields[0]!.name,
991
+ entry.collectionName!,
992
+ entry.collectionSlug!,
993
+ collectionDefinitions,
994
+ )
995
+ if (fieldResult) {
996
+ return applyCollectionSource(entry, fieldResult, referenceIndex)
997
+ }
998
+ return undefined
999
+ }
1000
+
1001
+ // Multiple image fields — try to match the rendered URL to a field value.
1002
+ const imgSrc = entry.imageMetadata!.src
1003
+ let firstFieldResult: SourceLocation | undefined
1004
+ for (const field of imageFields) {
1005
+ const fieldResult = await findFieldInCollectionEntry(
1006
+ field.name,
1007
+ entry.collectionName!,
1008
+ entry.collectionSlug!,
1009
+ collectionDefinitions,
1010
+ )
1011
+ if (!fieldResult?.snippet) continue
1012
+
1013
+ // Remember the first resolved field as fallback
1014
+ firstFieldResult ??= fieldResult
1015
+
1016
+ // Check if the field's value matches the rendered URL (exact or after Astro processing)
1017
+ const yamlKeyMatch = fieldResult.snippet.match(/^\s*[\w][\w-]*:\s*/)
1018
+ if (yamlKeyMatch) {
1019
+ try {
1020
+ const parsed = parseYaml(fieldResult.snippet)
1021
+ if (parsed && typeof parsed === 'object') {
1022
+ const key = fieldResult.snippet.match(/^\s*([\w][\w-]*):/)?.[1]
1023
+ const value = key ? (parsed as Record<string, unknown>)[key] : undefined
1024
+ if (typeof value === 'string' && (value === imgSrc || imgSrc.includes(value) || value.includes(imgSrc))) {
1025
+ return applyCollectionSource(entry, fieldResult, referenceIndex)
1026
+ }
1027
+ }
1028
+ } catch {
1029
+ // Not valid YAML
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ // No value match — fall back to first resolved image field
1035
+ if (firstFieldResult) {
1036
+ return applyCollectionSource(entry, firstFieldResult, referenceIndex)
1037
+ }
1038
+
1039
+ return undefined
1040
+ }
1041
+
1042
+ // ============================================================================
1043
+ // Image Expression Resolution
1044
+ // ============================================================================
1045
+
1046
+ /**
1047
+ * Resolve a dynamic image expression (e.g., src={article.image}) to its data source.
1048
+ * Mirrors the text expression resolution flow: tries variable definitions, cross-file
1049
+ * prop tracking, and collection frontmatter search.
1050
+ */
1051
+ async function resolveImageExpression(
1052
+ entry: ManifestEntry,
1053
+ nearbyImg: ImageMatch,
1054
+ cached: CachedParsedFile,
1055
+ filePath: string,
1056
+ collectionDefinitions?: Record<string, CollectionDefinition>,
1057
+ referenceIndex?: Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>,
1058
+ ): Promise<ManifestEntry | undefined> {
1059
+ const imgSrc = entry.imageMetadata?.src
1060
+ if (!imgSrc) return undefined
1061
+
1062
+ const normalizedSrc = normalizeText(imgSrc)
1063
+
1064
+ // Step 1: Try variable definitions — handles local variables (const image = "...")
1065
+ const matchingDef = cached.variableDefinitions.find(
1066
+ def => normalizeText(def.value) === normalizedSrc,
1067
+ )
1068
+ if (matchingDef) {
1069
+ const defSnippet = cached.lines[matchingDef.line - 1] || ''
1070
+ const sourceHash = generateSourceHash(defSnippet)
1071
+ return {
1072
+ ...entry,
1073
+ sourceLine: matchingDef.line,
1074
+ sourceSnippet: defSnippet,
1075
+ variableName: buildDefinitionPath(matchingDef),
1076
+ sourceHash,
1077
+ }
1078
+ }
1079
+
1080
+ // Step 2: Try cross-file prop tracking — handles props from parent components
1081
+ const exprPattern = /\{(\w+(?:\.\w+|\[\d+\])*)\}/g
1082
+ let exprMatch: RegExpExecArray | null
1083
+ while ((exprMatch = exprPattern.exec(nearbyImg.snippet)) !== null) {
1084
+ const exprPath = exprMatch[1]!
1085
+ const baseVar = exprPath.match(/^(\w+)/)?.[1]
1086
+ if (baseVar && cached.propAliases.has(baseVar)) {
1087
+ const propName = cached.propAliases.get(baseVar)!
1088
+ const componentFileName = path.basename(filePath)
1089
+ const result = await searchForExpressionProp(
1090
+ componentFileName,
1091
+ propName,
1092
+ exprPath,
1093
+ imgSrc,
1094
+ )
1095
+ if (result) {
1096
+ const propSnippet = result.snippet ?? imgSrc
1097
+ const sourceHash = generateSourceHash(propSnippet)
1098
+ return {
1099
+ ...entry,
1100
+ sourcePath: result.file,
1101
+ sourceLine: result.line,
1102
+ sourceSnippet: propSnippet,
1103
+ variableName: result.variableName,
1104
+ sourceHash,
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ // Step 3: Search collection frontmatter — handles {article.data.image} patterns
1111
+ // where the image URL lives in a markdown/data file's frontmatter
1112
+ const collectionResult = await searchCollectionWithDecodedFallback(imgSrc, collectionDefinitions)
1113
+ if (collectionResult) {
1114
+ return applyCollectionSource(entry, collectionResult, referenceIndex)
1115
+ }
1116
+
1117
+ // Step 4: Field-name-based lookup — handles Astro-optimized images where the rendered URL
1118
+ // is a hashed filename (e.g., /assets/02ea4e4b132e.webp) that can't be matched by value.
1119
+ // Extract the field name from the expression (e.g., {article.data.image} → "image")
1120
+ // and look it up directly in the known collection entry's data file.
1121
+ if (entry.collectionName && entry.collectionSlug && collectionDefinitions) {
1122
+ const exprFieldPattern = /\{[\w]+(?:\.data)?\.(\w+)\}/
1123
+ const fieldMatch = nearbyImg.snippet.match(exprFieldPattern)
1124
+ if (fieldMatch?.[1]) {
1125
+ const fieldResult = await findFieldInCollectionEntry(
1126
+ fieldMatch[1],
1127
+ entry.collectionName,
1128
+ entry.collectionSlug,
1129
+ collectionDefinitions,
1130
+ )
1131
+ if (fieldResult) {
1132
+ return applyCollectionSource(entry, fieldResult, referenceIndex)
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+ return undefined
1138
+ }
1139
+
1140
+ /**
1141
+ * Extract the original image path from an Astro Image optimization URL.
1142
+ * Astro's `<Image>` component rewrites src to `/_image?href=%2Fpath.jpg&w=...` in dev.
1143
+ * Returns the decoded `href` param, or undefined if the URL isn't an Astro image URL.
1144
+ */
1145
+ export function extractAstroImageOriginalUrl(src: string): string | undefined {
1146
+ try {
1147
+ const url = new URL(src, 'http://localhost')
1148
+ if (url.pathname === '/_image' || url.pathname.startsWith('/_image/')) {
1149
+ const href = url.searchParams.get('href')
1150
+ if (href && href !== src) return href
1151
+ }
1152
+ } catch {
1153
+ // Not a valid URL
1154
+ }
1155
+ return undefined
1156
+ }
package/src/types.ts CHANGED
@@ -180,6 +180,13 @@ export interface ManifestEntry {
180
180
  /** Whether inline text styling (bold, italic, etc.) can be applied.
181
181
  * False when text comes from a string variable/prop that cannot contain HTML markup. */
182
182
  allowStyling?: boolean
183
+
184
+ // === Reference field metadata ===
185
+
186
+ /** Collection the text was found in when it came through a reference (e.g., 'authors') */
187
+ referenceCollection?: string
188
+ /** Collections that have reference fields pointing to referenceCollection */
189
+ referencedBy?: Array<{ collection: string; fieldName: string; isArray?: boolean }>
183
190
  }
184
191
 
185
192
  export interface ComponentInstance {
@@ -249,6 +256,16 @@ export interface FieldDefinition {
249
256
  fields?: FieldDefinition[]
250
257
  /** Sample values seen across entries */
251
258
  examples?: unknown[]
259
+ /** Where the field renders in the editor UI */
260
+ position?: 'sidebar' | 'header'
261
+ /** Group name for visual grouping with section headers */
262
+ group?: string
263
+ /** Referenced collection name for 'reference' type fields */
264
+ collection?: string
265
+ /** Hide from the editor UI (e.g. derived/computed fields) */
266
+ hidden?: boolean
267
+ /** Source field name this field is derived from (e.g. categoryHref derived from category) */
268
+ derivedFrom?: string
252
269
  }
253
270
 
254
271
  /** Per-entry metadata for collection browsing */
@@ -259,6 +276,8 @@ export interface CollectionEntryInfo {
259
276
  draft?: boolean
260
277
  /** URL pathname of the rendered page for this entry */
261
278
  pathname?: string
279
+ /** Full entry data for data collections (JSON/YAML) */
280
+ data?: Record<string, unknown>
262
281
  }
263
282
 
264
283
  /** Definition of a content collection with inferred schema */
@@ -275,8 +294,10 @@ export interface CollectionDefinition {
275
294
  fields: FieldDefinition[]
276
295
  /** Whether the collection has draft support */
277
296
  supportsDraft?: boolean
297
+ /** Collection type: 'content' for markdown, 'data' for JSON/YAML */
298
+ type?: 'content' | 'data'
278
299
  /** File extension used by entries */
279
- fileExtension: 'md' | 'mdx'
300
+ fileExtension: 'md' | 'mdx' | 'json' | 'yaml' | 'yml'
280
301
  /** Per-entry metadata for browsing */
281
302
  entries?: CollectionEntryInfo[]
282
303
  }
@@ -321,6 +342,8 @@ export interface CmsManifest {
321
342
  availableTextStyles?: AvailableTextStyles
322
343
  /** All pages in the site with pathname and title */
323
344
  pages?: PageEntry[]
345
+ /** Component names allowed in the MDX component picker (undefined = all) */
346
+ mdxComponents?: string[]
324
347
  }
325
348
 
326
349
  // === SEO Types ===
@@ -575,6 +598,14 @@ export type CmsPostMessage =
575
598
  | CmsStateChangedMessage
576
599
  | CmsPageNavigatedMessage
577
600
 
601
+ // ============================================================================
602
+ // Feature Flags
603
+ // ============================================================================
604
+
605
+ export interface CmsFeatures {
606
+ selectElement?: boolean
607
+ }
608
+
578
609
  // ============================================================================
579
610
  // Inbound messages (parent → editor iframe)
580
611
  // ============================================================================
@@ -584,5 +615,83 @@ export interface CmsDeselectElementMessage {
584
615
  type: 'cms-deselect-element'
585
616
  }
586
617
 
618
+ export interface CmsSetFeaturesMessage {
619
+ type: 'cms-set-features'
620
+ features: CmsFeatures
621
+ }
622
+
587
623
  /** All possible CMS postMessage types sent from the parent to the editor iframe */
588
- export type CmsInboundMessage = CmsDeselectElementMessage
624
+ export type CmsInboundMessage = CmsDeselectElementMessage | CmsSetFeaturesMessage
625
+
626
+ // ============================================================================
627
+ // Page Operations (shared between server handlers and editor UI)
628
+ // ============================================================================
629
+
630
+ export interface CreatePageRequest {
631
+ title: string
632
+ slug: string
633
+ layoutPath?: string
634
+ }
635
+
636
+ export interface DuplicatePageRequest {
637
+ sourcePagePath: string
638
+ slug: string
639
+ title?: string
640
+ createRedirect?: boolean
641
+ }
642
+
643
+ export interface DeletePageRequest {
644
+ pagePath: string
645
+ createRedirect?: boolean
646
+ redirectTo?: string
647
+ }
648
+
649
+ export interface PageOperationResponse {
650
+ success: boolean
651
+ filePath?: string
652
+ slug?: string
653
+ url?: string
654
+ error?: string
655
+ }
656
+
657
+ export interface LayoutInfo {
658
+ name: string
659
+ path: string
660
+ }
661
+
662
+ // ============================================================================
663
+ // Redirect Operations (shared between server handlers and editor UI)
664
+ // ============================================================================
665
+
666
+ export interface RedirectRule {
667
+ source: string
668
+ destination: string
669
+ statusCode: number
670
+ lineIndex: number
671
+ }
672
+
673
+ export interface AddRedirectRequest {
674
+ source: string
675
+ destination: string
676
+ statusCode?: number
677
+ }
678
+
679
+ export interface UpdateRedirectRequest {
680
+ lineIndex: number
681
+ source: string
682
+ destination: string
683
+ statusCode?: number
684
+ }
685
+
686
+ export interface DeleteRedirectRequest {
687
+ lineIndex: number
688
+ }
689
+
690
+ export interface RedirectOperationResponse {
691
+ success: boolean
692
+ error?: string
693
+ }
694
+
695
+ export interface GetRedirectsResponse {
696
+ rules: RedirectRule[]
697
+ }
package/src/utils.ts CHANGED
@@ -143,11 +143,14 @@ export function escapeReplacement(str: string): string {
143
143
  */
144
144
  export function resolveAndValidatePath(filePath: string): string {
145
145
  const projectRoot = getProjectRoot()
146
- const fullPath = path.isAbsolute(filePath)
147
- ? path.resolve(filePath)
148
- : path.resolve(projectRoot, filePath)
149
-
150
146
  const resolvedRoot = path.resolve(projectRoot)
147
+ // Absolute filesystem paths (e.g. /Users/...) stay intact;
148
+ // project-relative paths with a leading slash (e.g. /src/content/...) get it stripped
149
+ const isAbsoluteFs = filePath.startsWith(resolvedRoot)
150
+ const normalizedPath = (!isAbsoluteFs && filePath.startsWith('/')) ? filePath.slice(1) : filePath
151
+ const fullPath = path.isAbsolute(normalizedPath) ? path.resolve(normalizedPath) : path.resolve(projectRoot, normalizedPath)
152
+
153
+ // Ensure the resolved path is within the project root
151
154
  if (!fullPath.startsWith(resolvedRoot + path.sep) && fullPath !== resolvedRoot) {
152
155
  throw new Error(`Path traversal detected: ${filePath}`)
153
156
  }
@@ -184,3 +187,36 @@ export async function acquireFileLock(filePath: string): Promise<() => void> {
184
187
  release()
185
188
  }
186
189
  }
190
+
191
+ export { slugify } from './shared'
192
+
193
+ /**
194
+ * Type-safe check for Node.js system errors (ENOENT, EEXIST, etc.).
195
+ */
196
+ export function isNodeError(error: unknown, code: string): boolean {
197
+ return error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code
198
+ }
199
+
200
+ /**
201
+ * Escape HTML special characters to prevent injection.
202
+ * Covers &, <, >, ", and ' — the full set needed for both text content and attribute values.
203
+ */
204
+ export function escapeHtml(text: string): string {
205
+ return text
206
+ .replace(/&/g, '&amp;')
207
+ .replace(/</g, '&lt;')
208
+ .replace(/>/g, '&gt;')
209
+ .replace(/"/g, '&quot;')
210
+ .replace(/'/g, '&#39;')
211
+ }
212
+
213
+ /**
214
+ * Compute a POSIX-style relative import path from one file to another.
215
+ * Ensures the result starts with `./' or `../` and uses forward slashes.
216
+ */
217
+ export function relativeImportPath(fromFile: string, toFile: string): string {
218
+ const fromDir = path.dirname(fromFile)
219
+ let rel = path.relative(fromDir, toFile).split(path.sep).join('/')
220
+ if (!rel.startsWith('.')) rel = `./${rel}`
221
+ return rel
222
+ }