@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.
- package/dist/editor.js +12447 -12473
- package/package.json +1 -1
- package/src/collection-scanner.ts +69 -35
- package/src/dev-middleware.ts +86 -45
- package/src/editor/components/attribute-editor.tsx +2 -10
- package/src/editor/components/bg-image-overlay.tsx +2 -10
- package/src/editor/components/collections-browser.tsx +8 -24
- package/src/editor/components/color-toolbar.tsx +2 -9
- package/src/editor/components/confirm-dialog.tsx +4 -12
- package/src/editor/components/create-page-modal.tsx +23 -19
- package/src/editor/components/fields.tsx +158 -124
- package/src/editor/components/frontmatter-fields.tsx +9 -1
- package/src/editor/components/link-edit-popover.tsx +3 -6
- package/src/editor/components/markdown-editor-overlay.tsx +44 -46
- package/src/editor/components/markdown-inline-editor.tsx +2 -1
- package/src/editor/components/mdx-block-view.tsx +1 -0
- package/src/editor/components/mdx-component-picker.tsx +3 -6
- package/src/editor/components/media-library.tsx +15 -37
- package/src/editor/components/modal-shell.tsx +34 -5
- package/src/editor/components/prop-editor.tsx +77 -73
- package/src/editor/components/reference-picker.tsx +6 -24
- package/src/editor/components/seo-editor.tsx +4 -10
- package/src/editor/components/spinner.tsx +17 -0
- package/src/editor/components/toolbar.tsx +2 -1
- package/src/editor/constants.ts +33 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
- package/src/editor/hooks/useSearchFilter.ts +21 -0
- package/src/field-types.ts +2 -0
- package/src/handlers/api-routes.ts +10 -16
- package/src/html-processor.ts +75 -94
- package/src/index.ts +5 -0
- package/src/manifest-writer.ts +15 -0
- package/src/rehype-cms-marker.ts +15 -0
- package/src/types.ts +1 -0
- package/src/vite-plugin.ts +18 -72
- 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
|
+
}
|
package/src/field-types.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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}`)
|
package/src/html-processor.ts
CHANGED
|
@@ -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
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/manifest-writer.ts
CHANGED
|
@@ -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
package/src/vite-plugin.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
84
|
-
expectedDeletions.
|
|
85
|
-
|
|
86
|
-
|
|
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,
|
|
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
|
-
}
|