@nuasite/cms 0.28.0 → 0.30.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.
Files changed (37) hide show
  1. package/dist/editor.js +12447 -12473
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +69 -35
  4. package/src/dev-middleware.ts +86 -45
  5. package/src/editor/components/attribute-editor.tsx +2 -10
  6. package/src/editor/components/bg-image-overlay.tsx +2 -10
  7. package/src/editor/components/collections-browser.tsx +8 -24
  8. package/src/editor/components/color-toolbar.tsx +2 -9
  9. package/src/editor/components/confirm-dialog.tsx +4 -12
  10. package/src/editor/components/create-page-modal.tsx +23 -19
  11. package/src/editor/components/fields.tsx +158 -124
  12. package/src/editor/components/frontmatter-fields.tsx +9 -1
  13. package/src/editor/components/link-edit-popover.tsx +3 -6
  14. package/src/editor/components/markdown-editor-overlay.tsx +44 -46
  15. package/src/editor/components/markdown-inline-editor.tsx +2 -1
  16. package/src/editor/components/mdx-block-view.tsx +1 -0
  17. package/src/editor/components/mdx-component-picker.tsx +3 -6
  18. package/src/editor/components/media-library.tsx +15 -37
  19. package/src/editor/components/modal-shell.tsx +34 -5
  20. package/src/editor/components/prop-editor.tsx +77 -73
  21. package/src/editor/components/reference-picker.tsx +6 -24
  22. package/src/editor/components/seo-editor.tsx +4 -10
  23. package/src/editor/components/spinner.tsx +17 -0
  24. package/src/editor/components/toolbar.tsx +2 -1
  25. package/src/editor/constants.ts +33 -0
  26. package/src/editor/hooks/index.ts +4 -0
  27. package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
  28. package/src/editor/hooks/useSearchFilter.ts +21 -0
  29. package/src/field-types.ts +2 -0
  30. package/src/handlers/api-routes.ts +10 -16
  31. package/src/html-processor.ts +75 -94
  32. package/src/index.ts +5 -0
  33. package/src/manifest-writer.ts +15 -0
  34. package/src/rehype-cms-marker.ts +15 -0
  35. package/src/types.ts +1 -0
  36. package/src/vite-plugin.ts +18 -72
  37. package/src/content-invalidator.ts +0 -134
@@ -0,0 +1,43 @@
1
+ import { useEffect, useRef } from 'preact/hooks'
2
+
3
+ /**
4
+ * Dismiss handler for floating UI: closes on outside mousedown or Escape key.
5
+ * Pass all refs that should be considered "inside" (e.g. panel + trigger).
6
+ *
7
+ * Uses composedPath() so it works correctly inside Shadow DOM, and registers
8
+ * in the capture phase so stopPropagation() in bubble-phase handlers
9
+ * (e.g. modal overlays) doesn't block detection.
10
+ */
11
+ export function useClickOutsideEscape(
12
+ refs: ReadonlyArray<{ readonly current: HTMLElement | null }>,
13
+ isOpen: boolean,
14
+ onClose: () => void,
15
+ ): void {
16
+ // Store refs and onClose in a ref so the effect never needs to re-register
17
+ // when the caller creates a new array (e.g. from spreading exemptRefs).
18
+ // The actual .current values of each ref are read at event time, not capture time.
19
+ const stableRefs = useRef(refs)
20
+ stableRefs.current = refs
21
+ const stableOnClose = useRef(onClose)
22
+ stableOnClose.current = onClose
23
+
24
+ useEffect(() => {
25
+ if (!isOpen) return
26
+ const onMouseDown = (e: MouseEvent) => {
27
+ const path = e.composedPath()
28
+ for (const ref of stableRefs.current) {
29
+ if (ref.current && path.includes(ref.current)) return
30
+ }
31
+ stableOnClose.current()
32
+ }
33
+ const onKeyDown = (e: KeyboardEvent) => {
34
+ if (e.key === 'Escape') stableOnClose.current()
35
+ }
36
+ document.addEventListener('mousedown', onMouseDown, true)
37
+ document.addEventListener('keydown', onKeyDown)
38
+ return () => {
39
+ document.removeEventListener('mousedown', onMouseDown, true)
40
+ document.removeEventListener('keydown', onKeyDown)
41
+ }
42
+ }, [isOpen])
43
+ }
@@ -0,0 +1,21 @@
1
+ import { useMemo, useRef } from 'preact/hooks'
2
+
3
+ /**
4
+ * Filter a list of items by a search query.
5
+ * The `getSearchableText` callback returns the text to match against (e.g. `o => \`${o.label} ${o.value}\``).
6
+ * Uses a ref internally so callers don't need to memoize the callback.
7
+ */
8
+ export function useSearchFilter<T>(
9
+ items: T[],
10
+ query: string,
11
+ getSearchableText: (item: T) => string,
12
+ ): T[] {
13
+ const fnRef = useRef(getSearchableText)
14
+ fnRef.current = getSearchableText
15
+
16
+ return useMemo(() => {
17
+ if (!query) return items
18
+ const q = query.toLowerCase()
19
+ return items.filter(item => fnRef.current(item).toLowerCase().includes(q))
20
+ }, [items, query])
21
+ }
@@ -112,6 +112,8 @@ export const n = {
112
112
  url: (hints?: TextHints) => stringField('url', hints),
113
113
  /** Email input */
114
114
  email: (hints?: TextHints) => stringField('email', hints),
115
+ /** Phone number input */
116
+ tel: (hints?: TextHints) => stringField('tel', hints),
115
117
  /** Color picker */
116
118
  color: () => withOrderBy(z.string().describe('cms:color')),
117
119
  /** Date picker (handles YAML Date coercion → ISO date string). Accepts hints for the scanner; no Zod validation applied. */
@@ -21,15 +21,6 @@ export interface RouteContext {
21
21
  manifestWriter: ManifestWriter
22
22
  contentDir: string
23
23
  mediaAdapter?: MediaStorageAdapter
24
- /**
25
- * Triggered after a content file (markdown / data collection) is written so
26
- * the dev middleware can synchronously refresh Astro's content layer and
27
- * invalidate Vite's SSR module cache before responding to the client.
28
- *
29
- * Awaiting this is important: returning success before the cache is fresh
30
- * causes the editor to reload the page into a stale render.
31
- */
32
- notifyContentChanged?: (filePath: string) => Promise<void>
33
24
  }
34
25
 
35
26
  type RouteHandler = (ctx: RouteContext) => Promise<void>
@@ -113,16 +104,20 @@ const routeMap = new Map<string, RouteHandler>([
113
104
  }
114
105
  sendJson(res, result)
115
106
  }),
116
- custom('POST', 'markdown/update', async ({ req, res, manifestWriter, notifyContentChanged }) => {
107
+ custom('POST', 'markdown/update', async ({ req, res, manifestWriter }) => {
117
108
  const body = await parseJsonBody<Parameters<typeof handleUpdateMarkdown>[0]>(req)
118
109
  const result = await handleUpdateMarkdown(body, manifestWriter.getComponentDefinitions())
119
- if (result.success && notifyContentChanged) {
120
- await notifyContentChanged(body.filePath)
121
- }
122
110
  sendJson(res, result)
123
111
  }),
124
112
  post('markdown/rename', (body: Parameters<typeof handleRenameMarkdown>[0]) => handleRenameMarkdown(body)),
125
- postWithStatus('markdown/create', (body: Parameters<typeof handleCreateMarkdown>[0]) => handleCreateMarkdown(body)),
113
+ custom('POST', 'markdown/create', async ({ req, res, manifestWriter, contentDir }) => {
114
+ const body = await parseJsonBody<Parameters<typeof handleCreateMarkdown>[0]>(req)
115
+ const result = await handleCreateMarkdown(body)
116
+ if (result.success) {
117
+ manifestWriter.setCollectionDefinitions(await scanCollections(contentDir))
118
+ }
119
+ sendJson(res, result, result.success ? 200 : 400)
120
+ }),
126
121
  custom('POST', 'markdown/delete', async ({ req, res, manifestWriter, contentDir }) => {
127
122
  const body = await parseJsonBody<Parameters<typeof handleDeleteMarkdown>[0]>(req)
128
123
  const fullPath = path.resolve(getProjectRoot(), body.filePath?.replace(/^\//, '') ?? '')
@@ -238,9 +233,8 @@ export async function handleCmsApiRoute(
238
233
  manifestWriter: ManifestWriter,
239
234
  contentDir: string,
240
235
  mediaAdapter?: MediaStorageAdapter,
241
- notifyContentChanged?: (filePath: string) => Promise<void>,
242
236
  ): Promise<void> {
243
- const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter, notifyContentChanged }
237
+ const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter }
244
238
 
245
239
  // Exact match lookup
246
240
  const handler = routeMap.get(`${req.method}:${route}`)
@@ -17,6 +17,35 @@ import { generateStableId } from './utils'
17
17
  /** Type for parsed HTML element nodes from node-html-parser */
18
18
  type HTMLNode = ParsedHTMLElement
19
19
 
20
+ /** Check whether any ancestor of `node` (inclusive) has `data-astro-source-file`. */
21
+ function hasAncestorSourceFile(node: HTMLNode): boolean {
22
+ let current: HTMLNode | null = node
23
+ while (current) {
24
+ if (current.getAttribute?.('data-astro-source-file')) return true
25
+ current = current.parentNode as HTMLNode | null
26
+ }
27
+ return false
28
+ }
29
+
30
+ /** Walk ancestors of `node` (inclusive) to find the nearest source file and line. */
31
+ function findAncestorSourceLocation(node: HTMLNode): { sourceFile?: string; sourceLine?: number } {
32
+ let current: HTMLNode | null = node
33
+ while (current) {
34
+ const file = current.getAttribute?.('data-astro-source-file')
35
+ if (file) {
36
+ const line = current.getAttribute?.('data-astro-source-loc') || current.getAttribute?.('data-astro-source-line')
37
+ let sourceLine: number | undefined
38
+ if (line) {
39
+ const parsed = parseInt(line.split(':')[0] ?? '1', 10)
40
+ if (!Number.isNaN(parsed)) sourceLine = parsed
41
+ }
42
+ return { sourceFile: file, sourceLine }
43
+ }
44
+ current = current.parentNode as HTMLNode | null
45
+ }
46
+ return {}
47
+ }
48
+
20
49
  /**
21
50
  * Inline text styling elements that should NOT be marked with CMS IDs.
22
51
  * These elements are text formatting and should be part of their parent's content.
@@ -434,52 +463,61 @@ export async function processHtml(
434
463
  // This needs to run BEFORE image marking so we can skip images inside markdown
435
464
  let markdownWrapperNode: HTMLNode | null = null
436
465
 
437
- // Two strategies:
438
- // 1. Dev mode: look for elements with data-astro-source-file containing children without it
439
- // 2. Build mode: find element whose first child content matches the start of markdown body
466
+ // Three strategies in priority order:
467
+ // 0. Rehype marker: the rehype-cms-marker plugin marks the first rendered element
468
+ // with data-cms-markdown-content its parent is the wrapper
469
+ // 1. Dev mode heuristic: elements with data-astro-source-file whose children lack it
470
+ // 2. Build mode: find element whose content matches the markdown body text
440
471
  if (collectionInfo) {
441
472
  const allElements = root.querySelectorAll('*')
442
473
  let foundWrapper = false
443
474
 
475
+ // Strategy 0: Rehype marker — most reliable
476
+ const markerEl = root.querySelector('[data-cms-markdown-content]')
477
+ if (markerEl) {
478
+ markerEl.removeAttribute('data-cms-markdown-content')
479
+ const parent = markerEl.parentNode as HTMLNode | null
480
+ if (parent && parent.tagName) {
481
+ const id = getNextId()
482
+ parent.setAttribute(attributeName, id)
483
+ parent.setAttribute('data-cms-markdown', 'true')
484
+ collectionWrapperId = id
485
+ markdownWrapperNode = parent
486
+ foundWrapper = true
487
+ }
488
+ }
489
+
444
490
  // Strategy 1: Dev mode - look for source file attributes
445
- const SKIP_WRAPPER_TAGS = new Set(['html', 'head', 'body', 'script', 'style', 'meta', 'link'])
446
- for (const node of allElements) {
447
- const tag = node.tagName?.toLowerCase?.() ?? ''
448
- if (SKIP_WRAPPER_TAGS.has(tag)) continue
449
- const sourceFile = node.getAttribute('data-astro-source-file')
450
- if (!sourceFile) continue
451
-
452
- // Check if this element has any direct child elements without source file attribute
453
- // These would be markdown-rendered elements
454
- const childElements = node.childNodes.filter(
455
- (child): child is HTMLNode => child.nodeType === 1 && 'tagName' in child,
456
- )
457
- const hasMarkdownChildren = childElements.some(
458
- (child) => !child.getAttribute?.('data-astro-source-file'),
459
- )
460
-
461
- if (hasMarkdownChildren) {
462
- // Check if any ancestor already has been marked as a collection wrapper
463
- // We want the innermost wrapper
464
- let parent = node.parentNode as HTMLNode | null
465
- let hasAncestorWrapper = false
466
- while (parent) {
467
- if (parent.getAttribute?.(attributeName)?.startsWith('cms-collection-')) {
468
- hasAncestorWrapper = true
469
- break
491
+ if (!foundWrapper) {
492
+ const SKIP_WRAPPER_TAGS = new Set(['html', 'head', 'body', 'script', 'style', 'meta', 'link'])
493
+ for (const node of allElements) {
494
+ const tag = node.tagName?.toLowerCase?.() ?? ''
495
+ if (SKIP_WRAPPER_TAGS.has(tag)) continue
496
+ const sourceFile = node.getAttribute('data-astro-source-file')
497
+ if (!sourceFile) continue
498
+
499
+ // Check if this element has any direct child elements without source file attribute
500
+ // These would be markdown-rendered elements
501
+ const childElements = node.childNodes.filter(
502
+ (child): child is HTMLNode => child.nodeType === 1 && 'tagName' in child,
503
+ )
504
+ const hasMarkdownChildren = childElements.some(
505
+ (child) => !child.getAttribute?.('data-astro-source-file'),
506
+ )
507
+
508
+ if (hasMarkdownChildren) {
509
+ // Remove data-cms-markdown from previous (shallower) wrapper
510
+ // we want only the deepest wrapper to have it
511
+ if (markdownWrapperNode) {
512
+ markdownWrapperNode.removeAttribute('data-cms-markdown')
470
513
  }
471
- parent = parent.parentNode as HTMLNode | null
472
- }
473
514
 
474
- if (!hasAncestorWrapper) {
475
- // Mark this as the collection wrapper using the standard attribute
476
515
  const id = getNextId()
477
516
  node.setAttribute(attributeName, id)
478
517
  node.setAttribute('data-cms-markdown', 'true')
479
518
  collectionWrapperId = id
480
519
  markdownWrapperNode = node
481
520
  foundWrapper = true
482
- // Don't break - we want the deepest wrapper, so we'll overwrite
483
521
  }
484
522
  }
485
523
  }
@@ -636,42 +674,13 @@ export async function processHtml(
636
674
 
637
675
  // When skipMarkdownContent is true (collection pages), only mark images
638
676
  // that have source file attributes (from Astro templates, not markdown)
639
- if (skipMarkdownContent) {
640
- // Check if the image or any ancestor has source file attribute
641
- let hasSourceAttr = false
642
- let current: HTMLNode | null = node
643
- while (current) {
644
- if (current.getAttribute?.('data-astro-source-file')) {
645
- hasSourceAttr = true
646
- break
647
- }
648
- current = current.parentNode as HTMLNode | null
649
- }
650
- if (!hasSourceAttr) return
651
- }
677
+ if (skipMarkdownContent && !hasAncestorSourceFile(node)) return
652
678
 
653
679
  const id = getNextId()
654
680
  node.setAttribute(attributeName, id)
655
681
  node.setAttribute('data-cms-img', 'true')
656
682
 
657
- // Try to get source location from the image itself or ancestors
658
- let sourceFile: string | undefined
659
- let sourceLine: number | undefined
660
- let current: HTMLNode | null = node
661
- while (current && !sourceFile) {
662
- const file = current.getAttribute?.('data-astro-source-file')
663
- const line = current.getAttribute?.('data-astro-source-loc') || current.getAttribute?.('data-astro-source-line')
664
- if (file) {
665
- sourceFile = file
666
- if (line) {
667
- const lineNum = parseInt(line.split(':')[0] ?? '1', 10)
668
- if (!Number.isNaN(lineNum)) {
669
- sourceLine = lineNum
670
- }
671
- }
672
- }
673
- current = current.parentNode as HTMLNode | null
674
- }
683
+ const { sourceFile, sourceLine } = findAncestorSourceLocation(node)
675
684
 
676
685
  // Build image metadata
677
686
  const metadata: ImageMetadata = {
@@ -708,41 +717,13 @@ export async function processHtml(
708
717
  if (!bgMeta) return
709
718
 
710
719
  // When skipMarkdownContent is true, only mark elements with source file attributes
711
- if (skipMarkdownContent) {
712
- let hasSourceAttr = false
713
- let current: HTMLNode | null = node
714
- while (current) {
715
- if (current.getAttribute?.('data-astro-source-file')) {
716
- hasSourceAttr = true
717
- break
718
- }
719
- current = current.parentNode as HTMLNode | null
720
- }
721
- if (!hasSourceAttr) return
722
- }
720
+ if (skipMarkdownContent && !hasAncestorSourceFile(node)) return
723
721
 
724
722
  const id = getNextId()
725
723
  node.setAttribute(attributeName, id)
726
724
  node.setAttribute('data-cms-bg-img', 'true')
727
725
 
728
- // Try to get source location from the element itself or ancestors
729
- let sourceFile: string | undefined
730
- let sourceLine: number | undefined
731
- let current: HTMLNode | null = node
732
- while (current && !sourceFile) {
733
- const file = current.getAttribute?.('data-astro-source-file')
734
- const line = current.getAttribute?.('data-astro-source-loc') || current.getAttribute?.('data-astro-source-line')
735
- if (file) {
736
- sourceFile = file
737
- if (line) {
738
- const lineNum = parseInt(line.split(':')[0] ?? '1', 10)
739
- if (!Number.isNaN(lineNum)) {
740
- sourceLine = lineNum
741
- }
742
- }
743
- }
744
- current = current.parentNode as HTMLNode | null
745
- }
726
+ const { sourceFile, sourceLine } = findAncestorSourceLocation(node)
746
727
 
747
728
  bgImageEntries.set(id, {
748
729
  metadata: bgMeta,
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { getErrorCollector, resetErrorCollector } from './error-collector'
13
13
  import { ManifestWriter } from './manifest-writer'
14
14
  import { createLocalStorageAdapter } from './media/local'
15
15
  import type { MediaStorageAdapter } from './media/types'
16
+ import { rehypeCmsMarker } from './rehype-cms-marker'
16
17
  import type { CmsFeatures, CmsMarkerOptions, ComponentDefinition } from './types'
17
18
  import { createVitePlugin } from './vite-plugin'
18
19
 
@@ -283,6 +284,9 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
283
284
  const needsAliases = !src && !hasPrebuiltBundle
284
285
 
285
286
  updateConfig({
287
+ markdown: {
288
+ rehypePlugins: [rehypeCmsMarker],
289
+ },
286
290
  vite: {
287
291
  plugins: vitePlugins,
288
292
  resolve: needsAliases
@@ -367,6 +371,7 @@ export type { Color, Date, DateTime, Email, Image, Reference, Textarea, Time, Ur
367
371
 
368
372
  export { scanCollections } from './collection-scanner'
369
373
  export { getProjectRoot, resetProjectRoot, setProjectRoot } from './config'
374
+ export { rehypeCmsMarker } from './rehype-cms-marker'
370
375
  export type { CollectionInfo, MarkdownContent, SourceLocation, VariableReference } from './source-finder'
371
376
  export { findCollectionSource, parseMarkdownContent } from './source-finder'
372
377
  export type {
@@ -125,6 +125,21 @@ export class ManifestWriter {
125
125
  return this.collectionDefinitions
126
126
  }
127
127
 
128
+ /**
129
+ * Clear all entry pathnames on collection definitions.
130
+ * Called when route files change so stale pathnames from addPage() don't
131
+ * point to routes that no longer exist.
132
+ */
133
+ clearCollectionPathnames(): void {
134
+ for (const def of Object.values(this.collectionDefinitions)) {
135
+ if (def.entries) {
136
+ for (const entry of def.entries) {
137
+ entry.pathname = undefined
138
+ }
139
+ }
140
+ }
141
+ }
142
+
128
143
  /**
129
144
  * Get the manifest path for a given page
130
145
  * Places manifest next to the page: /about -> /about.json, / -> /index.json
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Rehype plugin that marks the first element of rendered markdown/MDX content
3
+ * with `data-cms-markdown-content`. The HTML processor uses this marker to
4
+ * reliably identify the wrapper element (the marker's parent) instead of
5
+ * relying on heuristics.
6
+ */
7
+ export function rehypeCmsMarker() {
8
+ return (tree: any) => {
9
+ const firstElement = tree.children?.find((n: any) => n.type === 'element')
10
+ if (firstElement) {
11
+ firstElement.properties ??= {}
12
+ firstElement.properties['dataCmsMarkdownContent'] = ''
13
+ }
14
+ }
15
+ }
package/src/types.ts CHANGED
@@ -236,6 +236,7 @@ export type FieldType =
236
236
  | 'image'
237
237
  | 'url'
238
238
  | 'email'
239
+ | 'tel'
239
240
  | 'color'
240
241
  | 'select'
241
242
  | 'array'
@@ -1,8 +1,5 @@
1
- import { watch } from 'node:fs'
2
- import { join } from 'node:path'
3
1
  import type { Plugin } from 'vite'
4
- import { invalidateContentCache, notifyContentStoreUpdated, type ViteServerLike } from './content-invalidator'
5
- import { expectedDeletions } from './dev-middleware'
2
+ import { expectedDeletions, invalidateCollectionRoutesCache } from './dev-middleware'
6
3
  import type { ManifestWriter } from './manifest-writer'
7
4
  import { markFileDirty } from './source-finder'
8
5
  import type { CmsMarkerOptions, ComponentDefinition } from './types'
@@ -56,6 +53,11 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
56
53
  if (INDEXED_EXTENSIONS.test(filePath)) {
57
54
  markFileDirty(filePath)
58
55
  }
56
+ // Invalidate cached collection routes when a dynamic route file changes
57
+ if (filePath.includes('/src/pages/') && filePath.includes('[')) {
58
+ invalidateCollectionRoutesCache()
59
+ manifestWriter.clearCollectionPathnames()
60
+ }
59
61
  }
60
62
 
61
63
  // Intercept Vite's file watcher to:
@@ -80,83 +82,27 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
80
82
  // processes them. We use prependListener so our handler runs first.
81
83
  const origEmit = watcher.emit.bind(watcher)
82
84
  watcher.emit = ((event: string, filePath: string, ...args: any[]) => {
83
- if ((event === 'unlink' || event === 'unlinkDir') && expectedDeletions.has(filePath)) {
84
- expectedDeletions.delete(filePath)
85
- // Swallow the event — don't let Vite/Astro see it
86
- return true
85
+ if (event === 'unlink' || event === 'unlinkDir') {
86
+ if (expectedDeletions.has(filePath)) {
87
+ expectedDeletions.delete(filePath)
88
+ // Swallow the event — don't let Vite/Astro see it
89
+ return true
90
+ }
91
+ // Invalidate cached collection routes when a dynamic route file is deleted
92
+ if (filePath.includes('/src/pages/') && filePath.includes('[')) {
93
+ invalidateCollectionRoutesCache()
94
+ manifestWriter.clearCollectionPathnames()
95
+ }
87
96
  }
88
97
  return origEmit(event, filePath, ...args)
89
98
  }) as typeof watcher.emit
90
99
  },
91
100
  }
92
101
 
93
- // Vite's bundled chokidar 3.6.0 fails to detect changes to .astro/data-store.json
94
- // (added via watcher.add() in Astro's vite-plugin-content-virtual-mod).
95
- // Without this, content collection edits update the data store on disk but the
96
- // browser never receives a full-reload because Vite's watcher never fires "change"
97
- // for that file. We use native fs.watch as a reliable fallback.
98
- //
99
- // Caveat: native fs.watch on Linux tracks the inode, not the path. Astro writes
100
- // data-store.json via atomic rename (writeFile-tmp + rename), which replaces the
101
- // inode and silently kills the existing watcher. We re-attach on every event to
102
- // keep tracking the live file across atomic writes.
103
- const dataStoreWatchPlugin: Plugin = {
104
- name: 'cms-data-store-watch',
105
- configureServer(server) {
106
- if (command !== 'dev') return
107
- const root = server.config.root
108
- const dataStorePath = join(root, '.astro', 'data-store.json')
109
- let fsWatcher: ReturnType<typeof watch> | undefined
110
- let debounce: ReturnType<typeof setTimeout> | undefined
111
- let closed = false
112
-
113
- const invalidate = () => {
114
- invalidateContentCache(server as unknown as ViteServerLike)
115
- // Wake any CMS API middleware call that is currently blocked
116
- // waiting for the data store to reflect a just-written file.
117
- // This keeps the invalidation on a single path (here) and lets
118
- // the middleware respond only after the SSR module graph is fresh.
119
- notifyContentStoreUpdated()
120
- }
121
-
122
- const onEvent = () => {
123
- clearTimeout(debounce)
124
- debounce = setTimeout(invalidate, 80)
125
- // Re-attach: native fs.watch dies after the inode is replaced by an
126
- // atomic rename. Close current and restart so subsequent writes are
127
- // observed.
128
- fsWatcher?.close()
129
- fsWatcher = undefined
130
- if (!closed) startWatching()
131
- }
132
-
133
- const startWatching = () => {
134
- if (closed) return
135
- try {
136
- fsWatcher = watch(dataStorePath, onEvent)
137
- } catch {
138
- // File doesn't exist yet — retry when it appears
139
- setTimeout(startWatching, 2000)
140
- }
141
- }
142
-
143
- // Data store is created during content sync, which runs after server start
144
- setTimeout(startWatching, 3000)
145
-
146
- const origClose = server.close.bind(server)
147
- server.close = async () => {
148
- closed = true
149
- fsWatcher?.close()
150
- clearTimeout(debounce)
151
- return origClose()
152
- }
153
- },
154
- }
155
-
156
102
  // Note: We cannot use transformIndexHtml for static Astro builds because
157
103
  // Astro generates HTML files directly without going through Vite's HTML pipeline.
158
104
  // HTML processing is done in build-processor.ts after pages are generated.
159
105
  // Source location attributes are provided natively by Astro's compiler
160
106
  // (data-astro-source-file, data-astro-source-loc) in dev mode.
161
- return [virtualManifestPlugin, watcherPlugin, dataStoreWatchPlugin, createArrayTransformPlugin()]
107
+ return [virtualManifestPlugin, watcherPlugin, createArrayTransformPlugin()]
162
108
  }
@@ -1,134 +0,0 @@
1
- /**
2
- * Vite SSR module cache invalidation + content-sync coordination.
3
- *
4
- * Astro's content layer chain (chokidar → glob loader → syncData → data store
5
- * → fs.watch → invalidateModule) is racy and unreliable under several conditions:
6
- *
7
- * - Native fs.watch on Linux dies after the first atomic rename of the watched
8
- * file (Astro writes data-store.json via writeFile-tmp + rename).
9
- * - Vite's bundled chokidar 3.6.0 misses the same atomic-write events.
10
- * - `invalidateModule(astro:data-layer-content)` alone does not propagate up
11
- * the import graph, so route modules that already cached `getCollection`
12
- * references keep returning stale data.
13
- *
14
- * This module exposes two things:
15
- *
16
- * - `invalidateContentCache(server)` — walks the SSR module graph from
17
- * `astro:data-layer-content` upward and invalidates every transitive
18
- * importer, then broadcasts `full-reload` to the client.
19
- * - `notifyContentStoreUpdated` / `awaitNextContentStoreUpdate` — a shared
20
- * rendezvous between the fs.watch plugin (which observes data-store.json
21
- * writes) and the CMS API middleware (which needs to hold the HTTP
22
- * response until the store is fresh). Keeps invalidation on a single path.
23
- */
24
-
25
- interface SsrModuleNode {
26
- id: string | null
27
- importers: Set<SsrModuleNode>
28
- }
29
-
30
- interface SsrModuleGraph {
31
- getModuleById(id: string): SsrModuleNode | undefined
32
- invalidateModule(
33
- mod: SsrModuleNode,
34
- seen?: Set<SsrModuleNode>,
35
- timestamp?: number,
36
- isHmr?: boolean,
37
- ): void
38
- }
39
-
40
- interface SsrEnvironment {
41
- moduleGraph: SsrModuleGraph
42
- hot: { send: (event: string, data?: unknown) => void }
43
- }
44
-
45
- interface ClientEnvironment {
46
- hot: { send: (payload: { type: string; path: string }) => void }
47
- }
48
-
49
- export interface ViteServerLike {
50
- environments: { ssr: SsrEnvironment; client: ClientEnvironment }
51
- }
52
-
53
- // Astro exposes the content data store as a virtual module whose resolved id
54
- // is `\0astro:data-layer-content` (see astro/dist/content/consts.js). Earlier
55
- // versions of this file used `\0astro:data-store`, which does not exist and
56
- // silently reduced `invalidateContentCache` to a no-op full-reload broadcast.
57
- const DATA_STORE_VIRTUAL_ID = '\0astro:data-layer-content'
58
-
59
- /**
60
- * Invalidate the SSR `astro:data-layer-content` virtual module and every
61
- * module that (transitively) imports it. After this returns, the next request
62
- * that imports any of these modules will re-execute and read fresh content.
63
- *
64
- * Also broadcasts `full-reload` so any connected browser refreshes.
65
- */
66
- export function invalidateContentCache(server: ViteServerLike): void {
67
- const ssr = server.environments.ssr
68
- const dataStoreMod = ssr.moduleGraph.getModuleById(DATA_STORE_VIRTUAL_ID)
69
- if (dataStoreMod) {
70
- const seen = new Set<SsrModuleNode>()
71
- const ts = Date.now()
72
- const walk = (mod: SsrModuleNode) => {
73
- if (seen.has(mod)) return
74
- seen.add(mod)
75
- ssr.moduleGraph.invalidateModule(mod, seen, ts, true)
76
- for (const importer of mod.importers) {
77
- walk(importer)
78
- }
79
- }
80
- walk(dataStoreMod)
81
- }
82
- ssr.hot.send('astro:content-changed', {})
83
- server.environments.client.hot.send({ type: 'full-reload', path: '*' })
84
- }
85
-
86
- // ---------------------------------------------------------------------------
87
- // Content-sync rendezvous
88
- // ---------------------------------------------------------------------------
89
- //
90
- // The CMS API middleware writes a content file and then needs to hold the HTTP
91
- // response until Astro has actually re-synced the data store — otherwise the
92
- // browser reloads into a stale render. The fs.watch plugin is the component
93
- // that observes the data-store.json write, so it is also the component that
94
- // resolves these waiters.
95
-
96
- type StoreUpdateResolver = () => void
97
- const pendingStoreUpdateWaiters = new Set<StoreUpdateResolver>()
98
-
99
- /**
100
- * Called by the data-store fs.watch plugin after it has invalidated the SSR
101
- * module cache in response to a data-store.json write. Wakes every middleware
102
- * caller currently parked in `awaitNextContentStoreUpdate`.
103
- */
104
- export function notifyContentStoreUpdated(): void {
105
- if (pendingStoreUpdateWaiters.size === 0) return
106
- const resolvers = Array.from(pendingStoreUpdateWaiters)
107
- pendingStoreUpdateWaiters.clear()
108
- for (const resolve of resolvers) resolve()
109
- }
110
-
111
- /**
112
- * Park until the next data-store.json write has been fully processed (store
113
- * reloaded on disk, SSR module graph invalidated). Resolves with `true` on
114
- * success or `false` if the timeout elapses first — callers should treat
115
- * timeout as "best-effort, proceed anyway".
116
- *
117
- * The timeout fallback exists because some edits legitimately do not change
118
- * the data store (e.g. whitespace-only edits are skipped by Astro's atomic
119
- * write comparator), in which case no fs.watch event will ever fire.
120
- */
121
- export function awaitNextContentStoreUpdate(timeoutMs: number): Promise<boolean> {
122
- return new Promise((resolve) => {
123
- const resolver = () => {
124
- clearTimeout(timer)
125
- pendingStoreUpdateWaiters.delete(resolver)
126
- resolve(true)
127
- }
128
- const timer = setTimeout(() => {
129
- pendingStoreUpdateWaiters.delete(resolver)
130
- resolve(false)
131
- }, timeoutMs)
132
- pendingStoreUpdateWaiters.add(resolver)
133
- })
134
- }