handoff-app 1.1.3 → 1.2.0-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.
Files changed (160) hide show
  1. package/config/config.template.js +21 -14
  2. package/config/config.template.ts +54 -0
  3. package/config/docs/system.md +2 -0
  4. package/dist/app-builder/build.js +31 -12
  5. package/dist/app-builder/config-diff/index.d.ts +4 -0
  6. package/dist/app-builder/config-diff/index.js +12 -0
  7. package/dist/app-builder/config-diff/registry.d.ts +9 -0
  8. package/dist/app-builder/config-diff/registry.js +30 -0
  9. package/dist/app-builder/config-diff/snapshot.d.ts +13 -0
  10. package/dist/app-builder/config-diff/snapshot.js +43 -0
  11. package/dist/app-builder/config-diff/strategies/pattern.d.ts +2 -0
  12. package/dist/app-builder/config-diff/strategies/pattern.js +106 -0
  13. package/dist/app-builder/config-diff/types.d.ts +33 -0
  14. package/dist/app-builder/config-diff/types.js +2 -0
  15. package/dist/app-builder/watchers/component.d.ts +15 -0
  16. package/dist/app-builder/watchers/component.js +69 -0
  17. package/dist/app-builder/watchers/utils.d.ts +14 -0
  18. package/dist/app-builder/watchers/utils.js +44 -0
  19. package/dist/app-builder/watchers.d.ts +10 -19
  20. package/dist/app-builder/watchers.js +65 -79
  21. package/dist/app-builder/websocket.js +10 -4
  22. package/dist/cache/build-cache.js +14 -5
  23. package/dist/cli/create.js +67 -23
  24. package/dist/cli/eject.js +48 -7
  25. package/dist/cli/make.js +29 -4
  26. package/dist/cli/scaffold.js +60 -19
  27. package/dist/cli.js +1 -1
  28. package/dist/config/helpers.d.ts +7 -0
  29. package/dist/config/helpers.js +31 -0
  30. package/dist/config/index.d.ts +2 -1
  31. package/dist/config/index.js +5 -1
  32. package/dist/config/loader.d.ts +10 -1
  33. package/dist/config/loader.js +92 -28
  34. package/dist/config/normalizers/declaration.d.ts +11 -0
  35. package/dist/config/normalizers/declaration.js +119 -0
  36. package/dist/config/normalizers/pattern.d.ts +8 -0
  37. package/dist/config/normalizers/pattern.js +35 -0
  38. package/dist/config/runtime.d.ts +9 -3
  39. package/dist/config/runtime.js +260 -33
  40. package/dist/declarations/helpers.d.ts +6 -0
  41. package/dist/declarations/helpers.js +19 -0
  42. package/dist/declarations/index.d.ts +2 -0
  43. package/dist/declarations/index.js +9 -0
  44. package/dist/declarations/types.d.ts +60 -0
  45. package/dist/declarations/types.js +2 -0
  46. package/dist/index.d.ts +12 -1
  47. package/dist/index.js +34 -5
  48. package/dist/pipeline/index.d.ts +1 -0
  49. package/dist/pipeline/index.js +3 -1
  50. package/dist/pipeline/patterns.d.ts +8 -0
  51. package/dist/pipeline/patterns.js +21 -0
  52. package/dist/transformers/docgen/index.js +12 -2
  53. package/dist/transformers/plugins/csf-render.js +170 -34
  54. package/dist/transformers/plugins/ssr-render.js +18 -2
  55. package/dist/transformers/preview/component/api.js +16 -7
  56. package/dist/transformers/preview/component/builder.js +61 -8
  57. package/dist/transformers/preview/component/css.d.ts +8 -0
  58. package/dist/transformers/preview/component/css.js +6 -4
  59. package/dist/transformers/preview/component/html.js +14 -5
  60. package/dist/transformers/preview/component/javascript.d.ts +0 -1
  61. package/dist/transformers/preview/component/javascript.js +25 -14
  62. package/dist/transformers/preview/component/previews.d.ts +9 -0
  63. package/dist/transformers/preview/component/previews.js +10 -0
  64. package/dist/transformers/preview/pattern/api.d.ts +11 -0
  65. package/dist/transformers/preview/pattern/api.js +79 -0
  66. package/dist/transformers/preview/pattern/builder.d.ts +17 -0
  67. package/dist/transformers/preview/pattern/builder.js +167 -0
  68. package/dist/transformers/preview/pattern/html.d.ts +19 -0
  69. package/dist/transformers/preview/pattern/html.js +94 -0
  70. package/dist/transformers/preview/types.d.ts +54 -0
  71. package/dist/transformers/utils/csf.d.ts +16 -0
  72. package/dist/transformers/utils/csf.js +30 -0
  73. package/dist/transformers/utils/index.d.ts +1 -0
  74. package/dist/transformers/utils/index.js +2 -0
  75. package/dist/transformers/utils/module.d.ts +5 -0
  76. package/dist/transformers/utils/module.js +26 -13
  77. package/dist/transformers/utils/usage.d.ts +23 -0
  78. package/dist/transformers/utils/usage.js +294 -0
  79. package/dist/transformers/utils/vite-logger.js +4 -4
  80. package/dist/transformers/vite-config.js +2 -0
  81. package/dist/types/config.d.ts +32 -7
  82. package/dist/types/preview.d.ts +2 -0
  83. package/dist/utils/duration.d.ts +2 -0
  84. package/dist/utils/duration.js +6 -0
  85. package/dist/utils/logger.d.ts +42 -5
  86. package/dist/utils/logger.js +135 -15
  87. package/docs/cli.md +3 -3
  88. package/package.json +3 -1
  89. package/src/app/components/Component/PageSliceResolver.tsx +6 -6
  90. package/src/app/components/Component/Preview.tsx +17 -12
  91. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +16 -6
  92. package/src/app/components/Markdown/CodeHighlight.tsx +60 -11
  93. package/src/app/components/util/index.ts +71 -2
  94. package/src/app/pages/system/pattern/[pattern]/index.tsx +346 -0
  95. package/src/app/pages/system/pattern/index.tsx +14 -0
  96. package/src/app-builder/README.md +37 -9
  97. package/src/app-builder/build.ts +34 -12
  98. package/src/app-builder/config-diff/index.ts +4 -0
  99. package/src/app-builder/config-diff/registry.ts +23 -0
  100. package/src/app-builder/config-diff/snapshot.ts +51 -0
  101. package/src/app-builder/config-diff/strategies/pattern.ts +80 -0
  102. package/src/app-builder/config-diff/types.ts +36 -0
  103. package/src/app-builder/watchers/component.ts +72 -0
  104. package/src/app-builder/watchers/utils.ts +44 -0
  105. package/src/app-builder/watchers.ts +74 -80
  106. package/src/app-builder/websocket.ts +16 -5
  107. package/src/cache/build-cache.ts +14 -5
  108. package/src/cli/create.ts +86 -25
  109. package/src/cli/eject.ts +29 -7
  110. package/src/cli/make.ts +32 -5
  111. package/src/cli/scaffold.ts +68 -21
  112. package/src/cli.ts +1 -1
  113. package/src/config/README.md +10 -1
  114. package/src/config/helpers.ts +43 -0
  115. package/src/config/index.ts +4 -1
  116. package/src/config/loader.ts +110 -31
  117. package/src/config/normalizers/declaration.ts +176 -0
  118. package/src/config/normalizers/pattern.ts +56 -0
  119. package/src/config/runtime.ts +317 -35
  120. package/src/declarations/helpers.ts +36 -0
  121. package/src/declarations/index.ts +19 -0
  122. package/src/declarations/types.ts +70 -0
  123. package/src/index.ts +47 -6
  124. package/src/pipeline/index.ts +1 -0
  125. package/src/pipeline/patterns.ts +12 -0
  126. package/src/transformers/README.md +2 -2
  127. package/src/transformers/docgen/index.ts +13 -2
  128. package/src/transformers/plugins/csf-render.ts +216 -49
  129. package/src/transformers/plugins/ssr-render.ts +23 -7
  130. package/src/transformers/preview/component/api.ts +27 -7
  131. package/src/transformers/preview/component/builder.ts +82 -9
  132. package/src/transformers/preview/component/css.ts +6 -5
  133. package/src/transformers/preview/component/html.ts +18 -5
  134. package/src/transformers/preview/component/javascript.ts +29 -19
  135. package/src/transformers/preview/component/previews.ts +12 -0
  136. package/src/transformers/preview/pattern/api.ts +83 -0
  137. package/src/transformers/preview/pattern/builder.ts +192 -0
  138. package/src/transformers/preview/pattern/html.ts +107 -0
  139. package/src/transformers/preview/types.ts +58 -1
  140. package/src/transformers/utils/csf.ts +48 -0
  141. package/src/transformers/utils/index.ts +3 -0
  142. package/src/transformers/utils/module.ts +33 -18
  143. package/src/transformers/utils/usage.ts +319 -0
  144. package/src/transformers/utils/vite-logger.ts +4 -4
  145. package/src/transformers/vite-config.ts +2 -0
  146. package/src/types/README.md +1 -1
  147. package/src/types/config.ts +33 -7
  148. package/src/types/preview.ts +2 -0
  149. package/src/utils/duration.ts +3 -0
  150. package/src/utils/logger.ts +143 -16
  151. package/templates/default/components/button/Button.jsx.tpl +64 -0
  152. package/templates/default/components/button/button.react.handoff.js.tpl +31 -0
  153. package/templates/default/components/button/button.react.handoff.ts.tpl +31 -0
  154. package/templates/default/handoff.config.js.tpl +29 -16
  155. package/templates/default/handoff.config.ts.tpl +196 -0
  156. package/templates/default/handoff.config.with-examples.js.tpl +28 -17
  157. package/templates/default/handoff.config.with-examples.ts.tpl +195 -0
  158. package/templates/default/package.json.tpl +1 -3
  159. package/handoff.config.js +0 -90
  160. package/templates/default/components/button/button.js.tpl +0 -62
@@ -0,0 +1,346 @@
1
+ 'use client';
2
+ import { PatternListObject } from '@handoff/transformers/preview/types';
3
+ import { startCase } from 'lodash';
4
+ import React, { useCallback, useEffect, useState } from 'react';
5
+ import ReactMarkdown from 'react-markdown';
6
+ import rehypeRaw from 'rehype-raw';
7
+ import Layout from '../../../../components/Layout/Main';
8
+ import { MarkdownComponents } from '../../../../components/Markdown/MarkdownComponents';
9
+ import PrevNextNav from '../../../../components/Navigation/PrevNextNav';
10
+ import HeadersType from '../../../../components/Typography/Headers';
11
+ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../../../components/ui/accordion';
12
+ import { Badge } from '../../../../components/ui/badge';
13
+ import { JsonTreeView } from '../../../../components/ui/json-tree-view';
14
+ import {
15
+ DocumentationProps,
16
+ fetchPatterns,
17
+ getClientRuntimeConfig,
18
+ getCurrentSection,
19
+ IParams,
20
+ staticBuildMenu,
21
+ } from '../../../../components/util';
22
+
23
+ type PatternNavItem = { id: string; title: string; description: string; group: string };
24
+
25
+ interface PatternPageProps extends DocumentationProps {
26
+ id: string;
27
+ previousPattern: PatternNavItem | null;
28
+ nextPattern: PatternNavItem | null;
29
+ }
30
+
31
+ const SYNTHETIC_PATTERN_PREVIEW_PREFIX = '__pattern_';
32
+
33
+ const isSyntheticPatternPreview = (previewKey?: string) => Boolean(previewKey?.startsWith(SYNTHETIC_PATTERN_PREVIEW_PREFIX));
34
+
35
+ const formatPreviewLabel = (component: PatternListObject['components'][number], index: number) => {
36
+ const previewKey = component.preview || component.resolvedPreview;
37
+
38
+ if (!previewKey) {
39
+ return {
40
+ title: 'Default preview',
41
+ subtitle: 'Uses the component default preview configuration.',
42
+ rawKey: null,
43
+ };
44
+ }
45
+
46
+ if (isSyntheticPatternPreview(previewKey)) {
47
+ return {
48
+ title: component.args ? 'Custom configuration' : `Generated preview ${index + 1}`,
49
+ subtitle: component.args ? 'Built from inline args for this pattern.' : 'Generated specifically for this pattern composition.',
50
+ rawKey: previewKey,
51
+ };
52
+ }
53
+
54
+ return {
55
+ title: startCase(previewKey),
56
+ subtitle: 'Named preview from the component docs.',
57
+ rawKey: previewKey,
58
+ };
59
+ };
60
+
61
+ const formatArgValue = (value: unknown) => {
62
+ if (typeof value === 'string') {
63
+ return value.length > 24 ? `${value.slice(0, 21)}...` : value;
64
+ }
65
+
66
+ if (typeof value === 'number' || typeof value === 'boolean') {
67
+ return String(value);
68
+ }
69
+
70
+ if (Array.isArray(value)) {
71
+ return `${value.length} item${value.length === 1 ? '' : 's'}`;
72
+ }
73
+
74
+ if (value && typeof value === 'object') {
75
+ return `${Object.keys(value as Record<string, unknown>).length} fields`;
76
+ }
77
+
78
+ if (value === null) {
79
+ return 'null';
80
+ }
81
+
82
+ return 'set';
83
+ };
84
+
85
+ const getArgSummary = (args?: Record<string, unknown>) => {
86
+ if (!args) return [];
87
+
88
+ return Object.entries(args).slice(0, 3);
89
+ };
90
+
91
+ export async function getStaticPaths() {
92
+ return {
93
+ paths: fetchPatterns()?.map((p) => ({ params: { pattern: p.id } })) ?? [],
94
+ fallback: false,
95
+ };
96
+ }
97
+
98
+ export const getStaticProps = async (context: { params: IParams }) => {
99
+ const { pattern: patternId } = context.params;
100
+ const patterns = fetchPatterns() ?? [];
101
+ const menu = staticBuildMenu();
102
+ const config = getClientRuntimeConfig();
103
+ const patternData = patterns.find((p) => p.id === patternId);
104
+
105
+ const sameGroupPatterns = patterns.filter((p) => p.group === patternData?.group);
106
+ const groupIndex = sameGroupPatterns.findIndex((p) => p.id === patternId);
107
+ const previousPattern = sameGroupPatterns[groupIndex - 1] ?? null;
108
+ const nextPattern = sameGroupPatterns[groupIndex + 1] ?? null;
109
+
110
+ const fallbackTitle = patternData?.title || startCase(patternId as string);
111
+
112
+ return {
113
+ props: {
114
+ id: patternId,
115
+ menu,
116
+ config,
117
+ current: getCurrentSection(menu, '/system') ?? [],
118
+ metadata: {
119
+ title: fallbackTitle,
120
+ description: patternData?.description || '',
121
+ metaTitle: `${fallbackTitle}${config?.app?.client ? ` | ${config.app.client} Design System` : ''}`,
122
+ metaDescription: patternData?.description || '',
123
+ },
124
+ previousPattern,
125
+ nextPattern,
126
+ },
127
+ };
128
+ };
129
+
130
+ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nextPattern }: PatternPageProps) => {
131
+ const [pattern, setPattern] = useState<PatternListObject | undefined>(undefined);
132
+ const [fetchError, setFetchError] = useState<string | undefined>(undefined);
133
+ const [iframeHeight, setIframeHeight] = useState('400px');
134
+ const iframeRef = React.useRef<HTMLIFrameElement>(null);
135
+
136
+ const appBasePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
137
+ const normalizedBasePath = appBasePath ? `/${appBasePath.replace(/^\/+|\/+$/g, '')}` : '';
138
+ const patternRoute = (patternId: string) => `${normalizedBasePath}/system/pattern/${patternId}`;
139
+
140
+ const previousLink = previousPattern ? { href: patternRoute(previousPattern.id), title: previousPattern.title } : null;
141
+ const nextLink = nextPattern ? { href: patternRoute(nextPattern.id), title: nextPattern.title } : null;
142
+ const invalidComponents = pattern?.components?.filter((comp) => comp.resolved === false) ?? [];
143
+
144
+ useEffect(() => {
145
+ const controller = new AbortController();
146
+ setPattern(undefined);
147
+ setFetchError(undefined);
148
+
149
+ fetch(`${normalizedBasePath}/api/pattern/${id}.json`, { signal: controller.signal })
150
+ .then((res) => {
151
+ if (!res.ok) throw new Error(`Failed to load pattern: ${res.status} ${res.statusText}`);
152
+ return res.json();
153
+ })
154
+ .then((data) => setPattern(data as PatternListObject))
155
+ .catch((err) => {
156
+ if (err.name !== 'AbortError') {
157
+ setFetchError(err instanceof Error ? err.message : 'Failed to load pattern data.');
158
+ }
159
+ });
160
+
161
+ return () => controller.abort();
162
+ }, [id, normalizedBasePath]);
163
+
164
+ const onIframeLoad = useCallback(() => {
165
+ if (iframeRef.current?.contentWindow?.document?.body) {
166
+ setIframeHeight(iframeRef.current.contentWindow.document.body.scrollHeight + 'px');
167
+ }
168
+ }, []);
169
+
170
+ if (fetchError) return <p className="p-4 text-red-500">{fetchError}</p>;
171
+ if (!pattern) return <p>Loading...</p>;
172
+
173
+ return (
174
+ <Layout config={config} menu={menu} current={current} metadata={metadata}>
175
+ <div className="flex flex-col gap-3 pb-14">
176
+ <small className="text-sm font-medium text-sky-600 dark:text-gray-300">Patterns</small>
177
+ <div className="flex flex-wrap items-center gap-3">
178
+ <HeadersType.H1>{metadata.title}</HeadersType.H1>
179
+ <Badge
180
+ variant="outline"
181
+ className="rounded-full border border-gray-200 px-3 py-1 text-[11px] font-medium text-gray-600 dark:border-gray-700 dark:text-gray-300"
182
+ >
183
+ {pattern.components?.length ?? 0} component{(pattern.components?.length ?? 0) === 1 ? '' : 's'}
184
+ </Badge>
185
+ </div>
186
+ {metadata.description && (
187
+ <div className="prose max-w-[800px] text-xl font-light leading-relaxed text-gray-600 dark:text-gray-300">
188
+ <ReactMarkdown components={MarkdownComponents} rehypePlugins={[rehypeRaw]}>
189
+ {metadata.description}
190
+ </ReactMarkdown>
191
+ </div>
192
+ )}
193
+ </div>
194
+
195
+ <div className="max-w-[1100px]">
196
+ {invalidComponents.length > 0 && (
197
+ <div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
198
+ {invalidComponents.length} pattern component{invalidComponents.length > 1 ? 's could' : ' could'} not be resolved and{' '}
199
+ {invalidComponents.length > 1 ? 'were' : 'was'} skipped.
200
+ </div>
201
+ )}
202
+
203
+ {/* Full-page pattern preview */}
204
+ <div className="rounded-lg border border-gray-200 dark:border-gray-900">
205
+ <div className="flex w-full items-center justify-between rounded-t-lg bg-gray-50 px-6 py-2 dark:bg-gray-800">
206
+ <p className="font-monospace text-[11px] text-accent-foreground">Pattern Preview</p>
207
+ </div>
208
+ <div className="dotted-bg w-full p-4">
209
+ {pattern.url ? (
210
+ <iframe
211
+ ref={iframeRef}
212
+ onLoad={onIframeLoad}
213
+ height={iframeHeight}
214
+ style={{
215
+ width: '100%',
216
+ height: iframeHeight,
217
+ display: 'block',
218
+ border: 'none',
219
+ }}
220
+ src={`${normalizedBasePath}/api/pattern/${pattern.url}`}
221
+ />
222
+ ) : (
223
+ <div className="flex items-center justify-center p-8 text-sm text-gray-500">No preview available for this pattern.</div>
224
+ )}
225
+ </div>
226
+ </div>
227
+
228
+ {/* Component list */}
229
+ {pattern.components && pattern.components.length > 0 && (
230
+ <div className="mt-8">
231
+ <div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
232
+ <div>
233
+ <HeadersType.H3>Components in this pattern</HeadersType.H3>
234
+ </div>
235
+ </div>
236
+
237
+ <div className="mt-5 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800">
238
+ {pattern.components.map((comp, i) => {
239
+ const previewMeta = formatPreviewLabel(comp, i);
240
+ const argSummary = getArgSummary(comp.args);
241
+ const hasArgs = Boolean(comp.args);
242
+
243
+ return (
244
+ <div
245
+ key={`${comp.id}-${i}`}
246
+ className="border-b border-gray-200 bg-white last:border-b-0 dark:border-gray-800 dark:bg-transparent"
247
+ >
248
+ <div className="grid gap-4 px-5 py-4 md:grid-cols-[minmax(0,220px)_minmax(0,1fr)] md:px-6">
249
+ <div className="flex flex-wrap items-center gap-3">
250
+ <span className="text-xs font-medium text-gray-400 dark:text-gray-500">{String(i + 1).padStart(2, '0')}</span>
251
+ <code className="font-mono text-[13px] text-gray-900 dark:text-gray-100">{comp.id}</code>
252
+ {comp.resolved === false && (
253
+ <Badge variant="warning" className="rounded-full px-2.5 py-0.5 text-[11px] font-medium">
254
+ Unresolved
255
+ </Badge>
256
+ )}
257
+ </div>
258
+
259
+ <div className="min-w-0">
260
+ {comp.resolved === false ? (
261
+ <div className="rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
262
+ Could not resolve component preview.
263
+ </div>
264
+ ) : (
265
+ <>
266
+ <div className="space-y-2">
267
+ <div>
268
+ <p className="text-[10px] font-medium uppercase tracking-[0.1em] text-gray-400 dark:text-gray-500">
269
+ Preview
270
+ </p>
271
+ <p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{previewMeta.title}</p>
272
+ </div>
273
+
274
+ {previewMeta.rawKey && !hasArgs && (
275
+ <div className="inline-flex max-w-full items-center gap-2 rounded-md bg-gray-50 px-2.5 py-1.5 dark:bg-gray-900">
276
+ <span className="text-[10px] font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">
277
+ Key
278
+ </span>
279
+ <code className="truncate font-mono text-xs text-gray-500 dark:text-gray-400">{previewMeta.rawKey}</code>
280
+ </div>
281
+ )}
282
+ </div>
283
+
284
+ {hasArgs ? (
285
+ <div className="mt-2">
286
+ <Accordion type="single" collapsible className="mt-1">
287
+ <AccordionItem value={`args-${i}`} className="border-none">
288
+ <div className="flex flex-wrap gap-2">
289
+ {argSummary.length > 0 && (
290
+ <dl className="flex flex-wrap gap-2">
291
+ {argSummary.map(([key, value]) => (
292
+ <div
293
+ key={key}
294
+ className="inline-flex max-w-full items-center gap-2 rounded-md bg-gray-50 px-2.5 py-1.5 dark:bg-gray-900"
295
+ >
296
+ <dt className="text-[10px] font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">
297
+ {key}
298
+ </dt>
299
+ <dd className="truncate text-xs text-gray-900 dark:text-gray-100">{formatArgValue(value)}</dd>
300
+ </div>
301
+ ))}
302
+ {Object.keys(comp.args ?? {}).length > argSummary.length && (
303
+ <div className="inline-flex max-w-full items-center rounded-md bg-gray-50 px-2.5 py-1.5 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400">
304
+ +{Object.keys(comp.args ?? {}).length - argSummary.length} more
305
+ </div>
306
+ )}
307
+ </dl>
308
+ )}
309
+
310
+ <AccordionTrigger className="inline-flex w-auto flex-none items-center gap-1 rounded-md bg-gray-50 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.12em] text-gray-400 no-underline hover:bg-gray-100 hover:text-gray-600 hover:no-underline dark:bg-gray-900 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-300 [&>svg]:h-3.5 [&>svg]:w-3.5">
311
+ All args
312
+ </AccordionTrigger>
313
+ </div>
314
+ <AccordionContent className="pt-2">
315
+ <JsonTreeView
316
+ data={comp.args}
317
+ defaultExpanded
318
+ maxInitialDepth={1}
319
+ className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-900"
320
+ />
321
+ </AccordionContent>
322
+ </AccordionItem>
323
+ </Accordion>
324
+ </div>
325
+ ) : (
326
+ <></>
327
+ )}
328
+ </>
329
+ )}
330
+ </div>
331
+ </div>
332
+ </div>
333
+ );
334
+ })}
335
+ </div>
336
+ </div>
337
+ )}
338
+
339
+ <hr className="mt-8" />
340
+ <PrevNextNav previous={previousLink} next={nextLink} />
341
+ </div>
342
+ </Layout>
343
+ );
344
+ };
345
+
346
+ export default PatternPage;
@@ -0,0 +1,14 @@
1
+ import { useRouter } from 'next/router';
2
+ import { useEffect } from 'react';
3
+
4
+ const PatternIndexRedirect = () => {
5
+ const router = useRouter();
6
+
7
+ useEffect(() => {
8
+ router.replace('/system');
9
+ }, [router]);
10
+
11
+ return null;
12
+ };
13
+
14
+ export default PatternIndexRedirect;
@@ -1,14 +1,42 @@
1
1
  # App Builder Module
2
2
 
3
- Handles building, watching, and serving the Next.js documentation application.
3
+ Builds and runs the Next.js documentation app from runtime config + generated preview output.
4
4
 
5
- ## Files
5
+ ## End-to-End Flow
6
6
 
7
- | File | Purpose |
7
+ 1. **Initialize app shell** (`build.ts`)
8
+ - Prepare `.handoff/<project>/app`, copy app sources, sync `public`, persist client config.
9
+
10
+ 2. **Build component artifacts**
11
+ - Production: `buildComponents` + `buildPatterns`.
12
+ - Watch mode: `processComponents(..., { useCache: true })` to skip unchanged components.
13
+
14
+ 3. **Build global bundles**
15
+ - Build main JS/CSS entry bundles when configured.
16
+
17
+ 4. **Run/watch**
18
+ - Production: run `next build` and publish static output.
19
+ - Watch: run `next dev` + WebSocket + chokidar watchers.
20
+
21
+ 5. **Incremental rebuild behavior** (`watchers.ts`)
22
+ - Source entry change -> targeted component segment rebuild.
23
+ - Main config/global entry change -> full component rebuild.
24
+ - Pattern config change -> diff-aware selective preview rebuild only for affected components.
25
+ - After component rebuilds -> run config-diff finalizers (e.g. pattern composition).
26
+
27
+ 6. **Event coalescing**
28
+ - Watchers share a single scheduler lock; rapid updates from the same source coalesce.
29
+
30
+ ## Key Files
31
+
32
+ | Path | Purpose |
8
33
  |------|---------|
9
- | `build.ts` | `buildApp()` — production build; `watchApp()` — dev server with watchers; `devApp()` — bare Next.js dev |
10
- | `websocket.ts` | `createWebSocketServer()` — WebSocket server for dev hot-reloading |
11
- | `paths.ts` | `getAppPath()`, `getWorkingPublicPath()`, `syncPublicFiles()` |
12
- | `client-config.ts` | `persistClientConfig()`, `generateTokensApi()` |
13
- | `watchers.ts` | File watchers for public dir, pages, global entries (SCSS/JS), runtime components, and config |
14
- | `index.ts` | Barrel re-exports |
34
+ | `build.ts` | `buildApp()`, `watchApp()`, `devApp()` orchestration |
35
+ | `watchers.ts` | Watcher orchestration and rebuild routing |
36
+ | `watchers/component.ts` | Runtime component path mapping + segment resolution helpers |
37
+ | `watchers/utils.ts` | Coalescing scheduler + watcher state |
38
+ | `config-diff/index.ts` | Diff strategy exports + registry/finalizers |
39
+ | `config-diff/strategies/pattern.ts` | Pattern snapshot/diff/apply strategy |
40
+ | `client-config.ts` | Persist client runtime config + token API payload |
41
+ | `paths.ts` | App/public path helpers + public sync |
42
+ | `websocket.ts` | Dev reload WebSocket server |
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import Handoff from '..';
5
5
  import { buildComponents } from '../pipeline/components';
6
+ import { buildPatterns } from '../pipeline/patterns';
6
7
  import { buildMainCss } from '../transformers/preview/component/css';
7
8
  import { buildMainJS } from '../transformers/preview/component/javascript';
8
9
  import processComponents from '../transformers/preview/component/builder';
@@ -90,9 +91,10 @@ const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<voi
90
91
  // Perform cleanup
91
92
  await cleanupAppDirectory(handoff);
92
93
 
93
- // Build components
94
+ // Build components, then patterns (patterns depend on component output)
94
95
  if (!skipComponents) {
95
96
  await buildComponents(handoff);
97
+ await buildPatterns(handoff);
96
98
  }
97
99
 
98
100
  // Prepare app
@@ -103,13 +105,16 @@ const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<voi
103
105
  // Build app
104
106
  const buildResult = spawn.sync('npx', ['next', 'build'], {
105
107
  cwd: appPath,
106
- stdio: 'inherit',
108
+ stdio: ['inherit', 'pipe', 'pipe'],
107
109
  env: {
108
110
  ...process.env,
109
111
  NODE_ENV: 'production',
110
112
  },
111
113
  });
112
114
 
115
+ Logger.childProcessBuffer(buildResult.stdout);
116
+ Logger.childProcessBuffer(buildResult.stderr);
117
+
113
118
  if (buildResult.status !== 0) {
114
119
  let errorMsg = `Next.js build failed with exit code ${buildResult.status}`;
115
120
  if (buildResult.error) {
@@ -143,6 +148,9 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
143
148
  await buildMainJS(handoff);
144
149
  await buildMainCss(handoff);
145
150
 
151
+ // Build patterns after components are ready
152
+ await buildPatterns(handoff);
153
+
146
154
  const appPath = await initializeProjectApp(handoff);
147
155
 
148
156
  // Persist client configuration
@@ -161,24 +169,32 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
161
169
  // create empty directory
162
170
  await fs.ensureDir(moduleOutput);
163
171
  }
172
+ Logger.info(`Starting Next.js dev server (Turbopack) at http://${hostname}:${port}…`);
173
+
164
174
  const nextProcess = spawn('npx', ['next', 'dev', '--turbopack', '--port', String(port)], {
165
175
  cwd: appPath,
166
- stdio: 'inherit',
176
+ stdio: ['inherit', 'pipe', 'pipe'],
167
177
  env: {
168
178
  ...process.env,
169
179
  NODE_ENV: 'development',
170
180
  },
171
181
  });
172
- Logger.success(`Ready on http://${hostname}:${port}`);
182
+ Logger.pipeChildStreams(nextProcess.stdout, nextProcess.stderr);
173
183
 
174
184
  nextProcess.on('error', (error) => {
175
- Logger.error(`Next.js dev process error: ${error}`);
185
+ Logger.error(`Next.js dev process failed to start: ${error}`);
176
186
  process.exit(1);
177
187
  });
178
188
 
179
- nextProcess.on('close', (code) => {
180
- Logger.success(`Next.js dev process closed with code ${code}`);
181
- process.exit(code);
189
+ nextProcess.on('close', (code, signal) => {
190
+ if (code === 0) {
191
+ Logger.success(`Next.js dev process exited normally`);
192
+ } else if (signal) {
193
+ Logger.warn(`Next.js dev process stopped (${signal})`);
194
+ } else {
195
+ Logger.error(`Next.js dev process exited with code ${code}`);
196
+ }
197
+ process.exit(code ?? 1);
182
198
  });
183
199
 
184
200
  const wss = await createWebSocketServer(handoff.config.app.ports?.websocket ?? 3001);
@@ -190,7 +206,8 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
190
206
  };
191
207
 
192
208
  const state: WatcherState = {
193
- debounce: false,
209
+ busy: false,
210
+ pendingHandlers: new Map(),
194
211
  runtimeComponentsWatcher: null,
195
212
  runtimeConfigurationWatcher: null,
196
213
  componentDirectoriesWatcher: null,
@@ -221,16 +238,21 @@ export const devApp = async (handoff: Handoff): Promise<void> => {
221
238
  // Persist client configuration
222
239
  await persistClientConfig(handoff);
223
240
 
224
- // Run
225
- const devResult = spawn.sync('npx', ['next', 'dev', '--turbopack', '--port', String(handoff.config.app.ports?.app ?? 3000)], {
241
+ const devPort = handoff.config.app.ports?.app ?? 3000;
242
+ Logger.info(`Starting Next.js dev server (Turbopack) on port ${devPort}…`);
243
+
244
+ const devResult = spawn.sync('npx', ['next', 'dev', '--turbopack', '--port', String(devPort)], {
226
245
  cwd: appPath,
227
- stdio: 'inherit',
246
+ stdio: ['inherit', 'pipe', 'pipe'],
228
247
  env: {
229
248
  ...process.env,
230
249
  NODE_ENV: 'development',
231
250
  },
232
251
  });
233
252
 
253
+ Logger.childProcessBuffer(devResult.stdout);
254
+ Logger.childProcessBuffer(devResult.stderr);
255
+
234
256
  if (devResult.status !== 0) {
235
257
  let errorMsg = `Next.js dev failed with exit code ${devResult.status}`;
236
258
  if (devResult.error) {
@@ -0,0 +1,4 @@
1
+ export type { ConfigDiffStrategy, FinalizeContext, RebuildHandle } from './types';
2
+ export { type MapSnapshot, diffMapSnapshots, stableStringify } from './snapshot';
3
+ export { patternDiffStrategy } from './strategies/pattern';
4
+ export { getAllStrategies, getStrategy, runAllFinalizers } from './registry';
@@ -0,0 +1,23 @@
1
+ import Handoff from '../..';
2
+ import { patternDiffStrategy } from './strategies/pattern';
3
+ import { ConfigDiffStrategy, FinalizeContext } from './types';
4
+
5
+ const strategies = new Map<string, ConfigDiffStrategy>([
6
+ [patternDiffStrategy.kind, patternDiffStrategy],
7
+ ]);
8
+
9
+ export const getStrategy = (kind: string): ConfigDiffStrategy | undefined =>
10
+ strategies.get(kind);
11
+
12
+ export const getAllStrategies = (): ConfigDiffStrategy[] =>
13
+ Array.from(strategies.values());
14
+
15
+ /**
16
+ * Runs every registered strategy finalizer.
17
+ * Called after component rebuilds so derived artifacts stay up-to-date.
18
+ */
19
+ export const runAllFinalizers = async (handoff: Handoff, context?: FinalizeContext): Promise<void> => {
20
+ for (const strategy of strategies.values()) {
21
+ await strategy.finalize(handoff, context);
22
+ }
23
+ };
@@ -0,0 +1,51 @@
1
+ /** outerKey -> Map<innerKey, serializedValue> */
2
+ export type MapSnapshot = Map<string, Map<string, string>>;
3
+
4
+ /**
5
+ * Deterministic JSON serializer that sorts object keys at every nesting level.
6
+ * Prevents false-positive diffs when identical values are constructed with
7
+ * different key insertion order across reloads.
8
+ */
9
+ export const stableStringify = (value: unknown): string =>
10
+ JSON.stringify(value, (_, v) =>
11
+ v && typeof v === 'object' && !Array.isArray(v)
12
+ ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)))
13
+ : v
14
+ );
15
+
16
+ /**
17
+ * Compares two MapSnapshots and returns the outer keys where the inner maps
18
+ * differ (added, removed, or values changed).
19
+ */
20
+ export const diffMapSnapshots = (
21
+ oldSnapshot: MapSnapshot,
22
+ newSnapshot: MapSnapshot
23
+ ): string[] => {
24
+ const affected = new Set<string>();
25
+
26
+ for (const [key, oldEntries] of oldSnapshot) {
27
+ const newEntries = newSnapshot.get(key);
28
+ if (!newEntries) {
29
+ affected.add(key);
30
+ continue;
31
+ }
32
+ if (oldEntries.size !== newEntries.size) {
33
+ affected.add(key);
34
+ continue;
35
+ }
36
+ for (const [subKey, oldValue] of oldEntries) {
37
+ if (newEntries.get(subKey) !== oldValue) {
38
+ affected.add(key);
39
+ break;
40
+ }
41
+ }
42
+ }
43
+
44
+ for (const key of newSnapshot.keys()) {
45
+ if (!oldSnapshot.has(key)) {
46
+ affected.add(key);
47
+ }
48
+ }
49
+
50
+ return Array.from(affected);
51
+ };