@nuasite/cms 0.19.0 → 0.20.1

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.
@@ -4,12 +4,21 @@ import type { MarkdownNode, SerializerState } from '@milkdown/transformer'
4
4
  import { $command, $node, $remark, $view } from '@milkdown/utils'
5
5
  import { render } from 'preact'
6
6
  import remarkMdx from 'remark-mdx'
7
+ import remarkParse from 'remark-parse'
8
+ import { unified } from 'unified'
7
9
  import { MdxBlockCard } from './components/mdx-block-view'
8
- import { openMdxPropsEditor } from './signals'
10
+ import { getComponentDefinition } from './manifest'
11
+ import { manifest } from './signals'
9
12
 
10
13
  /** Prefix used to distinguish expression attributes from string literals in serialized props */
11
14
  export const MDX_EXPR_PREFIX = '__mdx_expr__:'
12
15
 
16
+ /** HTML void elements that should not be treated as editable MDX components */
17
+ const HTML_VOID_ELEMENTS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'])
18
+
19
+ /** Cached unified processor for parsing markdown children during serialization */
20
+ const remarkParser = unified().use(remarkParse)
21
+
13
22
  export const remarkMdxPlugin: any = $remark('remarkMdx', () => remarkMdx)
14
23
 
15
24
  function parseJsxAttributes(attributes: any[]): { props: Record<string, string>; hasExpressions: boolean } {
@@ -64,21 +73,72 @@ function serializePropsToAttributes(props: Record<string, string>): any[] {
64
73
  return attributes
65
74
  }
66
75
 
76
+ /** Serialize mdast children back to markdown text */
77
+ function serializeChildren(children: any[]): string {
78
+ if (!children || children.length === 0) return ''
79
+ const parts: string[] = []
80
+ for (const child of children) {
81
+ if (child.type === 'paragraph') {
82
+ const text = serializeInlineChildren(child.children ?? [])
83
+ parts.push(text)
84
+ } else if (child.type === 'text') {
85
+ parts.push(child.value ?? '')
86
+ } else if (child.type === 'mdxJsxFlowElement' || child.type === 'mdxJsxTextElement') {
87
+ // Nested JSX — reconstruct as string
88
+ const name = child.name ?? ''
89
+ const attrs = (child.attributes ?? [])
90
+ .map((a: any) => {
91
+ if (a.type === 'mdxJsxAttribute' && a.name) {
92
+ if (typeof a.value === 'string') return `${a.name}="${a.value}"`
93
+ if (a.value?.type === 'mdxJsxAttributeValueExpression') return `${a.name}={${a.value.value}}`
94
+ }
95
+ return ''
96
+ })
97
+ .filter(Boolean)
98
+ .join(' ')
99
+ const inner = serializeChildren(child.children ?? [])
100
+ if (inner) {
101
+ parts.push(`<${name}${attrs ? ' ' + attrs : ''}>${inner}</${name}>`)
102
+ } else {
103
+ parts.push(`<${name}${attrs ? ' ' + attrs : ''} />`)
104
+ }
105
+ } else {
106
+ // Fallback — use value if present
107
+ if (child.value) parts.push(child.value)
108
+ }
109
+ }
110
+ return parts.join('\n\n')
111
+ }
112
+
113
+ function serializeInlineChildren(children: any[]): string {
114
+ return children.map((c: any) => {
115
+ if (c.type === 'text') return c.value ?? ''
116
+ if (c.type === 'strong') return `**${serializeInlineChildren(c.children ?? [])}**`
117
+ if (c.type === 'emphasis') return `*${serializeInlineChildren(c.children ?? [])}*`
118
+ if (c.type === 'inlineCode') return `\`${c.value ?? ''}\``
119
+ if (c.type === 'link') return `[${serializeInlineChildren(c.children ?? [])}](${c.url ?? ''})`
120
+ return c.value ?? ''
121
+ }).join('')
122
+ }
123
+
67
124
  export const mdxComponentNode = $node('mdx_component', () => ({
68
125
  group: 'block',
69
126
  atom: true,
70
127
  isolating: true,
71
128
  selectable: true,
72
- draggable: true,
129
+ draggable: false,
73
130
  attrs: {
74
131
  componentName: { default: '' },
75
132
  props: { default: '{}' },
76
133
  hasExpressions: { default: false },
134
+ children: { default: '' },
77
135
  },
78
136
  toDOM: (node: PmNode) => {
79
137
  const div = document.createElement('div')
80
138
  div.setAttribute('data-mdx-component', node.attrs.componentName)
81
139
  div.setAttribute('data-mdx-props', node.attrs.props)
140
+ if (node.attrs.children) div.setAttribute('data-mdx-children', node.attrs.children)
141
+ if (node.attrs.hasExpressions) div.setAttribute('data-mdx-expressions', 'true')
82
142
  div.className = 'mdx-component-block'
83
143
  div.textContent = `<${node.attrs.componentName} />`
84
144
  return div as any
@@ -88,6 +148,8 @@ export const mdxComponentNode = $node('mdx_component', () => ({
88
148
  getAttrs: (dom: HTMLElement) => ({
89
149
  componentName: dom.getAttribute('data-mdx-component') || '',
90
150
  props: dom.getAttribute('data-mdx-props') || '{}',
151
+ children: dom.getAttribute('data-mdx-children') || '',
152
+ hasExpressions: dom.getAttribute('data-mdx-expressions') === 'true',
91
153
  }),
92
154
  }],
93
155
  parseMarkdown: {
@@ -95,13 +157,17 @@ export const mdxComponentNode = $node('mdx_component', () => ({
95
157
  runner: (state, node, proseType: NodeType) => {
96
158
  const name = (node as any).name as string | null
97
159
  if (!name) return // Skip fragments
160
+ // Skip HTML void elements — they are not editable components
161
+ if (HTML_VOID_ELEMENTS.has(name.toLowerCase())) return
98
162
 
99
163
  const { props, hasExpressions } = parseJsxAttributes((node as any).attributes)
164
+ const children = serializeChildren((node as any).children ?? [])
100
165
 
101
166
  state.addNode(proseType, {
102
167
  componentName: name,
103
168
  props: JSON.stringify(props),
104
169
  hasExpressions,
170
+ children,
105
171
  })
106
172
  },
107
173
  },
@@ -110,13 +176,24 @@ export const mdxComponentNode = $node('mdx_component', () => ({
110
176
  runner: (state: SerializerState, node: PmNode) => {
111
177
  const componentName = node.attrs.componentName as string
112
178
  const props: Record<string, string> = JSON.parse(node.attrs.props as string)
179
+ const childrenText = (node.attrs.children as string) || ''
113
180
  const attributes = serializePropsToAttributes(props)
114
181
 
115
- state.addNode('mdxJsxFlowElement', undefined, undefined, {
116
- name: componentName,
117
- attributes,
118
- children: [],
119
- } as any)
182
+ if (childrenText.trim()) {
183
+ // Parse children markdown into proper mdast nodes so headings, lists, etc. are preserved
184
+ const childrenAst = remarkParser.parse(childrenText)
185
+ state.addNode('mdxJsxFlowElement', undefined, undefined, {
186
+ name: componentName,
187
+ attributes,
188
+ children: childrenAst.children,
189
+ } as any)
190
+ } else {
191
+ state.addNode('mdxJsxFlowElement', undefined, undefined, {
192
+ name: componentName,
193
+ attributes,
194
+ children: [],
195
+ } as any)
196
+ }
120
197
  },
121
198
  },
122
199
  }))
@@ -124,6 +201,7 @@ export const mdxComponentNode = $node('mdx_component', () => ({
124
201
  export interface InsertMdxComponentPayload {
125
202
  componentName: string
126
203
  props: Record<string, string>
204
+ children?: string
127
205
  }
128
206
 
129
207
  export const insertMdxComponentCommand = $command('insertMdxComponent', (ctx) => {
@@ -137,6 +215,7 @@ export const insertMdxComponentCommand = $command('insertMdxComponent', (ctx) =>
137
215
  componentName: payload.componentName,
138
216
  props: JSON.stringify(payload.props),
139
217
  hasExpressions: false,
218
+ children: payload.children || '',
140
219
  })
141
220
 
142
221
  if (dispatch) {
@@ -156,27 +235,36 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
156
235
  container.setAttribute('data-cms-ui', '')
157
236
  container.contentEditable = 'false'
158
237
 
159
- let lastAttrs: { componentName: string; props: string; hasExpressions: boolean } | null = null
238
+ let lastAttrs: { componentName: string; props: string; hasExpressions: boolean; children: string } | null = null
160
239
 
161
240
  const renderCard = (node: PmNode) => {
162
241
  const componentName = node.attrs.componentName as string
163
242
  const propsJson = node.attrs.props as string
164
243
  const props: Record<string, string> = JSON.parse(propsJson)
165
244
  const hasExpressions = node.attrs.hasExpressions as boolean
245
+ const children = (node.attrs.children as string) || ''
246
+ const definition = getComponentDefinition(manifest.value, componentName)
247
+ const hasDefaultSlot = definition?.slots?.includes('default') ?? false
248
+
249
+ lastAttrs = { componentName, props: propsJson, hasExpressions, children }
166
250
 
167
- lastAttrs = { componentName, props: propsJson, hasExpressions }
251
+ const updateNodeAttrs = (update: Record<string, unknown>) => {
252
+ const pos = typeof getPos === 'function' ? getPos() : null
253
+ if (pos != null) {
254
+ const currentNode = view.state.doc.nodeAt(pos)
255
+ if (currentNode) {
256
+ const tr = view.state.tr.setNodeMarkup(pos, undefined, { ...currentNode.attrs, ...update })
257
+ view.dispatch(tr)
258
+ }
259
+ }
260
+ }
168
261
 
169
262
  render(
170
263
  <MdxBlockCard
171
264
  componentName={componentName}
172
265
  props={props}
173
266
  hasExpressions={hasExpressions}
174
- onEdit={(cursorPos) => {
175
- const pos = typeof getPos === 'function' ? getPos() : null
176
- if (pos != null) {
177
- openMdxPropsEditor(pos, componentName, props, cursorPos)
178
- }
179
- }}
267
+ slotContent={children}
180
268
  onRemove={() => {
181
269
  const pos = typeof getPos === 'function' ? getPos() : null
182
270
  if (pos != null) {
@@ -187,6 +275,12 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
187
275
  }
188
276
  }
189
277
  }}
278
+ onSlotContentChange={hasDefaultSlot
279
+ ? (newContent: string) => updateNodeAttrs({ children: newContent })
280
+ : undefined}
281
+ onPropsChange={!hasExpressions
282
+ ? (newProps: Record<string, string>) => updateNodeAttrs({ props: JSON.stringify(newProps) })
283
+ : undefined}
190
284
  />,
191
285
  container,
192
286
  )
@@ -197,11 +291,13 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
197
291
  return {
198
292
  dom: container,
199
293
  stopEvent: (event: Event) => {
294
+ const target = event.target as HTMLElement
295
+ // Allow all events on inline editors (textarea, input, contenteditable)
296
+ if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') return true
297
+ if (target.isContentEditable || target.closest('[contenteditable]')) return true
298
+ if (target.closest('[data-mdx-action="children"]') || target.closest('[data-mdx-action="props"]')) return true
200
299
  if (event.type === 'mousedown' || event.type === 'click') {
201
- const target = event.target as HTMLElement
202
- if (target.closest('button') || target.closest('[data-mdx-action]')) {
203
- return true
204
- }
300
+ if (target.closest('button') || target.closest('[data-mdx-action]')) return true
205
301
  }
206
302
  return false
207
303
  },
@@ -214,6 +310,7 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
214
310
  && attrs.componentName === lastAttrs.componentName
215
311
  && attrs.props === lastAttrs.props
216
312
  && attrs.hasExpressions === lastAttrs.hasExpressions
313
+ && attrs.children === lastAttrs.children
217
314
  ) {
218
315
  return true
219
316
  }
@@ -0,0 +1,174 @@
1
+ import type { Editor } from '@milkdown/core'
2
+ import { editorViewCtx } from '@milkdown/core'
3
+ import type { EditorView } from '@milkdown/prose/view'
4
+
5
+ export interface ActiveFormats {
6
+ bold: boolean
7
+ italic: boolean
8
+ strikethrough: boolean
9
+ link: boolean
10
+ linkHref: string | null
11
+ bulletList: boolean
12
+ orderedList: boolean
13
+ blockquote: boolean
14
+ heading: number | null
15
+ }
16
+
17
+ export const defaultActiveFormats: ActiveFormats = {
18
+ bold: false,
19
+ italic: false,
20
+ strikethrough: false,
21
+ link: false,
22
+ linkHref: null,
23
+ bulletList: false,
24
+ orderedList: false,
25
+ blockquote: false,
26
+ heading: null,
27
+ }
28
+
29
+ /**
30
+ * Detect active inline/block formats at the current selection in a ProseMirror view.
31
+ */
32
+ export function getActiveFormats(view: EditorView): ActiveFormats {
33
+ const { state } = view
34
+ const { $from, from, to } = state.selection
35
+
36
+ // Check marks (inline formatting)
37
+ let bold = false
38
+ let italic = false
39
+ let strikethrough = false
40
+ let link = false
41
+ let linkHref: string | null = null
42
+
43
+ const marks = state.storedMarks || $from.marks()
44
+ for (const mark of marks) {
45
+ if (mark.type.name === 'strong') bold = true
46
+ if (mark.type.name === 'emphasis') italic = true
47
+ if (mark.type.name === 'strikethrough') strikethrough = true
48
+ if (mark.type.name === 'link') {
49
+ link = true
50
+ linkHref = mark.attrs.href as string
51
+ }
52
+ }
53
+
54
+ // Also check marks in the selection range
55
+ if (from !== to) {
56
+ state.doc.nodesBetween(from, to, (node) => {
57
+ if (node.marks) {
58
+ for (const mark of node.marks) {
59
+ if (mark.type.name === 'strong') bold = true
60
+ if (mark.type.name === 'emphasis') italic = true
61
+ if (mark.type.name === 'strikethrough') strikethrough = true
62
+ if (mark.type.name === 'link') {
63
+ link = true
64
+ linkHref = mark.attrs.href as string
65
+ }
66
+ }
67
+ }
68
+ })
69
+ }
70
+
71
+ // Check block types (lists, blockquote, heading)
72
+ let bulletList = false
73
+ let orderedList = false
74
+ let blockquote = false
75
+ let heading: number | null = null
76
+
77
+ for (let depth = $from.depth; depth > 0; depth--) {
78
+ const node = $from.node(depth)
79
+ if (node.type.name === 'bullet_list') bulletList = true
80
+ if (node.type.name === 'ordered_list') orderedList = true
81
+ if (node.type.name === 'blockquote') blockquote = true
82
+ }
83
+
84
+ if ($from.parent.type.name === 'heading') {
85
+ heading = $from.parent.attrs.level as number
86
+ }
87
+
88
+ return { bold, italic, strikethrough, link, linkHref, bulletList, orderedList, blockquote, heading }
89
+ }
90
+
91
+ /**
92
+ * Check whether the current selection is inside a list of the given type.
93
+ */
94
+ export function isInListType(view: EditorView, listType: string): boolean {
95
+ const { $from } = view.state.selection
96
+ for (let depth = $from.depth; depth > 0; depth--) {
97
+ if ($from.node(depth).type.name === listType) return true
98
+ }
99
+ return false
100
+ }
101
+
102
+ /**
103
+ * Toggle a heading level at the current selection. If the selection is already
104
+ * a heading of the given level, convert it back to a paragraph.
105
+ */
106
+ export function toggleHeading(view: EditorView, level: number): void {
107
+ const { state } = view
108
+ const headingType = state.schema.nodes.heading
109
+ const paragraphType = state.schema.nodes.paragraph
110
+ if (!headingType) return
111
+
112
+ const { $from } = state.selection
113
+ const isCurrentHeading = $from.parent.type.name === 'heading' && $from.parent.attrs.level === level
114
+ const targetType = isCurrentHeading ? paragraphType : headingType
115
+ const attrs = isCurrentHeading ? undefined : { level }
116
+ if (!targetType) return
117
+
118
+ const blockFrom = $from.before($from.depth)
119
+ const blockTo = state.selection.$to.after(state.selection.$to.depth)
120
+ view.dispatch(state.tr.setBlockType(blockFrom, blockTo, targetType, attrs))
121
+ view.focus()
122
+ }
123
+
124
+ function formatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
125
+ return a.bold === b.bold
126
+ && a.italic === b.italic
127
+ && a.strikethrough === b.strikethrough
128
+ && a.link === b.link
129
+ && a.linkHref === b.linkHref
130
+ && a.bulletList === b.bulletList
131
+ && a.orderedList === b.orderedList
132
+ && a.blockquote === b.blockquote
133
+ && a.heading === b.heading
134
+ }
135
+
136
+ /**
137
+ * Intercept dispatch on the editor view to track active formats via rAF
138
+ * debouncing. Fires the callback only when formats actually change.
139
+ * Returns a cleanup function that cancels the pending rAF.
140
+ */
141
+ export function setupFormatTracking(editor: Editor, callback: (formats: ActiveFormats) => void): () => void {
142
+ let formatRaf = 0
143
+ let lastFormats: ActiveFormats = defaultActiveFormats
144
+
145
+ const update = () => {
146
+ try {
147
+ const view = editor.ctx.get(editorViewCtx)
148
+ const formats = getActiveFormats(view)
149
+ if (!formatsEqual(formats, lastFormats)) {
150
+ lastFormats = formats
151
+ callback(formats)
152
+ }
153
+ } catch { /* ignore */ }
154
+ }
155
+
156
+ try {
157
+ const view = editor.ctx.get(editorViewCtx)
158
+ const origDispatch = view.dispatch.bind(view)
159
+ view.dispatch = (tr) => {
160
+ origDispatch(tr)
161
+ if (tr.selectionSet || tr.docChanged) {
162
+ cancelAnimationFrame(formatRaf)
163
+ formatRaf = requestAnimationFrame(update)
164
+ }
165
+ }
166
+ } catch { /* ignore */ }
167
+
168
+ // Fire initial check
169
+ update()
170
+
171
+ return () => {
172
+ cancelAnimationFrame(formatRaf)
173
+ }
174
+ }
@@ -26,7 +26,6 @@ import type {
26
26
  FieldDefinition,
27
27
  MarkdownEditorState,
28
28
  MarkdownPageEntry,
29
- MdxPropsEditorState,
30
29
  MediaItem,
31
30
  MediaLibraryState,
32
31
  PendingAttributeChange,
@@ -346,24 +345,8 @@ export const isMarkdownPreview = signal(false)
346
345
  // MDX Component Block State Signals
347
346
  // ============================================================================
348
347
 
349
- export const mdxPropsEditorState = signal<MdxPropsEditorState>({
350
- isOpen: false,
351
- nodePos: null,
352
- componentName: null,
353
- props: {},
354
- cursorPos: null,
355
- })
356
-
357
348
  export const mdxComponentPickerOpen = signal(false)
358
349
 
359
- export function openMdxPropsEditor(nodePos: number, componentName: string, props: Record<string, string>, cursorPos: { x: number; y: number }): void {
360
- mdxPropsEditorState.value = { isOpen: true, nodePos, componentName, props, cursorPos }
361
- }
362
-
363
- export function closeMdxPropsEditor(): void {
364
- mdxPropsEditorState.value = { isOpen: false, nodePos: null, componentName: null, props: {}, cursorPos: null }
365
- }
366
-
367
350
  // ============================================================================
368
351
  // Reference Picker State Signals
369
352
  // ============================================================================
@@ -825,7 +808,7 @@ export function setAIStreamingContent(content: string | null): void {
825
808
  }
826
809
 
827
810
  export function setAIError(error: string | null): void {
828
- aiState.value = { ...aiState.value, error: error }
811
+ aiState.value = { ...aiState.value, error }
829
812
  }
830
813
 
831
814
  export function resetAIState(): void {
@@ -698,16 +698,6 @@ export type UndoAction =
698
698
  // MDX Component Block Types
699
699
  // ============================================================================
700
700
 
701
- export interface MdxPropsEditorState {
702
- isOpen: boolean
703
- /** ProseMirror document position of the node being edited */
704
- nodePos: number | null
705
- componentName: string | null
706
- props: Record<string, string>
707
- /** Position for the floating editor panel */
708
- cursorPos: { x: number; y: number } | null
709
- }
710
-
711
701
  declare global {
712
702
  interface Window {
713
703
  NuaCmsConfig?: Partial<CmsConfig>
@@ -1,6 +1,6 @@
1
1
  import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
2
2
  import { processSeoFromHtml } from './seo-processor'
3
- import { enhanceManifestWithSourceSnippets } from './source-finder'
3
+
4
4
  import { extractBackgroundImageClasses, extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
5
5
  import type {
6
6
  Attribute,
@@ -90,6 +90,8 @@ export interface ProcessHtmlResult {
90
90
  collectionWrapperId?: string
91
91
  /** Extracted SEO data from the page */
92
92
  seo?: PageSeoData
93
+ /** Collection definitions passed through for deferred enhancement */
94
+ collectionDefinitions?: Record<string, CollectionDefinition>
93
95
  }
94
96
 
95
97
  /**
@@ -440,7 +442,10 @@ export async function processHtml(
440
442
  let foundWrapper = false
441
443
 
442
444
  // Strategy 1: Dev mode - look for source file attributes
445
+ const SKIP_WRAPPER_TAGS = new Set(['html', 'head', 'body', 'script', 'style', 'meta', 'link'])
443
446
  for (const node of allElements) {
447
+ const tag = node.tagName?.toLowerCase?.() ?? ''
448
+ if (SKIP_WRAPPER_TAGS.has(tag)) continue
444
449
  const sourceFile = node.getAttribute('data-astro-source-file')
445
450
  if (!sourceFile) continue
446
451
 
@@ -1023,10 +1028,6 @@ export async function processHtml(
1023
1028
  node.removeAttribute('data-astro-source-line')
1024
1029
  })
1025
1030
 
1026
- // Enhance manifest entries with actual source snippets from source files
1027
- // This allows the CMS to match and replace dynamic content in source files
1028
- const enhancedEntries = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions)
1029
-
1030
1031
  // Get the current HTML for SEO processing
1031
1032
  let finalHtml = root.toString()
1032
1033
 
@@ -1048,7 +1049,7 @@ export async function processHtml(
1048
1049
 
1049
1050
  // If title was marked with CMS ID, add it to entries
1050
1051
  if (seoResult.titleId && seo.title) {
1051
- enhancedEntries[seoResult.titleId] = {
1052
+ entries[seoResult.titleId] = {
1052
1053
  id: seoResult.titleId,
1053
1054
  tag: 'title',
1054
1055
  text: seo.title.content,
@@ -1061,10 +1062,11 @@ export async function processHtml(
1061
1062
 
1062
1063
  return {
1063
1064
  html: finalHtml,
1064
- entries: enhancedEntries,
1065
+ entries,
1065
1066
  components,
1066
1067
  collectionWrapperId,
1067
1068
  seo,
1069
+ collectionDefinitions,
1068
1070
  }
1069
1071
  }
1070
1072
 
package/src/index.ts CHANGED
@@ -112,6 +112,9 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
112
112
  name: '@nuasite/cms',
113
113
  hooks: {
114
114
  'astro:config:setup': async ({ updateConfig, command, injectScript, logger }) => {
115
+ // CMS is only needed during dev — skip all setup during build
116
+ if (command !== 'dev') return
117
+
115
118
  // --- CMS Marker setup ---
116
119
  idCounter.value = 0
117
120
  manifestWriter.reset()
@@ -295,21 +298,8 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
295
298
  },
296
299
 
297
300
  'astro:build:done': async ({ dir, logger }) => {
298
- if (generateManifest) {
299
- await processBuildOutput(dir, markerConfig, manifestWriter, idCounter, logger)
300
- }
301
-
302
301
  // Merge CMS-managed redirects (src/_redirects) into dist/_redirects
303
302
  await mergeRedirects(dir, logger)
304
-
305
- const errorCollector = getErrorCollector()
306
- if (errorCollector.hasWarnings()) {
307
- const warnings = errorCollector.getWarnings()
308
- logger.warn(`${warnings.length} warning(s) during processing:`)
309
- for (const { context, message } of warnings) {
310
- logger.warn(` - ${context}: ${message}`)
311
- }
312
- }
313
303
  },
314
304
  },
315
305
  }
@@ -18,6 +18,9 @@ const textSearchIndex: SearchIndexEntry[] = []
18
18
  const imageSearchIndex: ImageIndexEntry[] = []
19
19
  let searchIndexInitialized = false
20
20
 
21
+ /** Files that changed since last indexing — tracked by Vite watcher */
22
+ const dirtyFiles = new Set<string>()
23
+
21
24
  // ============================================================================
22
25
  // Cache Access Functions
23
26
  // ============================================================================
@@ -58,6 +61,49 @@ export function addToImageSearchIndex(entry: ImageIndexEntry): void {
58
61
  imageSearchIndex.push(entry)
59
62
  }
60
63
 
64
+ // ============================================================================
65
+ // Dirty File Tracking (incremental re-indexing)
66
+ // ============================================================================
67
+
68
+ /**
69
+ * Mark a file as dirty so its index entries are refreshed on next page load.
70
+ * Called by the Vite file watcher when source files change.
71
+ * @param absPath - Absolute path to the changed file
72
+ */
73
+ export function markFileDirty(absPath: string): void {
74
+ dirtyFiles.add(absPath)
75
+ // Also evict the parsed file cache so it's re-read from disk
76
+ parsedFileCache.delete(absPath)
77
+ }
78
+
79
+ export function getDirtyFiles(): Set<string> {
80
+ return dirtyFiles
81
+ }
82
+
83
+ export function clearDirtyFiles(): void {
84
+ dirtyFiles.clear()
85
+ }
86
+
87
+ /**
88
+ * Remove all index entries for a specific file (by relative path).
89
+ * Used before re-indexing a changed file to avoid duplicates.
90
+ */
91
+ export function removeFileFromIndexes(relFile: string): void {
92
+ filterInPlace(textSearchIndex, (e) => e.file !== relFile)
93
+ filterInPlace(imageSearchIndex, (e) => e.file !== relFile)
94
+ }
95
+
96
+ /** Remove non-matching elements in-place (single pass, no per-element splice). */
97
+ function filterInPlace<T>(arr: T[], keep: (item: T) => boolean): void {
98
+ let write = 0
99
+ for (const item of arr) {
100
+ if (keep(item)) {
101
+ arr[write++] = item
102
+ }
103
+ }
104
+ arr.length = write
105
+ }
106
+
61
107
  // ============================================================================
62
108
  // Cache Clear Function
63
109
  // ============================================================================
@@ -69,6 +115,7 @@ export function clearSourceFinderCache(): void {
69
115
  parsedFileCache.clear()
70
116
  directoryCache.clear()
71
117
  markdownFileCache.clear()
118
+ dirtyFiles.clear()
72
119
  // Clear arrays in-place to avoid stale references from consumers
73
120
  textSearchIndex.length = 0
74
121
  imageSearchIndex.length = 0