diff --git a/src/app-builder/build.ts b/src/app-builder/build.ts index 964185cf..be596329 100644 --- a/src/app-builder/build.ts +++ b/src/app-builder/build.ts @@ -17,6 +17,7 @@ import { watchComponentDirectories, watchGlobalEntries, watchPages, + watchPatternDirectories, watchPublicDirectory, watchRuntimeComponents, watchRuntimeConfiguration, @@ -224,12 +225,14 @@ export const watchApp = async (handoff: Handoff): Promise => { runtimeComponentsWatcher: null, runtimeConfigurationWatcher: null, componentDirectoriesWatcher: null, + patternDirectoriesWatcher: null, }; watchPublicDirectory(handoff, wss, state, chokidarConfig); watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff)); watchRuntimeConfiguration(handoff, state); watchComponentDirectories(handoff, state, chokidarConfig); + watchPatternDirectories(handoff, state, chokidarConfig); watchGlobalEntries(handoff, state, chokidarConfig); watchPages(handoff, chokidarConfig); }; diff --git a/src/app-builder/watchers.ts b/src/app-builder/watchers.ts index 987af7f5..f5fe6338 100644 --- a/src/app-builder/watchers.ts +++ b/src/app-builder/watchers.ts @@ -2,6 +2,7 @@ import chokidar from 'chokidar'; import fs from 'fs-extra'; import path from 'path'; import Handoff from '..'; +import { buildPatterns } from '../pipeline/patterns'; import processComponents, { ComponentSegment } from '../transformers/preview/component/builder'; import { buildMainCss } from '../transformers/preview/component/css'; import { buildMainJS } from '../transformers/preview/component/javascript'; @@ -273,22 +274,49 @@ export const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState) }; /** - * Watches the parent component directories from config.entries.components - * for new components being added. When a new config file (e.g. button.json) - * appears in a new subdirectory, reloads the runtime config and restarts - * the component/configuration watchers so the new component is picked up. + * Shared factory for watching a parent directory for newly created entity + * subdirectories (components or patterns). Handles the common scaffolding so + * that callers only describe what differs for their entity kind: + * + * - which config-entry paths to watch + * - how to read the current known ids after a reload + * - which WatcherState slot to use + * - a log label for the entity kind + * - the rebuild work to run once the runtime config is fresh */ -export const watchComponentDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => { - if (state.componentDirectoriesWatcher) { - state.componentDirectoriesWatcher.close(); +const watchEntityDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions, options: { + /** Config paths to watch, e.g. handoff.config.entries?.components */ + getConfigPaths: (handoff: Handoff) => string[]; + /** Current known entity ids from runtime config, called again after reload to refresh the set */ + getKnownIds: (handoff: Handoff) => string[]; + /** Read the active FSWatcher from state */ + getWatcher: (state: WatcherState) => chokidar.FSWatcher | null; + /** Write the active FSWatcher back into state */ + setWatcher: (state: WatcherState, watcher: chokidar.FSWatcher) => void; + /** Prefix for the scheduleHandler key, e.g. 'newComponent' or 'newPattern' */ + scheduleKeyPrefix: string; + /** Human-readable label used in log messages, e.g. 'component' or 'pattern' */ + entityLabel: string; + /** + * Entity-specific rebuild work, called after reload + persistClientConfig + + * watcher re-registration. Receives the handoff instance (with fresh runtime + * config) and the directory name of the new entity. + */ + onDetected: (handoff: Handoff, dirName: string) => Promise; +}) => { + const { getConfigPaths, getKnownIds, getWatcher, setWatcher, scheduleKeyPrefix, entityLabel, onDetected } = options; + + const existingWatcher = getWatcher(state); + if (existingWatcher) { + existingWatcher.close(); } - const componentPaths = handoff.config.entries?.components ?? []; - if (componentPaths.length === 0) return; + const configPaths = getConfigPaths(handoff); + if (configPaths.length === 0) return; const dirsToWatch: string[] = []; - for (const componentPath of componentPaths) { - const resolved = path.resolve(handoff.workingPath, componentPath); + for (const configPath of configPaths) { + const resolved = path.resolve(handoff.workingPath, configPath); if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { dirsToWatch.push(resolved); } @@ -296,40 +324,127 @@ export const watchComponentDirectories = (handoff: Handoff, state: WatcherState, if (dirsToWatch.length === 0) return; - const knownComponents = new Set(Object.keys(handoff.runtimeConfig?.entries?.components ?? {})); + // Snapshot known ids at watch startup so we only react to genuinely new + // entities, not to file changes inside already-known directories. + const knownIds = new Set(getKnownIds(handoff)); - state.componentDirectoriesWatcher = chokidar.watch(dirsToWatch, { + const watcher = chokidar.watch(dirsToWatch, { ...chokidarConfig, depth: 1, }); + setWatcher(state, watcher); - state.componentDirectoriesWatcher.on('add', async (file) => { + watcher.on('add', async (file) => { const basename = path.basename(file); const dirName = path.basename(path.dirname(file)); - const isConfigFile = basename.endsWith('.json') || basename.endsWith('.js') || basename.endsWith('.cjs'); - const isNewComponent = isConfigFile && basename.startsWith(dirName) && !knownComponents.has(dirName); + // Only react to the primary declaration file for the directory (e.g. + // button.json, button.js, or button.handoff.ts inside a button/ subdir). + // The basename.startsWith(dirName) guard below ensures .ts here only + // ever matches files named after their parent directory, not arbitrary + // TypeScript source files. + const isConfigFile = basename.endsWith('.json') || basename.endsWith('.js') || basename.endsWith('.cjs') || basename.endsWith('.ts'); + const isNewEntity = isConfigFile && basename.startsWith(dirName) && !knownIds.has(dirName); - if (!isNewComponent) return; + if (!isNewEntity) return; - await scheduleHandler(state, `newComponent:${dirName}`, async () => { + await scheduleHandler(state, `${scheduleKeyPrefix}:${dirName}`, async () => { try { - Logger.warn(`New component detected: ${dirName}. Reloading configuration...`); + Logger.warn(`New ${entityLabel} detected: ${dirName}. Reloading configuration...`); + + // Snapshot ids before reload so we can tell whether the new entity + // was successfully registered afterwards. + const idsBefore = new Set(getKnownIds(handoff)); + handoff.reload(); - knownComponents.add(dirName); + knownIds.add(dirName); - for (const id of Object.keys(handoff.runtimeConfig?.entries?.components ?? {})) { - knownComponents.add(id); + // Refresh from the post-reload runtime config so any ids that were + // discovered alongside the new entity are also marked as known. + const idsAfter = getKnownIds(handoff); + for (const id of idsAfter) { + knownIds.add(id); } await persistClientConfig(handoff); + + // Re-register file watchers so the new config file and any runtime + // files it introduces are covered going forward. This must happen even + // when the file failed to parse so that watchRuntimeConfiguration can + // trigger a retry the moment the file is saved with valid content. watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff)); watchRuntimeConfiguration(handoff, state); - await processComponents(handoff, dirName); - await runAllFinalizers(handoff, { patternRebuildComponentIds: [dirName] }); + + // If no new ids appeared after reload the file was empty or invalid. + // Skip the build — watchRuntimeConfiguration will fire when it is saved. + const hasNewIds = idsAfter.some((id) => !idsBefore.has(id)); + if (!hasNewIds) { + Logger.warn(`${entityLabel} "${dirName}" config is empty or incomplete — build will run automatically once the file is saved.`); + return; + } + + await onDetected(handoff, dirName); } catch (e) { - Logger.error('Error processing new component:', e); + Logger.error(`Error processing new ${entityLabel}:`, e); } }); }); }; + +/** + * Watches the parent component directories from config.entries.components for + * new components being added. When a new config file appears in a new + * subdirectory, reloads the runtime config, restarts the component and + * configuration watchers, then builds the new component and any patterns that + * reference it. + */ +export const watchComponentDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => { + watchEntityDirectories(handoff, state, chokidarConfig, { + getConfigPaths: (h) => h.config.entries?.components ?? [], + getKnownIds: (h) => Object.keys(h.runtimeConfig?.entries?.components ?? {}), + getWatcher: (s) => s.componentDirectoriesWatcher, + setWatcher: (s, w) => { s.componentDirectoriesWatcher = w; }, + scheduleKeyPrefix: 'newComponent', + entityLabel: 'component', + onDetected: async (handoff, dirName) => { + await processComponents(handoff, dirName); + await runAllFinalizers(handoff, { patternRebuildComponentIds: [dirName] }); + }, + }); +}; + +/** + * Watches the parent pattern directories from config.entries.patterns for new + * patterns being added. The rebuild sequence intentionally differs from + * watchComponentDirectories because the dependency arrow is reversed: a new + * pattern references existing components, so we rebuild only the preview + * segment of those components (to produce the __pattern_* HTML files that + * injectPatternPreviews registered) before composing the pattern itself. + */ +export const watchPatternDirectories = (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => { + watchEntityDirectories(handoff, state, chokidarConfig, { + getConfigPaths: (h) => h.config.entries?.patterns ?? [], + getKnownIds: (h) => Object.keys(h.runtimeConfig?.entries?.patterns ?? {}), + getWatcher: (s) => s.patternDirectoriesWatcher, + setWatcher: (s, w) => { s.patternDirectoriesWatcher = w; }, + scheduleKeyPrefix: 'newPattern', + entityLabel: 'pattern', + onDetected: async (handoff, dirName) => { + // Rebuild only the previews segment for each component the new pattern + // references. Their JS/CSS/structure are unchanged — only the new + // synthetic preview HTML files need to be generated so that buildPatterns + // can assemble the pattern from them. + const newPattern = handoff.runtimeConfig?.entries?.patterns?.[dirName]; + if (newPattern?.components?.length) { + for (const ref of newPattern.components) { + await processComponents(handoff, ref.id, ComponentSegment.Previews); + } + } + + // Build only the new pattern directly rather than going through + // runAllFinalizers, which would rebuild all patterns unnecessarily. + const newPatternId = newPattern?.id ?? dirName; + await buildPatterns(handoff, { onlyPatternIds: new Set([newPatternId]) }); + }, + }); +}; diff --git a/src/app-builder/watchers/utils.ts b/src/app-builder/watchers/utils.ts index 7d21590e..5bc69976 100644 --- a/src/app-builder/watchers/utils.ts +++ b/src/app-builder/watchers/utils.ts @@ -7,6 +7,8 @@ export interface WatcherState { runtimeComponentsWatcher: chokidar.FSWatcher | null; runtimeConfigurationWatcher: chokidar.FSWatcher | null; componentDirectoriesWatcher: chokidar.FSWatcher | null; + /** Watches entries.patterns parent directories for newly created pattern subdirectories. */ + patternDirectoriesWatcher: chokidar.FSWatcher | null; } /** diff --git a/src/app/components/NotFound.tsx b/src/app/components/NotFound.tsx new file mode 100644 index 00000000..81f3e160 --- /dev/null +++ b/src/app/components/NotFound.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +interface NotFoundProps { + title?: string; + description?: React.ReactNode; + linkHref?: string; + linkLabel?: string; +} + +const NotFound: React.FC = () => { + return ( +
+
404
+

Oops! Page not found.

+

+ This page doesn’t exist or has been moved. +
+ Please check the URL or return to the homepage. +

+
+ ); +}; + +export default NotFound; diff --git a/src/app/pages/[...slug]/index.tsx b/src/app/pages/[...slug]/index.tsx index 95967318..30c56ad8 100644 --- a/src/app/pages/[...slug]/index.tsx +++ b/src/app/pages/[...slug]/index.tsx @@ -1,7 +1,7 @@ import { GetStaticProps } from 'next'; import Head from 'next/head'; -import Link from 'next/link'; import { useRef } from 'react'; +import NotFound from '../../components/NotFound'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; @@ -48,21 +48,7 @@ export default function DocCatchAllPage({ content, menu, metadata, current, conf 404 - Page Not Found -
-
404
-

Oops! Page not found.

-

- Sorry, the page you are looking for does not exist or has been moved. -
- Please check the URL or return to the homepage. -

- - Go Home - -
+ ); } diff --git a/src/app/pages/system/pattern/[pattern]/index.tsx b/src/app/pages/system/pattern/[pattern]/index.tsx index faa2f3e6..d2c4d95c 100644 --- a/src/app/pages/system/pattern/[pattern]/index.tsx +++ b/src/app/pages/system/pattern/[pattern]/index.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import Layout from '../../../../components/Layout/Main'; +import NotFound from '../../../../components/NotFound'; import { MarkdownComponents } from '../../../../components/Markdown/MarkdownComponents'; import PrevNextNav from '../../../../components/Navigation/PrevNextNav'; import HeadersType from '../../../../components/Typography/Headers'; @@ -130,6 +131,7 @@ export const getStaticProps = async (context: { params: IParams }) => { const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nextPattern }: PatternPageProps) => { const [pattern, setPattern] = useState(undefined); const [fetchError, setFetchError] = useState(undefined); + const [patternNotFound, setPatternNotFound] = useState(false); const [iframeHeight, setIframeHeight] = useState('400px'); const iframeRef = React.useRef(null); @@ -145,13 +147,20 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex const controller = new AbortController(); setPattern(undefined); setFetchError(undefined); + setPatternNotFound(false); fetch(`${normalizedBasePath}/api/pattern/${id}.json`, { signal: controller.signal }) .then((res) => { + if (res.status === 404) { + setPatternNotFound(true); + return null; + } if (!res.ok) throw new Error(`Failed to load pattern: ${res.status} ${res.statusText}`); return res.json(); }) - .then((data) => setPattern(data as PatternListObject)) + .then((data) => { + if (data) setPattern(data as PatternListObject); + }) .catch((err) => { if (err.name !== 'AbortError') { setFetchError(err instanceof Error ? err.message : 'Failed to load pattern data.'); @@ -167,7 +176,27 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex } }, []); - if (fetchError) return

{fetchError}

; + if (patternNotFound) { + return ( + +
+ +
+
+ ); + } + + if (fetchError) { + return ( + +
+

Something went wrong

+

{fetchError}

+
+
+ ); + } + if (!pattern) return

Loading...

; return ( diff --git a/src/cache/build-cache.ts b/src/cache/build-cache.ts index 0b80af06..9ec0fc0c 100644 --- a/src/cache/build-cache.ts +++ b/src/cache/build-cache.ts @@ -226,6 +226,23 @@ export function getComponentFilePaths(handoff: Handoff, componentId: string): { files.push(matchingConfigPath); } + // Include every pattern config file whose declaration references this component. + // When a pattern is added or modified, injectPatternPreviews injects new + // __pattern_* synthetic preview keys onto the referenced component, changing + // what processComponents must render. Without tracking these files here, the + // cache would incorrectly consider the component unchanged and skip the preview + // rebuild, leaving buildPatterns unable to find the required HTML fragments. + const runtimePatterns = handoff.runtimeConfig?.entries?.patterns ?? {}; + for (const configPath of configPaths) { + const entry = handoff.getConfigFileEntry(configPath); + if (entry?.kind !== 'pattern') continue; + const pattern = runtimePatterns[entry.entityId]; + if (!pattern) continue; + if (pattern.components?.some((ref) => ref.id === componentId)) { + files.push(configPath); + } + } + return { files, templateDir }; } diff --git a/src/config/runtime.ts b/src/config/runtime.ts index b9371891..46798c11 100644 --- a/src/config/runtime.ts +++ b/src/config/runtime.ts @@ -284,7 +284,12 @@ export const initRuntimeConfig = (handoff: HandoffContext): [runtimeConfig: Runt fallbackId: patternBaseName, }); } catch (err) { - Logger.error(`Failed to read or parse pattern config: ${resolvedPatternConfigPath}`, err); + // Treat as a warning rather than an error: the file may be empty or + // mid-edit. It is already tracked in configFiles above, so + // watchRuntimeConfiguration will pick it up once the file is saved + // with valid content. + Logger.warn(`Pattern config skipped (incomplete or invalid) — will retry on next save: ${resolvedPatternConfigPath}`); + Logger.debug(`Pattern parse detail:`, err); continue; }