create-nativecore 0.1.1 → 0.2.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 (175) hide show
  1. package/README.md +6 -14
  2. package/bin/index.mjs +403 -431
  3. package/package.json +3 -2
  4. package/template/.env.example +28 -0
  5. package/template/.htmlhintrc +14 -0
  6. package/template/api/data/dashboard.json +11 -0
  7. package/template/api/data/users.json +18 -0
  8. package/template/api/mockApi.js +161 -0
  9. package/template/assets/icon.svg +13 -0
  10. package/template/assets/logo.svg +25 -0
  11. package/template/eslint.config.js +94 -0
  12. package/template/index.html +137 -0
  13. package/template/manifest.json +19 -0
  14. package/template/public/.well-known/security.txt +9 -0
  15. package/template/public/_headers +24 -0
  16. package/template/public/_redirects +14 -0
  17. package/template/public/assets/icon.svg +13 -0
  18. package/template/public/assets/logo.svg +25 -0
  19. package/template/public/manifest.json +19 -0
  20. package/template/public/robots.txt +13 -0
  21. package/template/public/sitemap.xml +27 -0
  22. package/template/scripts/build-for-bots.mjs +121 -0
  23. package/template/scripts/convert-to-ts.mjs +106 -0
  24. package/template/scripts/fix-encoding.mjs +38 -0
  25. package/template/scripts/fix-svg-paths.mjs +32 -0
  26. package/template/scripts/generate-cf-router.mjs +52 -0
  27. package/template/scripts/inject-dev-tools.mjs +41 -0
  28. package/template/scripts/inject-version.mjs +65 -0
  29. package/template/scripts/make-component.mjs +445 -0
  30. package/template/scripts/make-component.mjs.backup +432 -0
  31. package/template/scripts/make-controller.mjs +119 -0
  32. package/template/scripts/make-core-component.mjs +303 -0
  33. package/template/scripts/make-view.mjs +346 -0
  34. package/template/scripts/minify.mjs +71 -0
  35. package/template/scripts/prepare-static-assets.mjs +141 -0
  36. package/template/scripts/prompt-bot-build.mjs +223 -0
  37. package/template/scripts/remove-component.mjs +170 -0
  38. package/template/scripts/remove-core-component.mjs +156 -0
  39. package/template/scripts/remove-dev.mjs +13 -0
  40. package/template/scripts/remove-view.mjs +200 -0
  41. package/template/scripts/strip-dev-blocks.mjs +30 -0
  42. package/template/scripts/watch-compile.mjs +69 -0
  43. package/template/server.js +1066 -0
  44. package/template/src/app.ts +115 -0
  45. package/template/src/components/appRegistry.ts +8 -0
  46. package/template/src/components/core/app-footer.ts +27 -0
  47. package/template/src/components/core/app-header.ts +175 -0
  48. package/template/src/components/core/app-sidebar.ts +238 -0
  49. package/template/src/components/core/loading-spinner.ts +25 -0
  50. package/template/src/components/core/nc-a.ts +313 -0
  51. package/template/src/components/core/nc-accordion.ts +186 -0
  52. package/template/src/components/core/nc-alert.ts +153 -0
  53. package/template/src/components/core/nc-animation.ts +1150 -0
  54. package/template/src/components/core/nc-autocomplete.ts +271 -0
  55. package/template/src/components/core/nc-avatar-group.ts +113 -0
  56. package/template/src/components/core/nc-avatar.ts +148 -0
  57. package/template/src/components/core/nc-badge.ts +86 -0
  58. package/template/src/components/core/nc-bottom-nav.ts +214 -0
  59. package/template/src/components/core/nc-breadcrumb.ts +96 -0
  60. package/template/src/components/core/nc-button.ts +307 -0
  61. package/template/src/components/core/nc-card.ts +160 -0
  62. package/template/src/components/core/nc-checkbox.ts +282 -0
  63. package/template/src/components/core/nc-chip.ts +115 -0
  64. package/template/src/components/core/nc-code.ts +314 -0
  65. package/template/src/components/core/nc-collapsible.ts +154 -0
  66. package/template/src/components/core/nc-color-picker.ts +268 -0
  67. package/template/src/components/core/nc-copy-button.ts +119 -0
  68. package/template/src/components/core/nc-date-picker.ts +443 -0
  69. package/template/src/components/core/nc-div.ts +280 -0
  70. package/template/src/components/core/nc-divider.ts +81 -0
  71. package/template/src/components/core/nc-drawer.ts +230 -0
  72. package/template/src/components/core/nc-dropdown.ts +178 -0
  73. package/template/src/components/core/nc-empty-state.ts +134 -0
  74. package/template/src/components/core/nc-file-upload.ts +354 -0
  75. package/template/src/components/core/nc-form.ts +312 -0
  76. package/template/src/components/core/nc-image.ts +184 -0
  77. package/template/src/components/core/nc-input.ts +383 -0
  78. package/template/src/components/core/nc-kbd.ts +48 -0
  79. package/template/src/components/core/nc-menu-item.ts +193 -0
  80. package/template/src/components/core/nc-menu.ts +376 -0
  81. package/template/src/components/core/nc-modal.ts +238 -0
  82. package/template/src/components/core/nc-nav-item.ts +151 -0
  83. package/template/src/components/core/nc-number-input.ts +350 -0
  84. package/template/src/components/core/nc-otp-input.ts +235 -0
  85. package/template/src/components/core/nc-pagination.ts +178 -0
  86. package/template/src/components/core/nc-popover.ts +260 -0
  87. package/template/src/components/core/nc-progress-circular.ts +119 -0
  88. package/template/src/components/core/nc-progress.ts +134 -0
  89. package/template/src/components/core/nc-radio.ts +235 -0
  90. package/template/src/components/core/nc-rating.ts +266 -0
  91. package/template/src/components/core/nc-rich-text.ts +283 -0
  92. package/template/src/components/core/nc-scroll-top.ts +116 -0
  93. package/template/src/components/core/nc-select.ts +452 -0
  94. package/template/src/components/core/nc-skeleton.ts +107 -0
  95. package/template/src/components/core/nc-slider.ts +285 -0
  96. package/template/src/components/core/nc-snackbar.ts +230 -0
  97. package/template/src/components/core/nc-splash.ts +343 -0
  98. package/template/src/components/core/nc-stepper.ts +247 -0
  99. package/template/src/components/core/nc-switch.ts +281 -0
  100. package/template/src/components/core/nc-tab-item.ts +138 -0
  101. package/template/src/components/core/nc-table.ts +279 -0
  102. package/template/src/components/core/nc-tabs.ts +554 -0
  103. package/template/src/components/core/nc-tag-input.ts +279 -0
  104. package/template/src/components/core/nc-textarea.ts +216 -0
  105. package/template/src/components/core/nc-time-picker.ts +438 -0
  106. package/template/src/components/core/nc-timeline.ts +186 -0
  107. package/template/src/components/core/nc-tooltip.ts +143 -0
  108. package/template/src/components/frameworkRegistry.ts +68 -0
  109. package/template/src/components/preloadRegistry.ts +28 -0
  110. package/template/src/components/registry.ts +8 -0
  111. package/template/src/components/ui/dashboard-signal-lab.ts +284 -0
  112. package/template/src/constants/apiEndpoints.ts +27 -0
  113. package/template/src/constants/errorMessages.ts +23 -0
  114. package/template/src/constants/index.ts +8 -0
  115. package/template/src/constants/routePaths.ts +15 -0
  116. package/template/src/constants/storageKeys.ts +18 -0
  117. package/template/src/controllers/dashboard.controller.ts +200 -0
  118. package/template/src/controllers/home.controller.ts +21 -0
  119. package/template/src/controllers/index.ts +11 -0
  120. package/template/src/controllers/login.controller.ts +131 -0
  121. package/template/src/core/component.ts +354 -0
  122. package/template/src/core/errorHandler.ts +85 -0
  123. package/template/src/core/gpu-animation.ts +604 -0
  124. package/template/src/core/http.ts +173 -0
  125. package/template/src/core/lazyComponents.ts +90 -0
  126. package/template/src/core/router.ts +653 -0
  127. package/template/src/core/signals.ts +146 -0
  128. package/template/src/core/state.ts +248 -0
  129. package/template/src/dev/component-editor.ts +1363 -0
  130. package/template/src/dev/component-overlay.ts +278 -0
  131. package/template/src/dev/context-menu.ts +223 -0
  132. package/template/src/dev/denc-tools.ts +250 -0
  133. package/template/src/dev/hmr.ts +189 -0
  134. package/template/src/dev/nfbs.code-workspace +27 -0
  135. package/template/src/dev/outline-panel.ts +1247 -0
  136. package/template/src/middleware/auth.middleware.ts +23 -0
  137. package/template/src/routes/routes.ts +38 -0
  138. package/template/src/services/api.service.ts +394 -0
  139. package/template/src/services/auth.service.ts +176 -0
  140. package/template/src/services/index.ts +8 -0
  141. package/template/src/services/logger.service.ts +74 -0
  142. package/template/src/services/storage.service.ts +88 -0
  143. package/template/src/stores/appStore.ts +57 -0
  144. package/template/src/stores/uiStore.ts +36 -0
  145. package/template/src/styles/core-variables.css +219 -0
  146. package/template/src/styles/core.css +710 -0
  147. package/template/src/styles/main.css +3164 -0
  148. package/template/src/styles/variables.css +152 -0
  149. package/template/src/types/global.d.ts +47 -0
  150. package/template/src/utils/cacheBuster.ts +20 -0
  151. package/template/src/utils/dom.ts +149 -0
  152. package/template/src/utils/events.ts +203 -0
  153. package/template/src/utils/form.ts +176 -0
  154. package/template/src/utils/formatters.ts +169 -0
  155. package/template/src/utils/helpers.ts +195 -0
  156. package/template/src/utils/markdown.ts +307 -0
  157. package/template/src/utils/sidebar.ts +96 -0
  158. package/template/src/utils/smoothScroll.ts +85 -0
  159. package/template/src/utils/templates.ts +23 -0
  160. package/template/src/utils/validation.ts +73 -0
  161. package/template/src/views/protected/dashboard.html +293 -0
  162. package/template/src/views/public/home.html +150 -0
  163. package/template/src/views/public/login.html +102 -0
  164. package/template/tests/unit/component.test.ts +87 -0
  165. package/template/tests/unit/computed.test.ts +79 -0
  166. package/template/tests/unit/form.test.ts +68 -0
  167. package/template/tests/unit/formatters.test.ts +49 -0
  168. package/template/tests/unit/lazy-components.test.ts +59 -0
  169. package/template/tests/unit/markdown.test.ts +62 -0
  170. package/template/tests/unit/router.test.ts +112 -0
  171. package/template/tests/unit/signals.test.ts +54 -0
  172. package/template/tests/unit/validation.test.ts +50 -0
  173. package/template/tsconfig.build.json +21 -0
  174. package/template/tsconfig.json +51 -0
  175. package/template/vitest.config.ts +36 -0
@@ -0,0 +1,1066 @@
1
+ /**
2
+ * Simple SPA Development Server
3
+ * Serves index.html for all routes (except static assets)
4
+ * Includes mock API endpoints + Hot Module Replacement (HMR)
5
+ */
6
+
7
+ import http from 'http';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { WebSocketServer } from 'ws';
12
+ import * as mockApi from './api/mockApi.js';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+
17
+ const PORT = 8000;
18
+ const DEV_REMOTE_API_ORIGIN = process.env.DEV_REMOTE_API_ORIGIN || '';
19
+ const DEV_REMOTE_AUTH_LOGIN_URL = process.env.DEV_REMOTE_AUTH_LOGIN_URL || (DEV_REMOTE_API_ORIGIN ? `${DEV_REMOTE_API_ORIGIN}/auth/login` : '');
20
+ const HMR_PORT = 8001;
21
+ const ROOT_DIR = __dirname;
22
+
23
+ const MIME_TYPES = {
24
+ '.html': 'text/html',
25
+ '.css': 'text/css',
26
+ '.js': 'text/javascript',
27
+ '.ts': 'text/javascript',
28
+ '.md': 'text/markdown; charset=utf-8',
29
+ '.json': 'application/json',
30
+ '.png': 'image/png',
31
+ '.jpg': 'image/jpeg',
32
+ '.gif': 'image/gif',
33
+ '.svg': 'image/svg+xml',
34
+ '.ico': 'image/x-icon'
35
+ };
36
+
37
+ // Parse JSON body
38
+ function parseBody(req) {
39
+ return new Promise((resolve, reject) => {
40
+ let body = '';
41
+ req.on('data', chunk => {
42
+ body += chunk.toString();
43
+ });
44
+ req.on('end', () => {
45
+ try {
46
+ resolve(body ? JSON.parse(body) : {});
47
+ } catch (e) {
48
+ reject(e);
49
+ }
50
+ });
51
+ });
52
+ }
53
+
54
+ // ============================================
55
+ // DEV TOOLS: Component Metadata Parser
56
+ // ============================================
57
+
58
+ /**
59
+ * Get component metadata by parsing the source file
60
+ */
61
+ async function getComponentMetadata(tagName) {
62
+ // Find the component file
63
+ const possiblePaths = [
64
+ path.join(ROOT_DIR, 'src/components/ui', `${tagName}.ts`),
65
+ path.join(ROOT_DIR, 'src/components/core', `${tagName}.ts`),
66
+ path.join(ROOT_DIR, 'src/components', `${tagName}.ts`)
67
+ ];
68
+
69
+ let filePath = null;
70
+ for (const p of possiblePaths) {
71
+ if (fs.existsSync(p)) {
72
+ filePath = p;
73
+ break;
74
+ }
75
+ }
76
+
77
+ if (!filePath) {
78
+ const componentDirs = [
79
+ path.join(ROOT_DIR, 'src/components/core'),
80
+ path.join(ROOT_DIR, 'src/components/ui'),
81
+ path.join(ROOT_DIR, 'src/components'),
82
+ ];
83
+
84
+ const defineComponentPatterns = [
85
+ `defineComponent('${tagName}'`,
86
+ `defineComponent("${tagName}"`,
87
+ ];
88
+
89
+ for (const dir of componentDirs) {
90
+ if (!fs.existsSync(dir)) continue;
91
+
92
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
93
+ for (const entry of entries) {
94
+ if (!entry.isFile() || !entry.name.endsWith('.ts')) continue;
95
+
96
+ const candidatePath = path.join(dir, entry.name);
97
+ const candidateSource = fs.readFileSync(candidatePath, 'utf-8');
98
+
99
+ if (defineComponentPatterns.some(pattern => candidateSource.includes(pattern))) {
100
+ filePath = candidatePath;
101
+ break;
102
+ }
103
+ }
104
+
105
+ if (filePath) break;
106
+ }
107
+ }
108
+
109
+ if (!filePath) {
110
+ return null;
111
+ }
112
+
113
+ console.log(`[DEBUG] Reading component file: ${filePath}`);
114
+
115
+ const sourceCode = fs.readFileSync(filePath, 'utf-8');
116
+ const lines = sourceCode.split('\n');
117
+
118
+ // Parse class name
119
+ const classMatch = sourceCode.match(/export class (\w+) extends Component/);
120
+ const className = classMatch ? classMatch[1] : 'Unknown';
121
+
122
+ // Parse attributes from getAttribute calls
123
+ const attributes = [];
124
+ const attrRegex = /this\.getAttribute\(['"](\w+)['"]\)/g;
125
+ let attrMatch;
126
+ while ((attrMatch = attrRegex.exec(sourceCode)) !== null) {
127
+ const name = attrMatch[1];
128
+ const lineIndex = sourceCode.substring(0, attrMatch.index).split('\n').length;
129
+
130
+ let type = 'string';
131
+ let variantOptions = null;
132
+
133
+ const contextLine = lines[lineIndex - 1] || '';
134
+ if (contextLine.includes('parseInt') || contextLine.includes('parseFloat') || contextLine.includes('Number(')) {
135
+ type = 'number';
136
+ }
137
+ if (sourceCode.includes(`hasAttribute('${name}')`)) {
138
+ type = 'boolean';
139
+ }
140
+
141
+ // Check for dropdown options for ANY attribute (excluding known string-only)
142
+ if (name !== 'href' && name !== 'src' && name !== 'alt' && name !== 'title' && name !== 'class' && name !== 'id') {
143
+ variantOptions = extractVariantOptions(sourceCode, name);
144
+ if (variantOptions && variantOptions.length > 0) {
145
+ type = 'variant';
146
+ }
147
+ }
148
+
149
+ if (!attributes.find(a => a.name === name)) {
150
+ attributes.push({ name, type, defaultValue: '', currentValue: '', line: lineIndex, variantOptions });
151
+ }
152
+ }
153
+
154
+ // Parse CSS variables from template
155
+ const cssVariables = [];
156
+ const cssVarRegex = /--([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
157
+ let cssMatch;
158
+ while ((cssMatch = cssVarRegex.exec(sourceCode)) !== null) {
159
+ const name = `--${cssMatch[1]}`;
160
+ const defaultValue = cssMatch[2].trim();
161
+ const lineIndex = sourceCode.substring(0, cssMatch.index).split('\n').length;
162
+ cssVariables.push({ name, defaultValue, currentValue: defaultValue, line: lineIndex });
163
+ }
164
+
165
+ // Parse :host styles
166
+ const hostStyles = [];
167
+ const hostMatch = sourceCode.match(/:host\s*\{([^}]+)\}/);
168
+ if (hostMatch) {
169
+ const hostContent = hostMatch[1];
170
+ const styleRegex = /([a-z-]+)\s*:\s*([^;]+);/gi;
171
+ let styleMatch;
172
+ while ((styleMatch = styleRegex.exec(hostContent)) !== null) {
173
+ const prop = styleMatch[1].trim();
174
+ const value = styleMatch[2].trim();
175
+ // Skip CSS variables (already captured)
176
+ if (!prop.startsWith('--')) {
177
+ hostStyles.push({ property: prop, value });
178
+ }
179
+ }
180
+ }
181
+
182
+ // Parse computed styles from template (common patterns)
183
+ const inlineStyles = [];
184
+ const styleAttrRegex = /style\s*=\s*["']([^"']+)["']/g;
185
+ let inlineMatch;
186
+ while ((inlineMatch = styleAttrRegex.exec(sourceCode)) !== null) {
187
+ const styleContent = inlineMatch[1];
188
+ const props = styleContent.split(';').filter(s => s.trim());
189
+ props.forEach(p => {
190
+ const [prop, value] = p.split(':').map(s => s.trim());
191
+ if (prop && value && !inlineStyles.find(s => s.property === prop)) {
192
+ inlineStyles.push({ property: prop, value });
193
+ }
194
+ });
195
+ }
196
+
197
+ // Detect if component uses Shadow DOM
198
+ const usesShadowDOM = sourceCode.includes('static useShadowDOM = true') ||
199
+ sourceCode.includes('this.attachShadow');
200
+
201
+ // Detect observed attributes
202
+ const observedAttrsMatch = sourceCode.match(/static get observedAttributes\(\)\s*\{\s*return\s*\[([^\]]+)\]/);
203
+ console.log(`[DEBUG] observedAttributes match for ${tagName}:`, observedAttrsMatch ? observedAttrsMatch[1] : 'NOT FOUND');
204
+ if (observedAttrsMatch) {
205
+ const attrNames = observedAttrsMatch[1].match(/['"]([^'"]+)['"]/g);
206
+ if (attrNames) {
207
+ attrNames.forEach(name => {
208
+ const cleanName = name.replace(/['"]/g, '');
209
+ if (!attributes.find(a => a.name === cleanName)) {
210
+ // Determine attribute type
211
+ let attrType = 'string';
212
+ let variantOptions = null;
213
+
214
+ // Boolean attributes
215
+ const booleanAttrs = ['disabled', 'readonly', 'required', 'checked', 'selected', 'hidden', 'loading'];
216
+ if (booleanAttrs.includes(cleanName)) {
217
+ attrType = 'boolean';
218
+ }
219
+ // Try to extract dropdown options for ANY attribute
220
+ else if (cleanName !== 'href' && cleanName !== 'src' && cleanName !== 'class' && cleanName !== 'id') {
221
+ console.log(`[DEBUG] Extracting options for ${cleanName}...`);
222
+ variantOptions = extractVariantOptions(sourceCode, cleanName);
223
+ console.log(`[DEBUG] Extracted options for ${cleanName}:`, variantOptions);
224
+ if (variantOptions && variantOptions.length > 0) {
225
+ attrType = 'variant';
226
+ }
227
+ }
228
+ // Number attributes
229
+ if (['count', 'max', 'min', 'step', 'duration', 'delay', 'index'].includes(cleanName)) {
230
+ attrType = 'number';
231
+ }
232
+
233
+ attributes.push({
234
+ name: cleanName,
235
+ type: attrType,
236
+ defaultValue: '',
237
+ currentValue: '',
238
+ line: 0,
239
+ variantOptions
240
+ });
241
+
242
+ console.warn(`[DEBUG] Added attribute: ${cleanName}, type: ${attrType}, options:`, variantOptions);
243
+ }
244
+ });
245
+ }
246
+ }
247
+
248
+ return {
249
+ tagName,
250
+ filePath: filePath.replace(ROOT_DIR + path.sep, '').replace(/\\/g, '/'),
251
+ absoluteFilePath: filePath,
252
+ className,
253
+ attributes,
254
+ cssVariables,
255
+ hostStyles,
256
+ inlineStyles,
257
+ usesShadowDOM,
258
+ slots: [],
259
+ sourceCode
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Extract variant options from component source
265
+ * Priority: 1) static attributeOptions, 2) CSS patterns, 3) code patterns
266
+ */
267
+ function extractVariantOptions(sourceCode, attributeName) {
268
+ const options = new Set();
269
+
270
+ // PRIORITY 1: Check for static attributeOptions property
271
+ const attributeOptionsRegex = /static\s+attributeOptions\s*=\s*\{([^}]+)\}/s;
272
+ const attributeOptionsMatch = sourceCode.match(attributeOptionsRegex);
273
+
274
+ if (attributeOptionsMatch) {
275
+ const optionsBlock = attributeOptionsMatch[1];
276
+ // Match the specific attribute and its array
277
+ const attrRegex = new RegExp(`['"]?${attributeName.replace('-', '[\\-_]?')}['"]?\\s*:\\s*\\[([^\\]]+)\\]`, 'i');
278
+ const attrMatch = optionsBlock.match(attrRegex);
279
+
280
+ if (attrMatch) {
281
+ const values = attrMatch[1].match(/['"]([^'"]+)['"]/g);
282
+ if (values) {
283
+ values.forEach(v => options.add(v.replace(/['"]/g, '')));
284
+ console.log(`[DEBUG] Found attributeOptions for ${attributeName}:`, Array.from(options));
285
+ return options.size > 0 ? Array.from(options) : null; // Don't sort - preserve order
286
+ }
287
+ }
288
+ }
289
+
290
+ // PRIORITY 2 & 3: Fallback to CSS/code pattern detection
291
+ const sizeKeywords = ['sm', 'md', 'lg', 'xl', 'xs', 'small', 'medium', 'large', 'tiny', 'huge'];
292
+ const variantKeywords = ['primary', 'secondary', 'success', 'danger', 'warning', 'info',
293
+ 'light', 'dark', 'outline', 'ghost', 'link', 'text', 'error'];
294
+ const positionKeywords = ['left', 'right', 'top', 'bottom', 'center', 'start', 'end'];
295
+
296
+ if (attributeName === 'size') {
297
+ // Match patterns like: .nc-btn-sm, .size-lg, .small, etc.
298
+ const sizeRegex = new RegExp(`\\.(?:[a-z]+-)?(?:btn-|size-)?(${sizeKeywords.join('|')})\\s*\\{`, 'gi');
299
+ let match;
300
+ while ((match = sizeRegex.exec(sourceCode)) !== null) {
301
+ const size = match[1].toLowerCase();
302
+ options.add(size);
303
+ }
304
+
305
+ // Match :host([size="sm"]) or :host([size='lg'])
306
+ const hostSizeRegex = new RegExp(`:host\\(\\[size=["'](${sizeKeywords.join('|')})["']\\]\\)`, 'gi');
307
+ let hostMatch;
308
+ while ((hostMatch = hostSizeRegex.exec(sourceCode)) !== null) {
309
+ const size = hostMatch[1].toLowerCase();
310
+ options.add(size);
311
+ }
312
+
313
+ // Also check for comments like /* Sizes */ or /* Size: sm, md, lg */
314
+ const commentRegex = /\/\*\s*Sizes?\s*:?\s*\*\/[\s\S]*?(?=\/\*|$)/gi;
315
+ let commentMatch;
316
+ while ((commentMatch = commentRegex.exec(sourceCode)) !== null) {
317
+ const section = commentMatch[0];
318
+ sizeKeywords.forEach(keyword => {
319
+ if (section.toLowerCase().includes(keyword)) {
320
+ options.add(keyword);
321
+ }
322
+ });
323
+ }
324
+
325
+ console.log(`[DEBUG] Size options found for ${attributeName}:`, Array.from(options));
326
+ } else if (attributeName === 'variant') {
327
+ // Match patterns like: .nc-btn-primary, .variant-success, .btn-danger, etc.
328
+ const variantRegex = new RegExp(`\\.(?:[a-z]+-)?(?:btn-|variant-)?(${variantKeywords.join('|')})\\s*\\{`, 'gi');
329
+ let match;
330
+
331
+ while ((match = variantRegex.exec(sourceCode)) !== null) {
332
+ const variant = match[1].toLowerCase();
333
+ options.add(variant);
334
+ }
335
+
336
+ // Match :host([variant="primary"]) or :host([variant='primary'])
337
+ const hostVariantRegex = new RegExp(`:host\\(\\[variant=["'](${variantKeywords.join('|')})["']\\]\\)`, 'gi');
338
+ let hostMatch;
339
+ while ((hostMatch = hostVariantRegex.exec(sourceCode)) !== null) {
340
+ const variant = hostMatch[1].toLowerCase();
341
+ options.add(variant);
342
+ }
343
+
344
+ // Also check for comments like /* Variant: Primary */ or /* Variants */
345
+ const commentRegex = /\/\*\s*Variants?\s*:?\s*\*\/[\s\S]*?(?=\/\*|$)/gi;
346
+ let commentMatch;
347
+ while ((commentMatch = commentRegex.exec(sourceCode)) !== null) {
348
+ const section = commentMatch[0];
349
+ variantKeywords.forEach(keyword => {
350
+ if (section.toLowerCase().includes(keyword)) {
351
+ options.add(keyword);
352
+ }
353
+ });
354
+ }
355
+
356
+ console.log(`[DEBUG] Variant options found for ${attributeName}:`, Array.from(options));
357
+ } else if (attributeName.includes('position')) {
358
+ // Match iconPosition === 'left', icon-position="right", etc.
359
+ const positionRegex = new RegExp(`(${positionKeywords.join('|')})`, 'gi');
360
+ let match;
361
+ while ((match = positionRegex.exec(sourceCode)) !== null) {
362
+ const pos = match[1].toLowerCase();
363
+ // Only add if it's in a relevant context (near icon-position or iconPosition)
364
+ const contextStart = Math.max(0, match.index - 100);
365
+ const contextEnd = Math.min(sourceCode.length, match.index + 100);
366
+ const context = sourceCode.substring(contextStart, contextEnd);
367
+ if (context.includes('position') || context.includes('flex-direction')) {
368
+ options.add(pos);
369
+ }
370
+ }
371
+
372
+ console.log(`[DEBUG] Position options found for ${attributeName}:`, Array.from(options));
373
+ }
374
+
375
+ return options.size > 0 ? Array.from(options).sort() : null;
376
+ }
377
+
378
+ /**
379
+ * Edit component file with style changes
380
+ */
381
+ async function editComponentFile({ tagName, filePath, changes, styleChanges }) {
382
+ const fullPath = path.join(ROOT_DIR, filePath);
383
+
384
+ if (!fs.existsSync(fullPath)) {
385
+ return { success: false, message: `File not found: ${filePath}` };
386
+ }
387
+
388
+ try {
389
+ let sourceCode = fs.readFileSync(fullPath, 'utf-8');
390
+
391
+ // Handle style changes - inject into component's host styles
392
+ if (styleChanges && Object.keys(styleChanges).length > 0) {
393
+ // Convert camelCase to kebab-case for CSS
394
+ const cssProperties = Object.entries(styleChanges)
395
+ .map(([prop, value]) => {
396
+ const kebabProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
397
+ return `${kebabProp}: ${value};`;
398
+ })
399
+ .join('\n ');
400
+
401
+ // Check if :host styles already exist
402
+ if (sourceCode.includes(':host {')) {
403
+ // Update existing :host block
404
+ sourceCode = sourceCode.replace(
405
+ /(:host\s*\{[^}]*)(})/,
406
+ `$1\n ${cssProperties}\n $2`
407
+ );
408
+ } else if (sourceCode.includes('<style>')) {
409
+ // Add :host block after <style> tag
410
+ sourceCode = sourceCode.replace(
411
+ /(<style>)/,
412
+ `$1\n :host {\n ${cssProperties}\n }`
413
+ );
414
+ }
415
+
416
+ console.log(`[DevTools] Style changes for <${tagName}>:`, styleChanges);
417
+ }
418
+
419
+ // Handle legacy changes format
420
+ if (changes && Array.isArray(changes)) {
421
+ for (const change of changes) {
422
+ if (change.type === 'attribute') {
423
+ console.log(`[DevTools] Attribute change: ${change.name} = ${change.value}`);
424
+ }
425
+
426
+ if (change.type === 'cssVariable') {
427
+ const varRegex = new RegExp(`(${change.name}\\s*:\\s*)([^;]+)(;)`, 'g');
428
+ sourceCode = sourceCode.replace(varRegex, `$1${change.value}$3`);
429
+ }
430
+ }
431
+ }
432
+
433
+ // Write the file
434
+ fs.writeFileSync(fullPath, sourceCode, 'utf-8');
435
+
436
+ return { success: true, message: 'Component updated successfully' };
437
+
438
+ } catch (error) {
439
+ return { success: false, message: error.message };
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Save changes to a specific component instance in an HTML file
445
+ */
446
+ async function saveInstanceChanges({ tagName, viewPath, attributes, inlineStyles, elementIndex }) {
447
+ try {
448
+ console.log('[DevTools] saveInstanceChanges called with:', { tagName, viewPath, attributes, elementIndex });
449
+
450
+ // Map route paths to actual HTML file paths
451
+ const viewsMap = {
452
+ '/': 'src/views/public/home.html',
453
+ '/about': 'src/views/public/about.html',
454
+ '/login': 'src/views/public/login.html',
455
+ '/components': 'src/views/public/components.html',
456
+ '/dashboard': 'src/views/protected/dashboard.html',
457
+ '/under-construction': 'src/views/protected/under-construction.html',
458
+ '/testing': 'src/views/protected/testing.html',
459
+ '/user/:id': 'src/views/protected/user-detail.html'
460
+ };
461
+
462
+ // Handle dynamic routes (e.g., /user/123)
463
+ let htmlFilePath = viewsMap[viewPath];
464
+ if (!htmlFilePath) {
465
+ // Try to match dynamic routes
466
+ for (const [route, file] of Object.entries(viewsMap)) {
467
+ if (route.includes(':')) {
468
+ const routePattern = route.replace(/:[^/]+/g, '[^/]+');
469
+ const regex = new RegExp(`^${routePattern}$`);
470
+ if (regex.test(viewPath)) {
471
+ htmlFilePath = file;
472
+ break;
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ if (!htmlFilePath) {
479
+ console.error('[DevTools] Unknown view path:', viewPath);
480
+ return { success: false, message: `Unknown view path: ${viewPath}. Add it to viewsMap in server.js` };
481
+ }
482
+
483
+ const fullPath = path.join(ROOT_DIR, htmlFilePath);
484
+ if (!fs.existsSync(fullPath)) {
485
+ console.error('[DevTools] View file not found:', htmlFilePath);
486
+ return { success: false, message: `View file not found: ${htmlFilePath}` };
487
+ }
488
+
489
+ let content = fs.readFileSync(fullPath, 'utf-8');
490
+
491
+ // Find the specific component tag instance
492
+ const tagRegex = new RegExp(`<${tagName}([^>]*)>`, 'g');
493
+ let matches = [];
494
+ let match;
495
+
496
+ while ((match = tagRegex.exec(content)) !== null) {
497
+ matches.push({ index: match.index, fullMatch: match[0], attrs: match[1] });
498
+ }
499
+
500
+ console.log('[DevTools] Found', matches.length, 'instances of', tagName, 'in', htmlFilePath);
501
+ console.log('[DevTools] Looking for elementIndex:', elementIndex);
502
+
503
+ if (elementIndex >= matches.length) {
504
+ console.error('[DevTools] Component instance not found. Index:', elementIndex, 'Total:', matches.length);
505
+ return { success: false, message: `Component instance ${elementIndex} not found (found ${matches.length} instances)` };
506
+ }
507
+
508
+ const targetMatch = matches[elementIndex];
509
+
510
+ // Parse existing attributes from the tag
511
+ const existingAttrs = {};
512
+ const attrRegex = /(\w+)="([^"]*)"/g;
513
+ let attrMatch;
514
+ while ((attrMatch = attrRegex.exec(targetMatch.attrs)) !== null) {
515
+ existingAttrs[attrMatch[1]] = attrMatch[2];
516
+ }
517
+
518
+ // Merge with new attributes (new values override existing)
519
+ const mergedAttrs = { ...existingAttrs, ...attributes };
520
+
521
+ // Build new attributes string
522
+ const attrPairs = [];
523
+
524
+ // Add merged attributes
525
+ for (const [key, value] of Object.entries(mergedAttrs)) {
526
+ if (key !== 'style') { // Handle style separately
527
+ attrPairs.push(`${key}="${value}"`);
528
+ }
529
+ }
530
+
531
+ // Add inline styles as style attribute
532
+ if (inlineStyles && Object.keys(inlineStyles).length > 0) {
533
+ const styleString = Object.entries(inlineStyles)
534
+ .map(([prop, value]) => {
535
+ const kebabProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
536
+ return `${kebabProp}: ${value}`;
537
+ })
538
+ .join('; ');
539
+ attrPairs.push(`style="${styleString}"`);
540
+ }
541
+
542
+ const newTag = attrPairs.length > 0
543
+ ? `<${tagName} ${attrPairs.join(' ')}>`
544
+ : `<${tagName}>`;
545
+
546
+ // Replace the specific instance
547
+ content = content.substring(0, targetMatch.index) + newTag + content.substring(targetMatch.index + targetMatch.fullMatch.length);
548
+
549
+ fs.writeFileSync(fullPath, content, 'utf-8');
550
+
551
+ console.log(`[DevTools] Saved instance changes for <${tagName}> in ${htmlFilePath}`);
552
+
553
+ // Trigger HMR to update the page
554
+ notifyHMRClients(htmlFilePath);
555
+
556
+ return { success: true, message: 'Instance changes saved successfully' };
557
+
558
+ } catch (error) {
559
+ console.error('[DevTools] Error saving instance changes:', error);
560
+ return { success: false, message: error.message };
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Save changes globally to the component TypeScript file
566
+ */
567
+ async function saveGlobalChanges({ tagName, filePath, defaultAttributes, styleChanges }) {
568
+ try {
569
+ const fullPath = path.join(ROOT_DIR, filePath);
570
+
571
+ if (!fs.existsSync(fullPath)) {
572
+ return { success: false, message: `Component file not found: ${filePath}` };
573
+ }
574
+
575
+ let sourceCode = fs.readFileSync(fullPath, 'utf-8');
576
+
577
+ // Update default attribute values in the template() method
578
+ if (defaultAttributes && Object.keys(defaultAttributes).length > 0) {
579
+ for (const [attrName, attrValue] of Object.entries(defaultAttributes)) {
580
+ // Look for this.attr() calls or getAttribute() calls in template
581
+ const attrPattern = new RegExp(`(this\\.attr\\(['"]${attrName}['"],\\s*['"])([^'"]+)(['"]\\))`, 'g');
582
+ if (sourceCode.match(attrPattern)) {
583
+ sourceCode = sourceCode.replace(attrPattern, `$1${attrValue}$3`);
584
+ console.log(`[DevTools] Updated default for ${attrName} to ${attrValue}`);
585
+ }
586
+ }
587
+ }
588
+
589
+ // Handle style changes in :host block
590
+ if (styleChanges && Object.keys(styleChanges).length > 0) {
591
+ const cssProperties = Object.entries(styleChanges)
592
+ .map(([prop, value]) => {
593
+ const kebabProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
594
+ return `${kebabProp}: ${value};`;
595
+ })
596
+ .join('\n ');
597
+
598
+ if (sourceCode.includes(':host {')) {
599
+ sourceCode = sourceCode.replace(
600
+ /(:host\s*\{[^}]*)(})/,
601
+ `$1\n ${cssProperties}\n $2`
602
+ );
603
+ } else if (sourceCode.includes('<style>')) {
604
+ sourceCode = sourceCode.replace(
605
+ /(<style>)/,
606
+ `$1\n :host {\n ${cssProperties}\n }`
607
+ );
608
+ }
609
+ }
610
+
611
+ fs.writeFileSync(fullPath, sourceCode, 'utf-8');
612
+
613
+ console.log(`[DevTools] Saved global changes for <${tagName}>`);
614
+ notifyHMRClients(fullPath);
615
+ return { success: true, message: 'Global changes saved successfully' };
616
+
617
+ } catch (error) {
618
+ console.error('[DevTools] Error saving global changes:', error);
619
+ return { success: false, message: error.message };
620
+ }
621
+ }
622
+
623
+ // Handle API routes
624
+ async function handleApiRoute(req, res) {
625
+ const url = req.url;
626
+ const method = req.method;
627
+
628
+ // CORS headers
629
+
630
+ async function proxyRemoteLogin(body) {
631
+ const response = await fetch(DEV_REMOTE_AUTH_LOGIN_URL, {
632
+ method: 'POST',
633
+ headers: {
634
+ 'Content-Type': 'application/json',
635
+ },
636
+ body: JSON.stringify(body),
637
+ });
638
+
639
+ const contentType = response.headers.get('content-type') || 'application/json';
640
+ const data = contentType.includes('application/json')
641
+ ? await response.json()
642
+ : { message: await response.text() };
643
+
644
+ return {
645
+ status: response.status,
646
+ data,
647
+ };
648
+ }
649
+ res.setHeader('Access-Control-Allow-Origin', '*');
650
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
651
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
652
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
653
+
654
+ if (method === 'OPTIONS') {
655
+ res.writeHead(200);
656
+ res.end();
657
+ return;
658
+ }
659
+
660
+ try {
661
+ // POST /api/auth/login
662
+ if (url === '/api/auth/login' && method === 'POST') {
663
+ const body = await parseBody(req);
664
+ let result;
665
+
666
+ if (DEV_REMOTE_AUTH_LOGIN_URL) {
667
+ try {
668
+ result = await proxyRemoteLogin(body);
669
+ } catch (error) {
670
+ console.error('[API] Remote login proxy failed, falling back to mock login:', error.message);
671
+ result = mockApi.handleLogin(body);
672
+ }
673
+ } else {
674
+ result = mockApi.handleLogin(body);
675
+ }
676
+
677
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
678
+ res.end(JSON.stringify(result.data));
679
+ return;
680
+ }
681
+
682
+ // GET /api/dashboard/stats
683
+ if (url === '/api/dashboard/stats' && method === 'GET') {
684
+ const authHeader = req.headers.authorization;
685
+ const user = mockApi.verifyToken(authHeader);
686
+
687
+ if (!user) {
688
+ res.writeHead(401, { 'Content-Type': 'application/json' });
689
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
690
+ return;
691
+ }
692
+
693
+ const result = mockApi.handleDashboard();
694
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
695
+ res.end(JSON.stringify(result.data));
696
+ return;
697
+ }
698
+
699
+ // GET /api/auth/verify
700
+ if (url === '/api/auth/verify' && method === 'GET') {
701
+ const authHeader = req.headers.authorization;
702
+ const user = mockApi.verifyToken(authHeader);
703
+
704
+ if (!user) {
705
+ res.writeHead(401, { 'Content-Type': 'application/json' });
706
+ res.end(JSON.stringify({ error: 'Unauthorized - please login again' }));
707
+ return;
708
+ }
709
+
710
+ const result = mockApi.handleVerify(user);
711
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
712
+ res.end(JSON.stringify(result.data));
713
+ return;
714
+ }
715
+
716
+ // GET /api/users/:id
717
+ if (url.startsWith('/api/users/') && method === 'GET') {
718
+ const authHeader = req.headers.authorization;
719
+ const user = mockApi.verifyToken(authHeader);
720
+
721
+ if (!user) {
722
+ res.writeHead(401, { 'Content-Type': 'application/json' });
723
+ res.end(JSON.stringify({ error: 'Unauthorized - please login again' }));
724
+ return;
725
+ }
726
+
727
+ const userId = url.replace('/api/users/', '').split('?')[0];
728
+ const result = mockApi.handleUserDetail(userId);
729
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
730
+ res.end(JSON.stringify(result.data));
731
+ return;
732
+ }
733
+
734
+ // ============================================
735
+ // DEV TOOLS API (only works on localhost)
736
+ // These endpoints allow live editing of components
737
+ // ============================================
738
+
739
+ // GET /api/dev/component/:tagName - Get component metadata
740
+ if (url.startsWith('/api/dev/component/') && method === 'GET' && !url.includes('/edit')) {
741
+ const tagName = url.replace('/api/dev/component/', '');
742
+ const metadata = await getComponentMetadata(tagName);
743
+
744
+ if (!metadata) {
745
+ res.writeHead(404, { 'Content-Type': 'application/json' });
746
+ res.end(JSON.stringify({ error: `Component <${tagName}> not found` }));
747
+ return;
748
+ }
749
+
750
+ res.writeHead(200, { 'Content-Type': 'application/json' });
751
+ res.end(JSON.stringify(metadata));
752
+ return;
753
+ }
754
+
755
+ // POST /api/dev/component/edit - Edit component file
756
+ if (url === '/api/dev/component/edit' && method === 'POST') {
757
+ const body = await parseBody(req);
758
+ const result = await editComponentFile(body);
759
+
760
+ res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
761
+ res.end(JSON.stringify(result));
762
+ return;
763
+ }
764
+
765
+ // POST /api/dev/component/save-instance - Save instance changes to HTML
766
+ if (url === '/api/dev/component/save-instance' && method === 'POST') {
767
+ const body = await parseBody(req);
768
+ const result = await saveInstanceChanges(body);
769
+
770
+ res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
771
+ res.end(JSON.stringify(result));
772
+ return;
773
+ }
774
+
775
+ // POST /api/dev/component/save-global - Save global changes to component file
776
+ if (url === '/api/dev/component/save-global' && method === 'POST') {
777
+ const body = await parseBody(req);
778
+ const result = await saveGlobalChanges(body);
779
+
780
+ res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
781
+ res.end(JSON.stringify(result));
782
+ return;
783
+ }
784
+
785
+ // POST /api/dev/component/delete-instance - Delete component instance from HTML
786
+ if (url === '/api/dev/component/delete-instance' && method === 'POST') {
787
+ const body = await parseBody(req);
788
+ const { tagName, htmlPath, outerHTML } = body;
789
+ const fullPath = path.join(ROOT_DIR, htmlPath);
790
+
791
+ if (!fs.existsSync(fullPath)) {
792
+ res.writeHead(404, { 'Content-Type': 'application/json' });
793
+ res.end(JSON.stringify({ success: false, error: 'HTML file not found' }));
794
+ return;
795
+ }
796
+
797
+ let htmlContent = fs.readFileSync(fullPath, 'utf-8');
798
+
799
+ // Remove the exact instance (including attributes and content)
800
+ const escapedHTML = outerHTML.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
801
+ const regex = new RegExp(escapedHTML, 'g');
802
+
803
+ if (!htmlContent.match(regex)) {
804
+ res.writeHead(400, { 'Content-Type': 'application/json' });
805
+ res.end(JSON.stringify({ success: false, error: 'Component instance not found in HTML' }));
806
+ return;
807
+ }
808
+
809
+ htmlContent = htmlContent.replace(regex, '');
810
+ fs.writeFileSync(fullPath, htmlContent, 'utf-8');
811
+
812
+ console.log(`[DevTools] Deleted <${tagName}> instance from ${htmlPath}`);
813
+
814
+ res.writeHead(200, { 'Content-Type': 'application/json' });
815
+ res.end(JSON.stringify({ success: true }));
816
+
817
+ // Trigger HMR
818
+ notifyHMRClients(htmlPath);
819
+ return;
820
+ }
821
+
822
+ // API route not found
823
+ res.writeHead(404, { 'Content-Type': 'application/json' });
824
+ res.end(JSON.stringify({ error: 'API endpoint not found' }));
825
+
826
+ } catch (error) {
827
+ res.writeHead(500, { 'Content-Type': 'application/json' });
828
+ res.end(JSON.stringify({ error: 'Server error: ' + error.message }));
829
+ }
830
+ }
831
+
832
+ const server = http.createServer(async (req, res) => {
833
+ // Handle API routes
834
+ if (req.url.startsWith('/api/')) {
835
+ await handleApiRoute(req, res);
836
+ return;
837
+ }
838
+
839
+ // Strip query parameters for file path resolution
840
+ const urlWithoutQuery = req.url.split('?')[0];
841
+
842
+ // Handle static files and SPA routing
843
+ let filePath = path.join(ROOT_DIR, urlWithoutQuery === '/' ? 'index.html' : urlWithoutQuery);
844
+ const pathExists = fs.existsSync(filePath);
845
+ const pathIsDirectory = pathExists ? fs.statSync(filePath).isDirectory() : false;
846
+
847
+ // Handle favicon - return 204 if not found to avoid errors
848
+ if (urlWithoutQuery === '/favicon.ico' && !fs.existsSync(filePath)) {
849
+ res.writeHead(204); // No Content
850
+ res.end();
851
+ return;
852
+ }
853
+
854
+ // Determine if this is a file request or a route request
855
+ const ext = path.extname(urlWithoutQuery);
856
+ const isFileRequest = ext && ext !== '';
857
+
858
+ // Check if file exists (only for actual file requests with extensions)
859
+ if (isFileRequest && !pathExists) {
860
+ // File request but file doesn't exist - return 404
861
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
862
+ res.end('File not found: ' + urlWithoutQuery);
863
+ return;
864
+ }
865
+
866
+ // If file doesn't exist and no extension, serve index.html for SPA routing
867
+ if (!isFileRequest && (!pathExists || pathIsDirectory)) {
868
+ filePath = path.join(ROOT_DIR, 'index.html');
869
+ }
870
+
871
+ // Get file extension
872
+ const fileExt = path.extname(filePath);
873
+ const contentType = MIME_TYPES[fileExt] || 'text/plain';
874
+
875
+ // Read and serve file
876
+ fs.readFile(filePath, (error, content) => {
877
+ if (error) {
878
+ res.writeHead(500);
879
+ res.end('Server Error: ' + error.code);
880
+ } else {
881
+ // Add headers
882
+ const headers = { 'Content-Type': contentType };
883
+
884
+ // In development, disable all caching for instant updates
885
+ const isDevelopment = process.env.NODE_ENV !== 'production';
886
+
887
+ // In development, set a permissive CSP to allow HMR/devtools eval
888
+ if (isDevelopment && contentType === 'text/html') {
889
+ const connectSrc = [
890
+ "'self'",
891
+ 'ws://localhost:8001',
892
+ DEV_REMOTE_API_ORIGIN,
893
+ ].join(' ');
894
+
895
+ headers['Content-Security-Policy'] = [
896
+ "default-src 'self'",
897
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
898
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
899
+ "font-src 'self' https://fonts.gstatic.com",
900
+ `connect-src ${connectSrc}`,
901
+ "img-src 'self' data:"
902
+ ].join('; ');
903
+ }
904
+
905
+ if (isDevelopment) {
906
+ // No caching in development for HMR
907
+ headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
908
+ headers['Pragma'] = 'no-cache';
909
+ headers['Expires'] = '0';
910
+ } else {
911
+ // Production caching (with cache busting in place)
912
+ if (['.css', '.js'].includes(fileExt)) {
913
+ // Cache CSS/JS for 1 day with cache busting
914
+ headers['Cache-Control'] = 'public, max-age=86400';
915
+ } else if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff', '.woff2'].includes(fileExt)) {
916
+ // Cache images and fonts for 30 days
917
+ headers['Cache-Control'] = 'public, max-age=2592000';
918
+ } else if (fileExt === '.html') {
919
+ // HTML should not be cached
920
+ headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
921
+ }
922
+ }
923
+
924
+ res.writeHead(200, headers);
925
+ res.end(content, 'utf-8');
926
+ }
927
+ });
928
+ });
929
+
930
+ server.listen(PORT, () => {
931
+ console.log(`Server running at http://localhost:${PORT}/`);
932
+ console.log(`Serving files from: ${ROOT_DIR}`);
933
+ console.log(`SPA mode: All routes fallback to index.html`);
934
+ console.log(`Mock API: /api/* endpoints available`);
935
+ console.log(`\nšŸ“ Test credentials:`);
936
+ console.log(` Email: demo@example.com`);
937
+ console.log(` Password: pa$$w0rd\n`);
938
+ console.log(DEV_REMOTE_AUTH_LOGIN_URL
939
+ ? `Remote auth proxy enabled: ${DEV_REMOTE_AUTH_LOGIN_URL}`
940
+ : 'Remote auth proxy disabled: using local mock auth');
941
+ });
942
+
943
+ // ========== Hot Module Replacement (HMR) ==========
944
+
945
+ const wss = new WebSocketServer({ port: HMR_PORT });
946
+ const hmrClients = new Set();
947
+
948
+ function notifyHMRClients(file = 'unknown') {
949
+ const message = JSON.stringify({ type: 'file-changed', file, timestamp: Date.now() });
950
+ hmrClients.forEach(client => {
951
+ if (client.readyState === 1) client.send(message);
952
+ });
953
+ }
954
+
955
+ // Track connected HMR clients
956
+ wss.on('connection', (ws) => {
957
+ hmrClients.add(ws);
958
+ console.log('šŸ”„ HMR client connected');
959
+
960
+ ws.on('close', () => {
961
+ hmrClients.delete(ws);
962
+ console.log('šŸ”„ HMR client disconnected');
963
+ });
964
+
965
+ ws.on('error', (error) => {
966
+ console.error('šŸ”„ HMR WebSocket error:', error.message);
967
+ });
968
+ });
969
+
970
+ console.log(`šŸ”„ HMR enabled on ws://localhost:${HMR_PORT}`);
971
+
972
+ // ── File Watchers ─────────────────────────────────────────────────────────────
973
+ //
974
+ // Strategy: the server NEVER calls tsc. Instead:
975
+ // - dist/ is watched for .js output written by the external `tsc --watch` process
976
+ // - src/ is watched for .css and .html changes (no compilation needed)
977
+ // - index.html in root is watched directly
978
+ //
979
+ // To get fast HMR, run the TypeScript compiler in watch mode in a separate
980
+ // terminal alongside the server:
981
+ //
982
+ // Terminal 1: npm start (this server)
983
+ // Terminal 2: npx tsc --watch (incremental compiler)
984
+ // Terminal 3 (optional): npx tsc-alias --watch
985
+ //
986
+ // The compiler picks up a save, incrementally rebuilds in ~100-400ms, writes
987
+ // the .js file to dist/, and the server immediately fires the HMR WebSocket
988
+ // message — no cold tsc spawn, no npx overhead.
989
+
990
+ const distDir = path.join(ROOT_DIR, 'dist');
991
+ const srcDir = path.join(ROOT_DIR, 'src');
992
+
993
+ function notifyFile(file) {
994
+ const message = JSON.stringify({ type: 'file-changed', file, timestamp: Date.now() });
995
+ hmrClients.forEach(client => {
996
+ if (client.readyState === 1) client.send(message);
997
+ });
998
+ }
999
+
1000
+ function debounce(fn, delay) {
1001
+ let timer = null;
1002
+ return (...args) => {
1003
+ clearTimeout(timer);
1004
+ timer = setTimeout(() => fn(...args), delay);
1005
+ };
1006
+ }
1007
+
1008
+ try {
1009
+ // Watch dist/ — fires after tsc --watch writes compiled .js output.
1010
+ // tsc writes several files per compile (foo.js, foo.js.map, foo.d.ts).
1011
+ // We track the last .js file seen in the debounce window so the callback
1012
+ // always fires with a real JS filename even if the final fs event was a
1013
+ // .map or .d.ts file.
1014
+ let pendingJsFile = null;
1015
+ let distDebounceTimer = null;
1016
+
1017
+ fs.watch(distDir, { recursive: true }, (eventType, filename) => {
1018
+ if (!filename) return;
1019
+ const norm = filename.replace(/\\/g, '/');
1020
+ if (norm.endsWith('.js') && !norm.endsWith('.d.ts')) {
1021
+ pendingJsFile = norm;
1022
+ }
1023
+ clearTimeout(distDebounceTimer);
1024
+ distDebounceTimer = setTimeout(() => {
1025
+ if (pendingJsFile) {
1026
+ console.log(`[HMR] dist changed: ${pendingJsFile}`);
1027
+ notifyFile(pendingJsFile);
1028
+ pendingJsFile = null;
1029
+ }
1030
+ }, 50);
1031
+ });
1032
+
1033
+ // Watch src/ — CSS and HTML only (TS is handled via dist/ above)
1034
+ let pendingSrcFile = null;
1035
+ let srcDebounceTimer = null;
1036
+
1037
+ fs.watch(srcDir, { recursive: true }, (eventType, filename) => {
1038
+ if (!filename) return;
1039
+ const norm = filename.replace(/\\/g, '/');
1040
+ if (norm.endsWith('.css') || norm.endsWith('.html')) {
1041
+ pendingSrcFile = norm;
1042
+ }
1043
+ clearTimeout(srcDebounceTimer);
1044
+ srcDebounceTimer = setTimeout(() => {
1045
+ if (pendingSrcFile) {
1046
+ console.log(`[HMR] src changed: ${pendingSrcFile}`);
1047
+ notifyFile(pendingSrcFile);
1048
+ pendingSrcFile = null;
1049
+ }
1050
+ }, 50);
1051
+ });
1052
+
1053
+ // Watch the root shell HTML file
1054
+ for (const shellFile of ['index.html']) {
1055
+ fs.watch(path.join(ROOT_DIR, shellFile), debounce(() => {
1056
+ console.log(`[HMR] shell changed: ${shellFile}`);
1057
+ notifyFile(shellFile);
1058
+ }, 50));
1059
+ }
1060
+
1061
+ console.log('[HMR] Watching dist/ for compiled JS output');
1062
+ console.log('[HMR] Watching src/ for CSS and HTML changes');
1063
+ console.log('[HMR] NOTE: Run "npx tsc --watch" in a separate terminal for instant TS recompilation');
1064
+ } catch (error) {
1065
+ console.error('Could not start file watcher:', error.message);
1066
+ }