@nuasite/cms 0.46.1 → 0.46.2

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 CHANGED
@@ -386,7 +386,7 @@ function IC(t, e) {
386
386
  function _C(t, e) {
387
387
  return typeof e == "function" ? e(t) : e;
388
388
  }
389
- const u5 = "0.46.1", h5 = u5, ct = {
389
+ const u5 = "0.46.2", h5 = u5, ct = {
390
390
  /** Highlight overlay for hovered elements */
391
391
  HIGHLIGHT: 2147483644,
392
392
  /** Hover outline for elements/components */
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.46.1",
17
+ "version": "0.46.2",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@nuasite/cms-core": "0.46.1",
30
- "@nuasite/cms-types": "0.46.1",
29
+ "@nuasite/cms-core": "0.46.2",
30
+ "@nuasite/cms-types": "0.46.2",
31
31
  "@astrojs/compiler": "^3.0.1",
32
32
  "@babel/parser": "^7.29.2",
33
33
  "node-html-parser": "^7.1.0",
@@ -35,8 +35,8 @@
35
35
  "yaml": "^2.8.3"
36
36
  },
37
37
  "devDependencies": {
38
- "@nuasite/cms-sidecar": "0.46.1",
39
- "@nuasite/collections-admin": "0.46.1",
38
+ "@nuasite/cms-sidecar": "0.46.2",
39
+ "@nuasite/collections-admin": "0.46.2",
40
40
  "@babel/types": "^7.29.0",
41
41
  "@types/react": "^19.2.7",
42
42
  "@types/react-dom": "^19.2.3",
@@ -76,8 +76,8 @@
76
76
  "typescript": "^6.0.2",
77
77
  "vite": "^7.0.0",
78
78
  "@aws-sdk/client-s3": "^3.0.0",
79
- "@nuasite/cms-sidecar": "0.46.1",
80
- "@nuasite/collections-admin": "0.46.1",
79
+ "@nuasite/cms-sidecar": "0.46.2",
80
+ "@nuasite/collections-admin": "0.46.2",
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0"
83
83
  },
@@ -21,6 +21,9 @@ let searchIndexInitialized = false
21
21
  /** Pre-built reverse index: normalizedText → SourceLocation[] (collection data files) */
22
22
  let collectionTextIndex: Map<string, SourceLocation[]> | null = null
23
23
 
24
+ /** Per-collection-directory index: declared page URL → file abs path — used by the same-slug URL-disambiguation fallback */
25
+ const declaredUrlIndexCache = new Map<string, Map<string, string>>()
26
+
24
27
  /** Lazy reverse index on i18n entries: translationKey → SearchIndexEntry[]. Rebuilt on demand after any mutation. */
25
28
  let translationKeyIndex: Map<string, SearchIndexEntry[]> | null = null
26
29
 
@@ -93,6 +96,10 @@ export function setCollectionTextIndex(index: Map<string, SourceLocation[]> | nu
93
96
  collectionTextIndex = index
94
97
  }
95
98
 
99
+ export function getDeclaredUrlIndexCache(): Map<string, Map<string, string>> {
100
+ return declaredUrlIndexCache
101
+ }
102
+
96
103
  // ============================================================================
97
104
  // Dirty File Tracking (incremental re-indexing)
98
105
  // ============================================================================
@@ -106,6 +113,10 @@ export function markFileDirty(absPath: string): void {
106
113
  dirtyFiles.add(absPath)
107
114
  // Also evict the parsed file cache so it's re-read from disk
108
115
  parsedFileCache.delete(absPath)
116
+ // A changed file may add/remove/alter a declared URL anywhere in its
117
+ // collection directory — cheaper to drop the whole cache than track
118
+ // per-directory membership for a rarely-hit index.
119
+ declaredUrlIndexCache.clear()
109
120
  }
110
121
 
111
122
  export function getDirtyFiles(): Set<string> {
@@ -155,4 +166,5 @@ export function clearSourceFinderCache(): void {
155
166
  searchIndexInitialized = false
156
167
  collectionTextIndex = null
157
168
  translationKeyIndex = null
169
+ declaredUrlIndexCache.clear()
158
170
  }
@@ -4,7 +4,7 @@ import { isMap, isPair, isScalar, isSeq, LineCounter, parseDocument } from 'yaml
4
4
 
5
5
  import { getProjectRoot } from '../config'
6
6
  import type { CollectionDefinition } from '../types'
7
- import { getCollectionTextIndex, getMarkdownFileCache, setCollectionTextIndex } from './cache'
7
+ import { getCollectionTextIndex, getDeclaredUrlIndexCache, getMarkdownFileCache, setCollectionTextIndex } from './cache'
8
8
  import { normalizeText } from './snippet-utils'
9
9
  import type { CollectionInfo, MarkdownContent, SourceLocation } from './types'
10
10
 
@@ -52,20 +52,10 @@ async function doBuildCollectionTextIndex(
52
52
  } else {
53
53
  // Markdown — index scalars from frontmatter only
54
54
  const { lines } = cached
55
- let fmStart = -1
56
- let fmEnd = -1
57
- for (let i = 0; i < lines.length; i++) {
58
- if (lines[i]?.trim() === '---') {
59
- if (fmStart === -1) fmStart = i
60
- else {
61
- fmEnd = i
62
- break
63
- }
64
- }
65
- }
66
- if (fmEnd > 0) {
67
- const yamlStr = lines.slice(fmStart + 1, fmEnd).join('\n')
68
- collectScalarsFromYaml(yamlStr, fmStart + 1, lines, info, index)
55
+ const bounds = findFrontmatterBounds(lines)
56
+ if (bounds) {
57
+ const yamlStr = lines.slice(bounds.start + 1, bounds.end).join('\n')
58
+ collectScalarsFromYaml(yamlStr, bounds.start + 1, lines, info, index)
69
59
  }
70
60
  }
71
61
  } catch {
@@ -187,6 +177,22 @@ export function lookupCollectionText(
187
177
  // Markdown File Cache
188
178
  // ============================================================================
189
179
 
180
+ /**
181
+ * Locate the `---`-delimited frontmatter block in a markdown file's lines.
182
+ * Returns the indexes of the opening and closing `---` lines, or undefined if
183
+ * the file has no closed frontmatter block.
184
+ */
185
+ function findFrontmatterBounds(lines: string[]): { start: number; end: number } | undefined {
186
+ let start = -1
187
+ for (let i = 0; i < lines.length; i++) {
188
+ if (lines[i]?.trim() === '---') {
189
+ if (start === -1) start = i
190
+ else return { start, end: i }
191
+ }
192
+ }
193
+ return undefined
194
+ }
195
+
190
196
  /**
191
197
  * Get cached markdown file content
192
198
  */
@@ -210,6 +216,17 @@ async function getCachedMarkdownFile(filePath: string): Promise<{ content: strin
210
216
  // Collection Source Finding
211
217
  // ============================================================================
212
218
 
219
+ /**
220
+ * Frontmatter fields, in preference order, that may declare an entry's own
221
+ * canonical page URL. Only site-absolute values (starting with `/`) are trusted
222
+ * — external `url: https://…` values and bare slugs are ignored. Deliberately
223
+ * excludes `canonical`/`canonicalUrl`: by SEO convention those declare the URL
224
+ * that should be indexed *instead of* the current page (duplicate-content
225
+ * consolidation), which can point at a different entry entirely — trusting it
226
+ * as self-identity could resolve an edit to the wrong file.
227
+ */
228
+ const DECLARED_URL_FIELDS = ['urlpath', 'permalink', 'pathname', 'route', 'url']
229
+
213
230
  /**
214
231
  * Find markdown collection file for a given page path.
215
232
  *
@@ -217,6 +234,14 @@ async function getCachedMarkdownFile(filePath: string): Promise<{ content: strin
217
234
  * matching entry regardless of the URL prefix. This supports localized or
218
235
  * renamed routes (e.g. `/aktuality/my-article` with content in `src/content/news/`).
219
236
  *
237
+ * Filename matching alone cannot tell apart two entries that share a slug but
238
+ * live under different URL prefixes (e.g. the same article slug published under
239
+ * two topic prefixes, where one file carries a disambiguating filename suffix).
240
+ * When a filename match declares a canonical URL in its frontmatter that
241
+ * contradicts the requested path, we fall back to matching entries by that
242
+ * declared URL. Projects whose entries declare no URL field keep the exact
243
+ * previous (filename-only) behavior.
244
+ *
220
245
  * @param pagePath - The URL path of the page (e.g., '/services/3d-tisk')
221
246
  * @param contentDir - The content directory (default: 'src/content')
222
247
  * @returns Collection info if found, undefined otherwise
@@ -233,6 +258,7 @@ export async function findCollectionSource(
233
258
  return undefined
234
259
  }
235
260
 
261
+ const requestedUrl = normalizeSitePath(`/${cleanPath}`)
236
262
  const contentPath = path.join(getProjectRoot(), contentDir)
237
263
 
238
264
  try {
@@ -245,9 +271,12 @@ export async function findCollectionSource(
245
271
  let collectionDirs: string[]
246
272
  try {
247
273
  const entries = await fs.readdir(contentPath, { withFileTypes: true })
274
+ // Sorted so match/resolution order is deterministic across runs and
275
+ // platforms, not dependent on readdir's unspecified enumeration order.
248
276
  collectionDirs = entries
249
277
  .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
250
278
  .map(e => e.name)
279
+ .sort()
251
280
  } catch {
252
281
  return undefined
253
282
  }
@@ -266,6 +295,20 @@ export async function findCollectionSource(
266
295
  }
267
296
  }
268
297
 
298
+ if (matches.length === 0) continue
299
+
300
+ // Prefer the entry whose declared canonical URL equals the requested
301
+ // path. Only kicks in when an entry actually declares a URL, so
302
+ // URL-less projects fall through to the filename logic unchanged.
303
+ const byUrl = await resolveByDeclaredUrl(matches, requestedUrl, contentPath)
304
+ if (byUrl) {
305
+ // byUrl.file may differ from the file the filename match found
306
+ // (that's the whole point of this fallback) — its slug must be
307
+ // derived from the actual resolved file, not the URL-tail slug
308
+ // candidate, or downstream collectionSlug lookups break.
309
+ return { name: byUrl.name, slug: slugFromFilePath(byUrl.file), file: path.relative(getProjectRoot(), byUrl.file) }
310
+ }
311
+
269
312
  if (matches.length === 1 && matches[0]) {
270
313
  return {
271
314
  name: matches[0].name,
@@ -291,6 +334,162 @@ export async function findCollectionSource(
291
334
  return undefined
292
335
  }
293
336
 
337
+ /** Normalize a site-absolute path: ensure a leading slash, drop query/hash and any trailing slash. */
338
+ function normalizeSitePath(p: string): string {
339
+ let s = p.split('?')[0]?.split('#')[0] ?? p
340
+ if (!s.startsWith('/')) s = `/${s}`
341
+ if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
342
+ return s
343
+ }
344
+
345
+ /**
346
+ * Derive a collection entry's slug from its file path, matching the same
347
+ * convention collection-scanner.ts uses: flat `<slug>.md(x)` files use the
348
+ * basename minus extension; Hugo-style `<slug>/index.md(x)` files use the
349
+ * parent directory name.
350
+ */
351
+ function slugFromFilePath(fileAbsPath: string): string {
352
+ const base = path.basename(fileAbsPath)
353
+ if (base === 'index.md' || base === 'index.mdx') {
354
+ return path.basename(path.dirname(fileAbsPath))
355
+ }
356
+ return base.replace(/\.mdx?$/, '')
357
+ }
358
+
359
+ /**
360
+ * Read an entry's declared canonical page URL from its frontmatter, if any.
361
+ * Returns the normalized site-absolute path, or undefined when the file has no
362
+ * frontmatter or declares no site-absolute URL field.
363
+ */
364
+ async function readDeclaredPageUrl(fileAbsPath: string): Promise<string | undefined> {
365
+ const cached = await getCachedMarkdownFile(fileAbsPath)
366
+ if (!cached) return undefined
367
+
368
+ const bounds = findFrontmatterBounds(cached.lines)
369
+ if (!bounds) return undefined
370
+
371
+ let doc
372
+ try {
373
+ doc = parseDocument(cached.lines.slice(bounds.start + 1, bounds.end).join('\n'))
374
+ } catch {
375
+ return undefined
376
+ }
377
+ if (!isMap(doc.contents)) return undefined
378
+
379
+ const found: Record<string, string> = {}
380
+ for (const pair of doc.contents.items) {
381
+ if (!isPair(pair) || !isScalar(pair.key) || !isScalar(pair.value)) continue
382
+ const key = String(pair.key.value).toLowerCase()
383
+ if (!DECLARED_URL_FIELDS.includes(key)) continue
384
+ const val = pair.value.value
385
+ if (typeof val === 'string' && val.startsWith('/')) {
386
+ found[key] ??= normalizeSitePath(val)
387
+ }
388
+ }
389
+
390
+ for (const field of DECLARED_URL_FIELDS) {
391
+ if (found[field]) return found[field]
392
+ }
393
+ return undefined
394
+ }
395
+
396
+ /**
397
+ * Resolve the correct entry for `requestedUrl` using declared canonical URLs.
398
+ *
399
+ * 1. If a filename candidate declares exactly `requestedUrl`, use it.
400
+ * 2. Otherwise, if any candidate declares *some* URL (so the collection is
401
+ * URL-aware) but none matches, the filename match is for a same-slug sibling
402
+ * under a different prefix — scan the candidate collection(s) for the file
403
+ * whose declared URL is `requestedUrl`.
404
+ * 3. If no candidate declares any URL, return undefined so the caller keeps the
405
+ * legacy filename behavior.
406
+ */
407
+ async function resolveByDeclaredUrl(
408
+ matches: { name: string; file: string }[],
409
+ requestedUrl: string,
410
+ contentPath: string,
411
+ ): Promise<{ name: string; file: string } | undefined> {
412
+ let sawDeclaredUrl = false
413
+ for (const m of matches) {
414
+ const declared = await readDeclaredPageUrl(m.file)
415
+ if (declared === undefined) continue
416
+ sawDeclaredUrl = true
417
+ if (declared === requestedUrl) return m
418
+ }
419
+
420
+ if (!sawDeclaredUrl) return undefined
421
+
422
+ // Contradiction: the right entry is named differently from its slug. Scan
423
+ // the collection(s) that produced filename matches for a declared-URL hit.
424
+ // `matches` (and thus this Set) is built by iterating the sorted
425
+ // `collectionDirs`, so directory order here is deterministic.
426
+ for (const dir of new Set(matches.map(m => m.name))) {
427
+ const hit = await findFileByDeclaredUrl(path.join(contentPath, dir), requestedUrl)
428
+ if (hit) return { name: dir, file: hit }
429
+ }
430
+ return undefined
431
+ }
432
+
433
+ /**
434
+ * Find the file in a collection directory whose declared canonical URL
435
+ * matches, via a per-directory URL→file index that's built once and cached
436
+ * (see `getDeclaredUrlIndexCache`) — only the first request for an ambiguous
437
+ * slug in a given directory pays for the full scan.
438
+ */
439
+ async function findFileByDeclaredUrl(collectionPathAbs: string, requestedUrl: string): Promise<string | undefined> {
440
+ const cache = getDeclaredUrlIndexCache()
441
+ let index = cache.get(collectionPathAbs)
442
+ if (!index) {
443
+ index = await buildDeclaredUrlIndex(collectionPathAbs)
444
+ cache.set(collectionPathAbs, index)
445
+ }
446
+ return index.get(requestedUrl)
447
+ }
448
+
449
+ /**
450
+ * Scan a collection directory (flat `*.md(x)` files and Hugo-style
451
+ * `<slug>/index.md(x)`) and index every entry by its declared canonical URL.
452
+ * Entries are visited in sorted order so that if two entries declare the same
453
+ * URL (a content bug), the winner is deterministic rather than readdir-order
454
+ * dependent.
455
+ */
456
+ async function buildDeclaredUrlIndex(collectionPathAbs: string): Promise<Map<string, string>> {
457
+ const index = new Map<string, string>()
458
+ let dirEntries
459
+ try {
460
+ dirEntries = await fs.readdir(collectionPathAbs, { withFileTypes: true })
461
+ } catch {
462
+ return index
463
+ }
464
+
465
+ const files = dirEntries
466
+ .filter(e => e.isFile() && /\.mdx?$/.test(e.name))
467
+ .map(e => e.name)
468
+ .sort()
469
+ for (const name of files) {
470
+ const file = path.join(collectionPathAbs, name)
471
+ const declared = await readDeclaredPageUrl(file)
472
+ if (declared && !index.has(declared)) index.set(declared, file)
473
+ }
474
+
475
+ const subDirs = dirEntries
476
+ .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
477
+ .map(e => e.name)
478
+ .sort()
479
+ for (const dir of subDirs) {
480
+ for (const idx of ['index.md', 'index.mdx']) {
481
+ const file = path.join(collectionPathAbs, dir, idx)
482
+ const declared = await readDeclaredPageUrl(file)
483
+ if (declared) {
484
+ if (!index.has(declared)) index.set(declared, file)
485
+ break
486
+ }
487
+ }
488
+ }
489
+
490
+ return index
491
+ }
492
+
294
493
  /**
295
494
  * Find a markdown file in a collection directory by slug
296
495
  */
@@ -370,23 +569,11 @@ export async function findMarkdownSourceLocation(
370
569
  const { lines } = cached
371
570
  const normalizedSearch = normalizeText(textContent)
372
571
 
373
- // Find frontmatter boundaries
374
- let frontmatterStart = -1
375
- let frontmatterEnd = -1
376
- for (let i = 0; i < lines.length; i++) {
377
- if (lines[i]?.trim() === '---') {
378
- if (frontmatterStart === -1) {
379
- frontmatterStart = i
380
- } else {
381
- frontmatterEnd = i
382
- break
383
- }
384
- }
385
- }
386
- if (frontmatterEnd <= 0) return undefined
572
+ const bounds = findFrontmatterBounds(lines)
573
+ if (!bounds) return undefined
387
574
 
388
- const yamlStr = lines.slice(frontmatterStart + 1, frontmatterEnd).join('\n')
389
- const lineOffset = frontmatterStart + 1
575
+ const yamlStr = lines.slice(bounds.start + 1, bounds.end).join('\n')
576
+ const lineOffset = bounds.start + 1
390
577
  return findScalarInYamlAst(yamlStr, lineOffset, normalizedSearch, lines, collectionInfo)
391
578
  } catch {
392
579
  // Error reading file
@@ -550,20 +737,10 @@ export async function findFieldInCollectionEntry(
550
737
 
551
738
  // For markdown, search inside frontmatter only
552
739
  const { lines } = cached
553
- let fmStart = -1
554
- let fmEnd = -1
555
- for (let i = 0; i < lines.length; i++) {
556
- if (lines[i]?.trim() === '---') {
557
- if (fmStart === -1) fmStart = i
558
- else {
559
- fmEnd = i
560
- break
561
- }
562
- }
563
- }
564
- if (fmEnd <= 0) return undefined
565
- const yamlStr = lines.slice(fmStart + 1, fmEnd).join('\n')
566
- return findFieldByNameInYaml(yamlStr, fmStart + 1, fieldName, lines, info)
740
+ const bounds = findFrontmatterBounds(lines)
741
+ if (!bounds) return undefined
742
+ const yamlStr = lines.slice(bounds.start + 1, bounds.end).join('\n')
743
+ return findFieldByNameInYaml(yamlStr, bounds.start + 1, fieldName, lines, info)
567
744
  } catch {
568
745
  return undefined
569
746
  }
@@ -598,20 +775,10 @@ export async function findFieldsInCollectionEntry(
598
775
 
599
776
  // For markdown, search inside frontmatter only
600
777
  const { lines } = cached
601
- let fmStart = -1
602
- let fmEnd = -1
603
- for (let i = 0; i < lines.length; i++) {
604
- if (lines[i]?.trim() === '---') {
605
- if (fmStart === -1) fmStart = i
606
- else {
607
- fmEnd = i
608
- break
609
- }
610
- }
611
- }
612
- if (fmEnd <= 0) return new Map()
613
- const yamlStr = lines.slice(fmStart + 1, fmEnd).join('\n')
614
- return findFieldsByNameInYaml(yamlStr, fmStart + 1, fieldNames, lines, info)
778
+ const bounds = findFrontmatterBounds(lines)
779
+ if (!bounds) return new Map()
780
+ const yamlStr = lines.slice(bounds.start + 1, bounds.end).join('\n')
781
+ return findFieldsByNameInYaml(yamlStr, bounds.start + 1, fieldNames, lines, info)
615
782
  } catch {
616
783
  return new Map()
617
784
  }
@@ -695,27 +862,12 @@ export async function parseMarkdownContent(
695
862
 
696
863
  const { lines } = cached
697
864
 
698
- // Parse frontmatter
699
- let frontmatterStart = -1
700
- let frontmatterEnd = -1
701
-
702
- for (let i = 0; i < lines.length; i++) {
703
- const line = lines[i]?.trim()
704
- if (line === '---') {
705
- if (frontmatterStart === -1) {
706
- frontmatterStart = i
707
- } else {
708
- frontmatterEnd = i
709
- break
710
- }
711
- }
712
- }
713
-
865
+ const bounds = findFrontmatterBounds(lines)
714
866
  const frontmatter: Record<string, { value: string; line: number }> = {}
715
867
 
716
868
  // Extract frontmatter fields using yaml parser
717
- if (frontmatterEnd > 0) {
718
- const yamlStr = lines.slice(frontmatterStart + 1, frontmatterEnd).join('\n')
869
+ if (bounds) {
870
+ const yamlStr = lines.slice(bounds.start + 1, bounds.end).join('\n')
719
871
  const lineCounter = new LineCounter()
720
872
  const doc = parseDocument(yamlStr, { lineCounter })
721
873
 
@@ -726,7 +878,7 @@ export async function parseMarkdownContent(
726
878
  const value = isScalar(pair.value) ? String(pair.value.value) : ''
727
879
  const keyRange = (pair.key as any).range
728
880
  const yamlLine = keyRange ? lineCounter.linePos(keyRange[0]).line : 0
729
- const fileLine = yamlLine + frontmatterStart + 1
881
+ const fileLine = yamlLine + bounds.start + 1
730
882
  if (key && value) {
731
883
  frontmatter[key] = { value, line: fileLine }
732
884
  }
@@ -736,7 +888,7 @@ export async function parseMarkdownContent(
736
888
  }
737
889
 
738
890
  // Extract body (everything after frontmatter)
739
- const bodyStartLine = frontmatterEnd > 0 ? frontmatterEnd + 1 : 0
891
+ const bodyStartLine = bounds ? bounds.end + 1 : 0
740
892
  const bodyLines = lines.slice(bodyStartLine)
741
893
  const body = bodyLines.join('\n').trim()
742
894