@nuasite/cms 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/editor.js +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +76 -2
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- 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, '&')
|
|
207
|
+
.replace(/</g, '<')
|
|
208
|
+
.replace(/>/g, '>')
|
|
209
|
+
.replace(/"/g, '"')
|
|
210
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|