@nuasite/cms 0.28.0 → 0.29.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.
@@ -375,11 +375,22 @@ export function MarkdownEditorOverlay() {
375
375
  onMouseDown={stopPropagation}
376
376
  onClick={stopPropagation}
377
377
  >
378
- <div
378
+ <form
379
379
  class={`bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 w-full max-h-[90vh] flex flex-col ${
380
380
  hasSidebar ? 'max-w-6xl' : 'max-w-4xl'
381
381
  }`}
382
382
  data-cms-ui
383
+ onSubmit={(e) => {
384
+ e.preventDefault()
385
+ if (isCreateMode) {
386
+ handleCreate()
387
+ } else {
388
+ const currentContent = currentMarkdownPage.value?.content
389
+ if (currentContent !== undefined) {
390
+ handleSave(currentContent)
391
+ }
392
+ }
393
+ }}
383
394
  >
384
395
  {/* Header */}
385
396
  <div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
@@ -495,9 +506,8 @@ export function MarkdownEditorOverlay() {
495
506
  {isCreateMode
496
507
  ? (
497
508
  <button
498
- type="button"
499
- onClick={handleCreate}
500
- disabled={isSaving || !(page.frontmatter.title as string)?.trim()}
509
+ type="submit"
510
+ disabled={isSaving}
501
511
  class="px-4 py-2 text-sm bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
502
512
  data-cms-ui
503
513
  >
@@ -507,13 +517,7 @@ export function MarkdownEditorOverlay() {
507
517
  )
508
518
  : (
509
519
  <button
510
- type="button"
511
- onClick={() => {
512
- const currentContent = currentMarkdownPage.value?.content
513
- if (currentContent !== undefined) {
514
- handleSave(currentContent)
515
- }
516
- }}
520
+ type="submit"
517
521
  disabled={isSaving}
518
522
  class="px-4 py-2 text-sm bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
519
523
  data-cms-ui
@@ -577,7 +581,7 @@ export function MarkdownEditorOverlay() {
577
581
  />
578
582
  )}
579
583
  </div>
580
- </div>
584
+ </form>
581
585
  </div>
582
586
  )
583
587
  }
@@ -314,6 +314,7 @@ const INLINE_INPUT_TYPES: Record<string, string> = {
314
314
  datetime: 'datetime-local',
315
315
  time: 'time',
316
316
  email: 'email',
317
+ tel: 'tel',
317
318
  }
318
319
  const inputClass =
319
320
  'w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/25 transition-colors'
@@ -233,7 +233,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
233
233
  if (isCreating) {
234
234
  const slug = slugify(newName.trim())
235
235
  return (
236
- <div class="p-3 bg-white/5 border border-white/15 rounded-cms-md space-y-3">
236
+ <form
237
+ class="p-3 bg-white/5 border border-white/15 rounded-cms-md space-y-3"
238
+ onSubmit={(e) => {
239
+ e.preventDefault()
240
+ handleCreate()
241
+ }}
242
+ >
237
243
  <div class="flex items-center justify-between">
238
244
  <span class="text-[12px] font-medium text-white/70">Create new entry</span>
239
245
  {options.length > 0 && (
@@ -251,6 +257,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
251
257
  value={newName}
252
258
  onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
253
259
  placeholder="Enter name..."
260
+ required
254
261
  class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
255
262
  autoFocus
256
263
  />
@@ -279,15 +286,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
279
286
  Cancel
280
287
  </button>
281
288
  <button
282
- type="button"
283
- onClick={handleCreate}
284
- disabled={!newName.trim()}
289
+ type="submit"
285
290
  class="px-3 py-1.5 text-[12px] bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
286
291
  >
287
292
  Create
288
293
  </button>
289
294
  </div>
290
- </div>
295
+ </form>
291
296
  )
292
297
  }
293
298
 
@@ -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}`)
@@ -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
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
- }