@nuasite/cms 0.32.0 → 0.34.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 +4177 -4144
- package/package.json +2 -1
- package/src/astro-image-paths.ts +74 -0
- package/src/collection-scanner.ts +55 -334
- package/src/content-config-ast.ts +356 -0
- package/src/editor/components/frontmatter-fields.tsx +14 -3
- package/src/editor/components/frontmatter-sidebar.tsx +2 -0
- package/src/editor/components/image-overlay.tsx +16 -7
- package/src/editor/components/media-library.tsx +12 -1
- package/src/editor/manifest.ts +37 -1
- package/src/editor/markdown-api.ts +5 -1
- package/src/editor/signals.ts +4 -0
- package/src/editor/types.ts +4 -1
- package/src/handlers/api-routes.ts +22 -2
- package/src/handlers/astro-image-upload.ts +60 -0
- package/src/index.ts +1 -0
- package/src/migrate-astro-image.ts +116 -0
- package/src/source-finder/snippet-utils.ts +105 -7
- package/src/types.ts +11 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1195
|
-
return applyCollectionSource(entry,
|
|
1220
|
+
if (firstField) {
|
|
1221
|
+
return applyCollectionSource(entry, firstField.result, referenceIndex, { collectionFieldName: firstField.name })
|
|
1196
1222
|
}
|
|
1197
1223
|
|
|
1198
1224
|
return undefined
|
|
@@ -1400,6 +1426,78 @@ async function resolveImageExpression(
|
|
|
1400
1426
|
return undefined
|
|
1401
1427
|
}
|
|
1402
1428
|
|
|
1429
|
+
/**
|
|
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
|
+
|
|
1403
1501
|
/**
|
|
1404
1502
|
* Extract the original image path from an Astro Image optimization URL.
|
|
1405
1503
|
* Astro's `<Image>` component rewrites src to `/_image?href=%2Fpath.jpg&w=...` in dev.
|
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 */
|