Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/app-builder/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
watchComponentDirectories,
watchGlobalEntries,
watchPages,
watchPatternDirectories,
watchPublicDirectory,
watchRuntimeComponents,
watchRuntimeConfiguration,
Expand Down Expand Up @@ -224,12 +225,14 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
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);
};
Expand Down
165 changes: 140 additions & 25 deletions src/app-builder/watchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -273,63 +274,177 @@ 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<void>;
}) => {
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);
}
}

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]) });
},
});
};
2 changes: 2 additions & 0 deletions src/app-builder/watchers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
24 changes: 24 additions & 0 deletions src/app/components/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';

interface NotFoundProps {
title?: string;
description?: React.ReactNode;
linkHref?: string;
linkLabel?: string;
}

const NotFound: React.FC<NotFoundProps> = () => {
return (
<div className="flex flex-col items-center">
<div className="mb-2 text-7xl font-bold text-gray-800 dark:text-white">404</div>
<h1 className="mb-4 text-2xl font-semibold text-gray-700 dark:text-gray-300">Oops! Page not found.</h1>
<p className="mb-6 max-w-md text-center text-gray-500 dark:text-gray-400">
This page doesn&rsquo;t exist or has been moved.
<br />
Please check the URL or return to the homepage.
</p>
</div>
);
};

export default NotFound;
18 changes: 2 additions & 16 deletions src/app/pages/[...slug]/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,21 +48,7 @@ export default function DocCatchAllPage({ content, menu, metadata, current, conf
<title>404 - Page Not Found</title>
<meta name="description" content="Page Not Found" />
</Head>
<div className="flex flex-col items-center">
<div className="mb-2 text-7xl font-bold text-gray-800 dark:text-white">404</div>
<h1 className="mb-4 text-2xl font-semibold text-gray-700 dark:text-gray-300">Oops! Page not found.</h1>
<p className="mb-6 max-w-md text-center text-gray-500 dark:text-gray-400">
Sorry, the page you are looking for does not exist or has been moved.
<br />
Please check the URL or return to the homepage.
</p>
<Link
href="/"
className="rounded-md bg-blue-600 px-6 py-2 font-medium text-white shadow-md transition-colors duration-200 hover:bg-blue-700"
>
Go Home
</Link>
</div>
<NotFound />
</div>
);
}
Expand Down
33 changes: 31 additions & 2 deletions src/app/pages/system/pattern/[pattern]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<PatternListObject | undefined>(undefined);
const [fetchError, setFetchError] = useState<string | undefined>(undefined);
const [patternNotFound, setPatternNotFound] = useState(false);
const [iframeHeight, setIframeHeight] = useState('400px');
const iframeRef = React.useRef<HTMLIFrameElement>(null);

Expand All @@ -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.');
Expand All @@ -167,7 +176,27 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex
}
}, []);

if (fetchError) return <p className="p-4 text-red-500">{fetchError}</p>;
if (patternNotFound) {
return (
<Layout config={config} menu={menu} current={current} metadata={{ ...metadata, title: '404 - Page Not Found', metaTitle: '404 - Page Not Found' }}>
<div className="flex min-h-[60vh] flex-col items-center justify-center">
<NotFound />
</div>
</Layout>
);
}

if (fetchError) {
return (
<Layout config={config} menu={menu} current={current} metadata={metadata}>
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-2">
<p className="text-lg font-semibold text-gray-700 dark:text-gray-300">Something went wrong</p>
<p className="text-sm text-red-500">{fetchError}</p>
</div>
</Layout>
);
}

if (!pattern) return <p>Loading...</p>;

return (
Expand Down
17 changes: 17 additions & 0 deletions src/cache/build-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
Loading