@nuasite/cms 0.32.0 → 0.35.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.
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { pickAstroImageTarget } from '../astro-image-paths'
4
+ import type { ManifestWriter } from '../manifest-writer'
5
+ import type { MediaUploadContext } from '../types'
6
+ import { resolveSourcePath } from '../utils'
7
+
8
+ export interface AstroImageUploadResult {
9
+ success: true
10
+ url: string
11
+ filename: string
12
+ }
13
+
14
+ export interface AstroImageUploadError {
15
+ success: false
16
+ error: string
17
+ }
18
+
19
+ /**
20
+ * Attempt to route an upload to an Astro `image()` field's entry-relative location.
21
+ * Returns null when the context doesn't match an `astroImage` field — caller should
22
+ * fall back to the regular media-adapter upload flow.
23
+ */
24
+ export async function tryAstroImageUpload(args: {
25
+ context: Partial<MediaUploadContext>
26
+ manifestWriter: ManifestWriter
27
+ fileBuffer: Buffer
28
+ originalFilename: string
29
+ }): Promise<AstroImageUploadResult | AstroImageUploadError | null> {
30
+ const { collection, entry, field } = args.context
31
+ if (!collection || !entry || !field) return null
32
+
33
+ const definitions = args.manifestWriter.getCollectionDefinitions()
34
+ const def = definitions[collection]
35
+ if (!def) return null
36
+
37
+ const fieldDef = def.fields.find(f => f.name === field)
38
+ if (!fieldDef?.astroImage) return null
39
+
40
+ const entryInfo = def.entries?.find(e => e.slug === entry)
41
+ if (!entryInfo) {
42
+ return { success: false, error: `Entry not found: ${collection}/${entry}` }
43
+ }
44
+
45
+ const target = await pickAstroImageTarget({
46
+ entryAbsPath: resolveSourcePath(entryInfo.sourcePath),
47
+ slug: entryInfo.slug,
48
+ originalFilename: args.originalFilename,
49
+ compareBuffer: args.fileBuffer,
50
+ })
51
+
52
+ await fs.mkdir(path.dirname(target.absPath), { recursive: true })
53
+ await fs.writeFile(target.absPath, args.fileBuffer)
54
+
55
+ return {
56
+ success: true,
57
+ url: target.relPath,
58
+ filename: path.basename(target.absPath),
59
+ }
60
+ }
package/src/index.ts CHANGED
@@ -371,6 +371,7 @@ export type { Color, Date, DateTime, Email, Image, Reference, Textarea, Time, Ur
371
371
 
372
372
  export { scanCollections } from './collection-scanner'
373
373
  export { getProjectRoot, resetProjectRoot, setProjectRoot } from './config'
374
+ export { type FieldMigration, migrateAstroImages, type MigrationOptions, type MigrationResult } from './migrate-astro-image'
374
375
  export { rehypeCmsMarker } from './rehype-cms-marker'
375
376
  export type { CollectionInfo, MarkdownContent, SourceLocation, VariableReference } from './source-finder'
376
377
  export { findCollectionSource, parseMarkdownContent } from './source-finder'
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { parseDocument } from 'yaml'
4
+ import { pickAstroImageTarget } from './astro-image-paths'
5
+ import { scanCollections } from './collection-scanner'
6
+ import { getProjectRoot } from './config'
7
+
8
+ export interface MigrationOptions {
9
+ /** When true, report what would change without writing anything. */
10
+ dryRun?: boolean
11
+ /** Override the project root (mostly for tests). Defaults to getProjectRoot(). */
12
+ projectRoot?: string
13
+ }
14
+
15
+ export interface FieldMigration {
16
+ entrySourcePath: string
17
+ fieldName: string
18
+ originalValue: string
19
+ newValue: string
20
+ copiedFrom: string
21
+ copiedTo: string
22
+ }
23
+
24
+ export interface MigrationResult {
25
+ migrations: FieldMigration[]
26
+ skipped: Array<{ entrySourcePath: string; fieldName: string; reason: string }>
27
+ }
28
+
29
+ const FRONTMATTER_RE = /^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)/
30
+
31
+ /** Idempotent — values that are already relative or that don't resolve to a public file are skipped. */
32
+ export async function migrateAstroImages(options: MigrationOptions = {}): Promise<MigrationResult> {
33
+ const projectRoot = options.projectRoot ?? getProjectRoot()
34
+ const dryRun = options.dryRun ?? false
35
+
36
+ const collections = await scanCollections()
37
+ const migrations: FieldMigration[] = []
38
+ const skipped: MigrationResult['skipped'] = []
39
+
40
+ for (const def of Object.values(collections)) {
41
+ const astroFields = def.fields.filter(f => f.astroImage)
42
+ if (astroFields.length === 0 || !def.entries) continue
43
+
44
+ for (const entry of def.entries) {
45
+ const entryAbs = path.isAbsolute(entry.sourcePath)
46
+ ? entry.sourcePath
47
+ : path.join(projectRoot, entry.sourcePath)
48
+
49
+ let raw: string
50
+ try {
51
+ raw = await fs.readFile(entryAbs, 'utf-8')
52
+ } catch {
53
+ skipped.push({ entrySourcePath: entry.sourcePath, fieldName: '*', reason: 'read failed' })
54
+ continue
55
+ }
56
+
57
+ const fmMatch = raw.match(FRONTMATTER_RE)
58
+ if (!fmMatch) {
59
+ skipped.push({ entrySourcePath: entry.sourcePath, fieldName: '*', reason: 'no frontmatter' })
60
+ continue
61
+ }
62
+ const [fullFm, fmStart, yamlBody, fmEnd] = fmMatch as unknown as [string, string, string, string]
63
+ const doc = parseDocument(yamlBody)
64
+
65
+ let mutated = false
66
+ for (const field of astroFields) {
67
+ const current = doc.get(field.name)
68
+ if (typeof current !== 'string') {
69
+ if (current != null) skipped.push({ entrySourcePath: entry.sourcePath, fieldName: field.name, reason: 'non-string value' })
70
+ continue
71
+ }
72
+ if (!current.startsWith('/') || current.startsWith('//')) continue
73
+
74
+ const sourceAbs = path.join(projectRoot, 'public', current.replace(/^\/+/, ''))
75
+ let sourceBuf: Buffer
76
+ try {
77
+ sourceBuf = await fs.readFile(sourceAbs)
78
+ } catch {
79
+ skipped.push({ entrySourcePath: entry.sourcePath, fieldName: field.name, reason: `source missing: ${sourceAbs}` })
80
+ continue
81
+ }
82
+
83
+ const target = await pickAstroImageTarget({
84
+ entryAbsPath: entryAbs,
85
+ slug: entry.slug,
86
+ originalFilename: path.basename(current),
87
+ compareBuffer: sourceBuf,
88
+ })
89
+
90
+ if (!dryRun) {
91
+ await fs.mkdir(path.dirname(target.absPath), { recursive: true })
92
+ await fs.writeFile(target.absPath, sourceBuf)
93
+ }
94
+
95
+ doc.set(field.name, target.relPath)
96
+ mutated = true
97
+ migrations.push({
98
+ entrySourcePath: entry.sourcePath,
99
+ fieldName: field.name,
100
+ originalValue: current,
101
+ newValue: target.relPath,
102
+ copiedFrom: sourceAbs,
103
+ copiedTo: target.absPath,
104
+ })
105
+ }
106
+
107
+ if (mutated && !dryRun) {
108
+ const newYaml = doc.toString().replace(/\n$/, '')
109
+ const newRaw = raw.replace(fullFm, `${fmStart}${newYaml}${fmEnd}`)
110
+ await fs.writeFile(entryAbs, newRaw, 'utf-8')
111
+ }
112
+ }
113
+ }
114
+
115
+ return { migrations, skipped }
116
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { parse as parseYaml } from 'yaml'
4
4
 
5
+ import { isHugoStyleEntry } from '../astro-image-paths'
5
6
  import { getProjectRoot } from '../config'
6
7
  import type { Attribute, CollectionDefinition, ManifestEntry } from '../types'
7
8
  import { escapeRegex, generateSourceHash, resolveSourcePath } from '../utils'
@@ -702,10 +703,34 @@ export async function enhanceManifestWithSourceSnippets(
702
703
  return result
703
704
  }
704
705
 
706
+ // Built lazily on first need — most entry sets don't have any astroImage <Image> tags
707
+ // rendered outside `<Content />`, in which case we never iterate the collections.
708
+ let astroImageIndex: AstroImageCollectionIndex | undefined
709
+ const getAstroImageIndex = () => {
710
+ if (!astroImageIndex && collectionDefinitions) {
711
+ astroImageIndex = buildAstroImageCollectionIndex(collectionDefinitions)
712
+ }
713
+ return astroImageIndex
714
+ }
715
+
705
716
  // Process entries in parallel for better performance
706
717
  const entryPromises = Object.entries(entries).map(async ([id, entry]) => {
707
718
  // Handle image entries specially - find the line with src attribute
708
719
  if (entry.imageMetadata?.src) {
720
+ // Astro `image()` URLs (`/_image?href=/@fs/.../src/content/<collection>/<...>`)
721
+ // embed the source path. If we can match that path to a collection entry's
722
+ // directory, treat this image as belonging to that entry — even when no
723
+ // ancestor was tagged as the collection wrapper (e.g. a `<Image>` rendered
724
+ // outside `<Content />`).
725
+ if (!entry.collectionName) {
726
+ const index = getAstroImageIndex()
727
+ const inferred = index && inferCollectionFromAstroImageUrl(entry.imageMetadata.src, index)
728
+ if (inferred) {
729
+ entry.collectionName = inferred.collectionName
730
+ entry.collectionSlug = inferred.collectionSlug
731
+ }
732
+ }
733
+
709
734
  // ── Collection images: resolve directly from the data file ──
710
735
  // When an image belongs to a known collection entry, bypass the search index
711
736
  // entirely. Astro hashes image filenames (e.g. ./photo.jpg → /assets/a1b2c3.webp),
@@ -1148,14 +1173,15 @@ async function resolveCollectionImageField(
1148
1173
 
1149
1174
  // Single image field — unambiguous
1150
1175
  if (imageFields.length === 1) {
1176
+ const fieldName = imageFields[0]!.name
1151
1177
  const fieldResult = await findFieldInCollectionEntry(
1152
- imageFields[0]!.name,
1178
+ fieldName,
1153
1179
  entry.collectionName!,
1154
1180
  entry.collectionSlug!,
1155
1181
  collectionDefinitions,
1156
1182
  )
1157
1183
  if (fieldResult) {
1158
- return applyCollectionSource(entry, fieldResult, referenceIndex)
1184
+ return applyCollectionSource(entry, fieldResult, referenceIndex, { collectionFieldName: fieldName })
1159
1185
  }
1160
1186
  return undefined
1161
1187
  }
@@ -1169,12 +1195,12 @@ async function resolveCollectionImageField(
1169
1195
  collectionDefinitions,
1170
1196
  )
1171
1197
 
1172
- let firstFieldResult: SourceLocation | undefined
1198
+ let firstField: { name: string; result: SourceLocation } | undefined
1173
1199
  for (const field of imageFields) {
1174
1200
  const fieldResult = allResults.get(field.name)
1175
1201
  if (!fieldResult?.snippet) continue
1176
1202
 
1177
- firstFieldResult ??= fieldResult
1203
+ firstField ??= { name: field.name, result: fieldResult }
1178
1204
 
1179
1205
  try {
1180
1206
  const cleaned = fieldResult.snippet.replace(/,\s*$/, '')
@@ -1182,7 +1208,7 @@ async function resolveCollectionImageField(
1182
1208
  if (parsed && typeof parsed === 'object') {
1183
1209
  const value = (parsed as Record<string, unknown>)[field.name]
1184
1210
  if (typeof value === 'string' && (value === imgSrc || imgSrc.includes(value) || value.includes(imgSrc))) {
1185
- return applyCollectionSource(entry, fieldResult, referenceIndex)
1211
+ return applyCollectionSource(entry, fieldResult, referenceIndex, { collectionFieldName: field.name })
1186
1212
  }
1187
1213
  }
1188
1214
  } catch {
@@ -1191,8 +1217,8 @@ async function resolveCollectionImageField(
1191
1217
  }
1192
1218
 
1193
1219
  // No value match — fall back to first resolved image field
1194
- if (firstFieldResult) {
1195
- return applyCollectionSource(entry, firstFieldResult, referenceIndex)
1220
+ if (firstField) {
1221
+ return applyCollectionSource(entry, firstField.result, referenceIndex, { collectionFieldName: firstField.name })
1196
1222
  }
1197
1223
 
1198
1224
  return undefined
@@ -1401,16 +1427,93 @@ async function resolveImageExpression(
1401
1427
  }
1402
1428
 
1403
1429
  /**
1404
- * Extract the original image path from an Astro Image optimization URL.
1405
- * Astro's `<Image>` component rewrites src to `/_image?href=%2Fpath.jpg&w=...` in dev.
1406
- * Returns the decoded `href` param, or undefined if the URL isn't an Astro image URL.
1430
+ * Per-call index over collection entries with at least one `astroImage` field.
1431
+ * Built once per `enhanceEntries` invocation so each manifest entry's lookup is O(1)
1432
+ * for flat entries and O(hugoEntries) for hugo-style.
1433
+ */
1434
+ export interface AstroImageCollectionIndex {
1435
+ /** Flat-md entries grouped by their parent directory (lookup by `path.dirname(href)`). */
1436
+ flatByDir: Map<string, Array<{ slug: string; coll: string }>>
1437
+ /** Hugo-style entries — image lives under entry directory, identified by prefix match. */
1438
+ hugoEntries: Array<{ dir: string; slug: string; coll: string }>
1439
+ }
1440
+
1441
+ export function buildAstroImageCollectionIndex(
1442
+ collectionDefinitions: Record<string, CollectionDefinition>,
1443
+ ): AstroImageCollectionIndex {
1444
+ const flatByDir = new Map<string, Array<{ slug: string; coll: string }>>()
1445
+ const hugoEntries: Array<{ dir: string; slug: string; coll: string }> = []
1446
+ for (const def of Object.values(collectionDefinitions)) {
1447
+ if (!def.entries || !def.fields.some(f => f.astroImage)) continue
1448
+ for (const entry of def.entries) {
1449
+ const entryAbs = resolveSourcePath(entry.sourcePath)
1450
+ const entryDir = path.dirname(entryAbs)
1451
+ if (isHugoStyleEntry(entryAbs)) {
1452
+ hugoEntries.push({ dir: entryDir, slug: entry.slug, coll: def.name })
1453
+ } else {
1454
+ let list = flatByDir.get(entryDir)
1455
+ if (!list) {
1456
+ list = []
1457
+ flatByDir.set(entryDir, list)
1458
+ }
1459
+ list.push({ slug: entry.slug, coll: def.name })
1460
+ }
1461
+ }
1462
+ }
1463
+ return { flatByDir, hugoEntries }
1464
+ }
1465
+
1466
+ /**
1467
+ * Match an Astro dev image URL (`/_image?href=/@fs/...`) to a collection entry by
1468
+ * locating the `href` source path under that entry's directory. Used when an
1469
+ * `<Image>` is rendered outside `<Content />` and so doesn't inherit collection
1470
+ * info from the markdown wrapper.
1471
+ */
1472
+ export function inferCollectionFromAstroImageUrl(
1473
+ src: string,
1474
+ index: AstroImageCollectionIndex,
1475
+ ): { collectionName: string; collectionSlug: string } | undefined {
1476
+ const href = extractAstroImageOriginalUrl(src)
1477
+ if (!href) return undefined
1478
+
1479
+ // `/@fs/<absolute-path>` → strip the prefix to get the real filesystem path.
1480
+ let absHref = href
1481
+ if (absHref.startsWith('/@fs/')) absHref = absHref.slice('/@fs'.length)
1482
+ try {
1483
+ absHref = decodeURIComponent(absHref)
1484
+ } catch {
1485
+ // Already decoded
1486
+ }
1487
+
1488
+ const flatList = index.flatByDir.get(path.dirname(absHref))
1489
+ if (flatList) {
1490
+ const hrefBase = path.basename(absHref)
1491
+ for (const { slug, coll } of flatList) {
1492
+ if (hrefBase.startsWith(`${slug}-`)) return { collectionName: coll, collectionSlug: slug }
1493
+ }
1494
+ }
1495
+ for (const { dir, slug, coll } of index.hugoEntries) {
1496
+ if (absHref.startsWith(dir + path.sep)) return { collectionName: coll, collectionSlug: slug }
1497
+ }
1498
+ return undefined
1499
+ }
1500
+
1501
+ /**
1502
+ * Extract the original image path from a dev-mode optimized image URL.
1503
+ * Recognizes:
1504
+ * - Astro's `<Image>`: `/_image?href=%2Fpath.jpg&w=...` → `href` param
1505
+ * - astro-imagetools / vite-imagetools: `/@image/<hash>.<ext>?f=<abs-path>&...` → `f` param
1407
1506
  */
1408
1507
  export function extractAstroImageOriginalUrl(src: string): string | undefined {
1409
1508
  try {
1410
1509
  const url = new URL(src, 'http://localhost')
1411
1510
  if (url.pathname === '/_image' || url.pathname.startsWith('/_image/')) {
1412
1511
  const href = url.searchParams.get('href')
1413
- if (href && href !== src) return href
1512
+ if (href) return href
1513
+ }
1514
+ if (url.pathname.startsWith('/@image/')) {
1515
+ const f = url.searchParams.get('f')
1516
+ if (f) return f
1414
1517
  }
1415
1518
  } catch {
1416
1519
  // Not a valid URL
package/src/types.ts CHANGED
@@ -72,6 +72,13 @@ export interface ImageMetadata {
72
72
  sizes?: string
73
73
  }
74
74
 
75
+ /** Identifies the (collection, entry, field) destination for an editor upload. */
76
+ export interface MediaUploadContext {
77
+ collection: string
78
+ entry: string
79
+ field: string
80
+ }
81
+
75
82
  /** Content constraints for validation */
76
83
  export interface ContentConstraints {
77
84
  /** Maximum content length */
@@ -158,6 +165,8 @@ export interface ManifestEntry {
158
165
  collectionName?: string
159
166
  /** Entry slug for collection entries (e.g., '3d-tisk') */
160
167
  collectionSlug?: string
168
+ /** Schema field name when this entry was resolved to a specific collection field (e.g., 'image', 'cover'). */
169
+ collectionFieldName?: string
161
170
  /** Path to the markdown content file (e.g., 'src/content/blog/my-post.md') */
162
171
  contentPath?: string
163
172
 
@@ -285,6 +294,8 @@ export interface FieldDefinition {
285
294
  derivedFrom?: string
286
295
  /** Editor hints for enhanced field rendering */
287
296
  hints?: FieldHints
297
+ /** True when the field uses Astro's `image()` schema (entry-relative paths through astro:assets). */
298
+ astroImage?: boolean
288
299
  }
289
300
 
290
301
  /** Per-entry metadata for collection browsing */