@notis_ai/cli 0.2.0-beta.20.1 → 0.2.0-beta.32.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 (35) hide show
  1. package/README.md +36 -9
  2. package/package.json +2 -1
  3. package/src/command-specs/apps.js +780 -42
  4. package/src/command-specs/helpers.js +28 -3
  5. package/src/command-specs/index.js +1 -1
  6. package/src/command-specs/tools.js +33 -6
  7. package/src/runtime/agent-browser.js +192 -0
  8. package/src/runtime/app-boundary-validator.js +132 -0
  9. package/src/runtime/app-dev-server.js +577 -0
  10. package/src/runtime/app-dev-sessions.js +87 -0
  11. package/src/runtime/app-platform.js +352 -17
  12. package/src/runtime/cli-mode.generated.js +4 -0
  13. package/src/runtime/cli-mode.js +29 -0
  14. package/src/runtime/output.js +2 -2
  15. package/src/runtime/ports.js +15 -0
  16. package/src/runtime/profiles.js +34 -3
  17. package/src/runtime/transport.js +129 -4
  18. package/template/.harness/index.html.tmpl +260 -0
  19. package/template/app/layout.tsx +1 -2
  20. package/template/notis.config.ts +16 -1
  21. package/template/package.json +1 -2
  22. package/template/packages/notis-sdk/src/components/MultiSelectActionBar.tsx +272 -0
  23. package/template/packages/notis-sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  24. package/template/packages/notis-sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  25. package/template/packages/notis-sdk/src/config.ts +24 -1
  26. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +2 -1
  27. package/template/packages/notis-sdk/src/hooks/useMultiSelect.ts +502 -0
  28. package/template/packages/notis-sdk/src/hooks/useNotis.ts +5 -3
  29. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +1 -1
  30. package/template/packages/notis-sdk/src/hooks/useTopBarSearch.ts +73 -0
  31. package/template/packages/notis-sdk/src/index.ts +20 -0
  32. package/template/packages/notis-sdk/src/provider.tsx +23 -24
  33. package/template/packages/notis-sdk/src/runtime.ts +43 -26
  34. package/template/packages/notis-sdk/src/styles.css +6 -91
  35. package/src/runtime/app-preview-server.js +0 -336
@@ -1,35 +1,34 @@
1
1
  'use client';
2
2
 
3
- import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
3
+ import React, { createContext, useContext, type Context, type ReactNode } from 'react';
4
4
  import type { NotisRuntime } from './runtime';
5
- import { getRuntime } from './runtime';
6
5
 
7
- const NotisContext = createContext<NotisRuntime | null>(null);
6
+ const NOTIS_CONTEXT_SYMBOL = Symbol.for('notis.sdk.runtime_context');
8
7
 
9
- /**
10
- * Provides the Notis runtime context to all child components. Place this in
11
- * your root layout so that hooks like useDatabase, useTool, etc. can access
12
- * the platform bridge.
13
- *
14
- * During `next dev` (no runtime injected), hooks return safe defaults (empty
15
- * arrays, null values). During `notis apps preview` and in the portal, the
16
- * runtime is fully functional.
17
- */
18
- export function NotisProvider({ children, runtime }: { children: ReactNode; runtime?: NotisRuntime | null }) {
19
- const [resolvedRuntime, setResolvedRuntime] = useState<NotisRuntime | null>(runtime ?? null);
8
+ type NotisContextGlobal = typeof globalThis & {
9
+ [NOTIS_CONTEXT_SYMBOL]?: Context<NotisRuntime | null>;
10
+ };
11
+
12
+ function getNotisContext(): Context<NotisRuntime | null> {
13
+ const scope = globalThis as NotisContextGlobal;
14
+ if (!scope[NOTIS_CONTEXT_SYMBOL]) {
15
+ scope[NOTIS_CONTEXT_SYMBOL] = createContext<NotisRuntime | null>(null);
16
+ }
17
+ return scope[NOTIS_CONTEXT_SYMBOL]!;
18
+ }
20
19
 
21
- useEffect(() => {
22
- // If runtime was passed as a prop (portal component rendering), use it directly.
23
- // Otherwise fall back to window.__NOTIS_RUNTIME__ (local dev / preview).
24
- if (runtime !== undefined) {
25
- setResolvedRuntime(runtime);
26
- } else {
27
- setResolvedRuntime(getRuntime());
28
- }
29
- }, [runtime]);
20
+ const NotisContext = getNotisContext();
30
21
 
22
+ /**
23
+ * Provides the Notis runtime context to all child components. The portal owns
24
+ * the runtime and injects it into the rendered app tree.
25
+ */
26
+ export function NotisProvider({ children, runtime }: { children: ReactNode; runtime: NotisRuntime | null }) {
27
+ if (runtime === undefined) {
28
+ throw new Error('NotisProvider requires an explicit runtime prop.');
29
+ }
31
30
  return (
32
- <NotisContext.Provider value={resolvedRuntime}>
31
+ <NotisContext.Provider value={runtime}>
33
32
  {children}
34
33
  </NotisContext.Provider>
35
34
  );
@@ -1,12 +1,10 @@
1
1
  /**
2
2
  * NotisRuntime is the bridge between app code running in the browser and the
3
- * Notis platform. It is injected as `window.__NOTIS_RUNTIME__` into every app
4
- * page -- both in local preview (with mock implementations) and in the portal
5
- * (with real server-backed implementations).
3
+ * Notis platform. The portal owns the runtime and injects it through
4
+ * `NotisProvider` when the app is rendered.
6
5
  *
7
- * App code should never access `window.__NOTIS_RUNTIME__` directly. Instead,
8
- * use the hooks from `@notis/sdk` (useDatabase, useTool, etc.) which read
9
- * from the NotisProvider context.
6
+ * App code should never reach for globals. Use the hooks from `@notis/sdk`
7
+ * (useDatabase, useTool, etc.) which read from the NotisProvider context.
10
8
  */
11
9
 
12
10
  // ---------------------------------------------------------------------------
@@ -33,6 +31,7 @@ export interface DocumentRecord {
33
31
  title: string;
34
32
  properties: Record<string, unknown>;
35
33
  icon?: string | null;
34
+ cover?: string | null;
36
35
  databaseSlug?: string;
37
36
  contentBlocknote?: Array<Record<string, unknown>> | null;
38
37
  contentMarkdown?: string | null;
@@ -60,10 +59,16 @@ export interface RouteDescriptor {
60
59
  path: string;
61
60
  name: string;
62
61
  icon?: string | null;
62
+ parentSlug?: string | null;
63
63
  default?: boolean;
64
64
  collection?: {
65
65
  database: string;
66
66
  titleProperty: string;
67
+ parentProperty?: string | null;
68
+ sidebar?: {
69
+ mode: 'flat-list' | 'tree';
70
+ allowCreate: boolean;
71
+ } | null;
67
72
  } | null;
68
73
  }
69
74
 
@@ -73,6 +78,15 @@ export interface CollectionItem {
73
78
  icon?: string | null;
74
79
  }
75
80
 
81
+ export interface CollectionItemDetail extends CollectionItem {
82
+ properties: Record<string, unknown>;
83
+ }
84
+
85
+ export interface CollectionTreeItem extends CollectionItem {
86
+ parent_id: string | null;
87
+ has_children: boolean;
88
+ }
89
+
76
90
  // ---------------------------------------------------------------------------
77
91
  // App descriptor
78
92
  // ---------------------------------------------------------------------------
@@ -101,17 +115,34 @@ export interface QueryFilter {
101
115
  page_size?: number;
102
116
  }
103
117
 
118
+ export interface NotisRuntimeContext {
119
+ collectionItem?: CollectionItemDetail | null;
120
+ }
121
+
104
122
  // ---------------------------------------------------------------------------
105
- // NotisRuntime -- the window.__NOTIS_RUNTIME__ contract
123
+ // NotisRuntime
106
124
  // ---------------------------------------------------------------------------
107
125
 
108
126
  export interface NotisRuntime {
109
127
  app: AppDescriptor;
110
128
  route: RouteDescriptor;
111
129
  databases: DatabaseDescriptor[];
130
+ context: NotisRuntimeContext;
112
131
 
113
132
  navigate?: (payload: { kind: string; [key: string]: unknown }) => void;
114
133
 
134
+ registerTopBarSearch?: (
135
+ config:
136
+ | {
137
+ onChange: (value: string) => void;
138
+ placeholder?: string;
139
+ onSubmit?: () => void | Promise<void>;
140
+ }
141
+ | null,
142
+ ) => void;
143
+ setTopBarSearchValue?: (value: string) => void;
144
+ setTopBarSearchLoading?: (loading: boolean) => void;
145
+
115
146
  listTools(): Promise<ToolDescriptor[]>;
116
147
  callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
117
148
 
@@ -138,28 +169,14 @@ export interface NotisRuntime {
138
169
  pageSize?: number;
139
170
  }): Promise<{ items: CollectionItem[] }>;
140
171
 
172
+ listCollectionTree?(args?: {
173
+ databaseSlug?: string;
174
+ pageSize?: number;
175
+ }): Promise<{ items: CollectionTreeItem[] }>;
176
+
141
177
  request(path: string, options?: {
142
178
  method?: string;
143
179
  headers?: Record<string, string>;
144
180
  body?: unknown;
145
181
  }): Promise<unknown>;
146
182
  }
147
-
148
- // ---------------------------------------------------------------------------
149
- // Globals
150
- // ---------------------------------------------------------------------------
151
-
152
- declare global {
153
- interface Window {
154
- __NOTIS_RUNTIME__?: NotisRuntime;
155
- }
156
- }
157
-
158
- /**
159
- * Read the injected runtime from the window. Returns null during SSR or if the
160
- * runtime hasn't been injected yet (e.g. during `next dev` without preview).
161
- */
162
- export function getRuntime(): NotisRuntime | null {
163
- if (typeof window === 'undefined') return null;
164
- return window.__NOTIS_RUNTIME__ ?? null;
165
- }
@@ -1,110 +1,25 @@
1
1
  /**
2
2
  * Notis theme CSS variables.
3
3
  *
4
- * These match the portal's globals.css so that apps rendered inside the portal
5
- * iframe have identical styling. Import this in your root layout:
4
+ * Import this in your app layout:
6
5
  *
7
6
  * import '@notis/sdk/styles.css';
8
7
  *
9
- * Then use Tailwind classes as normal -- shadcn components will pick up these
10
- * variables automatically.
8
+ * The portal injects the live Notis theme variables at render time.
11
9
  */
12
10
 
13
11
  @tailwind base;
14
12
  @tailwind components;
15
13
  @tailwind utilities;
16
14
 
17
- @layer base {
18
- :root {
19
- --background: 0 0% 100%;
20
- --foreground: 222.2 84% 4.9%;
21
- --card: 0 0% 100%;
22
- --card-foreground: 222.2 84% 4.9%;
23
- --popover: 0 0% 100%;
24
- --popover-foreground: 222.2 84% 4.9%;
25
- --primary: 222.2 47.4% 11.2%;
26
- --primary-foreground: 210 40% 98%;
27
- --secondary: 210 40% 96.1%;
28
- --secondary-foreground: 222.2 47.4% 11.2%;
29
- --muted: 210 40% 96.1%;
30
- --muted-foreground: 0 0% 46.9%;
31
- --accent: 210 40% 96.1%;
32
- --accent-foreground: 222.2 47.4% 11.2%;
33
- --destructive: 0 84.2% 60.2%;
34
- --destructive-foreground: 210 40% 98%;
35
- --border: 214.3 31.8% 91.4%;
36
- --input: 214.3 31.8% 91.4%;
37
- --ring: 222.2 84% 4.9%;
38
- --radius: 0.5rem;
39
- --sidebar-background: 0 0% 7.5%;
40
- --sidebar-foreground: 240 4.8% 95.9%;
41
- --sidebar-primary: 0 0% 7.5%;
42
- --sidebar-primary-foreground: 240 4.8% 95.9%;
43
- --sidebar-accent: 240 3.7% 15.9%;
44
- --sidebar-accent-foreground: 240 4.8% 95.9%;
45
- --sidebar-border: 0 0% 23.5%;
46
- --sidebar-ring: 217.2 91.2% 59.8%;
47
- --chart-1: 210 100% 49%;
48
- --chart-2: 173 58% 39%;
49
- --chart-3: 197 37% 24%;
50
- --chart-4: 43 74% 66%;
51
- --chart-5: 27 87% 67%;
52
- --code-block-background: #f6f6f6;
53
- --code-block-foreground: #27272a;
54
- --portal-page-max-width: 1100px;
55
- --portal-page-gutter-mobile: 1rem;
56
- --portal-page-gutter-desktop: 1rem;
57
- --portal-page-top-mobile: 1rem;
58
- --portal-page-top-desktop: 1.5rem;
59
- --portal-surface-pad-x-mobile: 1rem;
60
- --portal-surface-pad-x-desktop: 1.5rem;
61
- --portal-surface-pad-bottom: 1.5rem;
62
- }
63
-
64
- .dark {
65
- --background: 0 0% 13%;
66
- --foreground: 210 40% 98%;
67
- --card: 0 0% 13%;
68
- --card-foreground: 210 40% 98%;
69
- --popover: 0 0% 13%;
70
- --popover-foreground: 210 40% 98%;
71
- --primary: 210 40% 98%;
72
- --primary-foreground: 222.2 47.4% 11.2%;
73
- --secondary: 0 0% 18%;
74
- --secondary-foreground: 210 40% 98%;
75
- --muted: 0 0% 18%;
76
- --muted-foreground: 0 0% 65.1%;
77
- --accent: 0 0% 18%;
78
- --accent-foreground: 210 40% 98%;
79
- --destructive: 0 84.2% 60.2%;
80
- --destructive-foreground: 210 40% 98%;
81
- --border: 0 0% 23.5%;
82
- --input: 0 0% 23.5%;
83
- --ring: 212.7 26.8% 83.9%;
84
- --sidebar-background: 0 0% 7.5%;
85
- --sidebar-foreground: 240 4.8% 95.9%;
86
- --sidebar-primary: 224.3 76.3% 48%;
87
- --sidebar-primary-foreground: 0 0% 100%;
88
- --sidebar-accent: 240 3.7% 15.9%;
89
- --sidebar-accent-foreground: 240 4.8% 95.9%;
90
- --sidebar-border: 0 0% 23.5%;
91
- --sidebar-ring: 217.2 91.2% 59.8%;
92
- --chart-1: 210 100% 49%;
93
- --chart-2: 160 60% 45%;
94
- --chart-3: 30 80% 55%;
95
- --chart-4: 280 65% 60%;
96
- --chart-5: 340 75% 55%;
97
- --code-block-background: #1e1e1e;
98
- --code-block-foreground: #e4e4e7;
99
- }
100
- }
101
-
102
15
  @layer base {
103
16
  * {
104
17
  @apply border-border;
105
18
  }
106
- body {
107
- @apply bg-background text-foreground;
19
+ [data-notis-app-root] {
20
+ color: hsl(var(--foreground));
21
+ display: block;
22
+ min-height: 100%;
108
23
  }
109
24
  }
110
25
 
@@ -1,336 +0,0 @@
1
- /**
2
- * Local preview server for built Notis app bundles.
3
- *
4
- * Serves a single index.html that loads the app bundle via <script type="module">.
5
- * Injects mock window.__NOTIS_RUNTIME__ before the bundle loads. Route switching
6
- * uses pathname navigation between route pages.
7
- *
8
- * Usage: `notis apps preview [dir]` starts this server on localhost:8787.
9
- */
10
-
11
- import { createServer } from 'node:http';
12
- import { existsSync, readFileSync } from 'node:fs';
13
- import { extname, resolve, sep } from 'node:path';
14
-
15
- import { readManifest } from './app-platform.js';
16
-
17
- // ---------------------------------------------------------------------------
18
- // Static file serving
19
- // ---------------------------------------------------------------------------
20
-
21
- function contentTypeFor(path) {
22
- const types = {
23
- '.html': 'text/html; charset=utf-8',
24
- '.css': 'text/css; charset=utf-8',
25
- '.js': 'text/javascript; charset=utf-8',
26
- '.json': 'application/json; charset=utf-8',
27
- '.svg': 'image/svg+xml',
28
- '.png': 'image/png',
29
- '.jpg': 'image/jpeg',
30
- '.jpeg': 'image/jpeg',
31
- '.ico': 'image/x-icon',
32
- '.woff': 'font/woff',
33
- '.woff2': 'font/woff2',
34
- };
35
- return types[extname(path)] || 'application/octet-stream';
36
- }
37
-
38
- // ---------------------------------------------------------------------------
39
- // Mock runtime generation
40
- // ---------------------------------------------------------------------------
41
-
42
- function buildSeedDocuments(databases) {
43
- const state = {};
44
- for (const slug of databases) {
45
- state[slug] = Array.from({ length: 3 }, (_, i) => {
46
- const title = `${slug} ${i + 1}`;
47
- const content = `This is a preview note for ${title}.`;
48
- return {
49
- id: `${slug}-seed-${i + 1}`,
50
- databaseSlug: slug,
51
- title,
52
- properties: {},
53
- icon: null,
54
- contentBlocknote: [{ type: 'paragraph', content: [{ type: 'text', text: content }] }],
55
- contentMarkdown: content,
56
- plainText: content,
57
- };
58
- });
59
- }
60
- return state;
61
- }
62
-
63
- function escapeHtml(value) {
64
- return String(value)
65
- .replaceAll('&', '&amp;')
66
- .replaceAll('<', '&lt;')
67
- .replaceAll('>', '&gt;')
68
- .replaceAll('"', '&quot;')
69
- .replaceAll("'", '&#39;');
70
- }
71
-
72
- function sanitizeBundlePath(value, fallback) {
73
- if (typeof value !== 'string') return fallback;
74
- const normalized = value.trim().replaceAll('\\', '/').replace(/^\/+/, '');
75
- if (!normalized || normalized.includes('..')) return fallback;
76
- return /^[A-Za-z0-9._/-]+$/.test(normalized) ? normalized : fallback;
77
- }
78
-
79
- function serializeForInlineScript(value) {
80
- return JSON.stringify(value).replaceAll('</', '<\\/').replaceAll('<!--', '<\\!--');
81
- }
82
-
83
- function buildPreviewHtml({ manifest, route, databases }) {
84
- const app = manifest.app;
85
- const seedState = buildSeedDocuments(databases);
86
- const bundleJs = sanitizeBundlePath(manifest.bundle?.js, 'bundle/app.js');
87
- const bundleCss = sanitizeBundlePath(manifest.bundle?.css, 'bundle/app.css');
88
- const exportName = typeof route.export_name === 'string' && route.export_name ? route.export_name : 'index';
89
- const pageTitle = escapeHtml(`${app.name} - ${route.name}`);
90
- const bundleCssHref = escapeHtml(`/${bundleCss}`);
91
- const bundleJsPathLiteral = JSON.stringify(`/${bundleJs}`);
92
- const exportNameLiteral = JSON.stringify(exportName);
93
-
94
- return `<!DOCTYPE html>
95
- <html lang="en">
96
- <head>
97
- <meta charset="UTF-8" />
98
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
99
- <title>${pageTitle}</title>
100
- <link rel="stylesheet" href="${bundleCssHref}" />
101
- <script>
102
- (function() {
103
- var DB = ${serializeForInlineScript(databases.map(function(slug) {
104
- return { slug: slug, title: slug, properties: [] };
105
- }))};
106
- var STATE = ${serializeForInlineScript(seedState)};
107
- var COLLECTION = ${serializeForInlineScript(route.collection || null)};
108
-
109
- function titleProp(slug) {
110
- var db = DB.find(function(d) { return d.slug === slug; });
111
- return db && db.properties ? (db.properties.find(function(p) { return p.type === 'title'; }) || {}).name || 'title' : 'title';
112
- }
113
-
114
- function dbSlug(v) { return v || (COLLECTION && COLLECTION.database) || null; }
115
-
116
- window.__NOTIS_RUNTIME__ = {
117
- app: ${serializeForInlineScript(app)},
118
- route: ${serializeForInlineScript(route)},
119
- databases: DB,
120
-
121
- navigate: function(payload) {
122
- console.log('[notis-preview] Navigate:', payload);
123
- if (payload.kind === 'route' && payload.path) {
124
- window.location.assign(payload.path);
125
- }
126
- },
127
-
128
- listTools: function() {
129
- return Promise.resolve(${serializeForInlineScript((manifest.tools || []).map((name) => ({ name })))});
130
- },
131
-
132
- callTool: function(name, args) {
133
- console.log('[notis-preview] Tool call:', name, args);
134
- return Promise.reject(new Error('Tool calls are not available in local preview. Deploy the app to use real tools.'));
135
- },
136
-
137
- queryDatabase: function(args) {
138
- var slug = dbSlug(args && args.databaseSlug);
139
- var docs = (STATE[slug] || []).slice(args && args.offset || 0);
140
- return Promise.resolve({ documents: docs });
141
- },
142
-
143
- getDocument: function(args) {
144
- var id = args && args.documentId;
145
- for (var slug in STATE) {
146
- var match = STATE[slug].find(function(d) { return d.id === id; });
147
- if (match) return Promise.resolve(match);
148
- }
149
- return Promise.reject(new Error('Document not found in preview.'));
150
- },
151
-
152
- upsertDocument: function(args) {
153
- var slug = dbSlug(args && args.databaseSlug);
154
- if (!slug) return Promise.reject(new Error('No database for upsert.'));
155
- var docs = STATE[slug] || [];
156
- var tp = titleProp(slug);
157
- var existing = docs.find(function(d) { return d.id === (args && args.documentId); });
158
- var props = Object.assign({}, existing && existing.properties, args && args.properties);
159
- if (args && args.title) props[tp] = args.title;
160
- var blocks = Array.isArray(args && args.contentBlocknote)
161
- ? JSON.parse(JSON.stringify(args.contentBlocknote))
162
- : existing && existing.contentBlocknote
163
- ? JSON.parse(JSON.stringify(existing.contentBlocknote))
164
- : [{ type: 'paragraph' }];
165
- var plainText = blocks
166
- .map(function(block) {
167
- if (typeof block.content === 'string') return block.content;
168
- if (!Array.isArray(block.content)) return '';
169
- return block.content
170
- .map(function(inline) { return inline && typeof inline.text === 'string' ? inline.text : ''; })
171
- .join('');
172
- })
173
- .filter(Boolean)
174
- .join('\\n')
175
- .trim();
176
- var doc = {
177
- id: existing ? existing.id : slug + '-' + Date.now(),
178
- databaseSlug: slug,
179
- title: props[tp] || 'Untitled',
180
- properties: props,
181
- icon: null,
182
- contentBlocknote: blocks,
183
- contentMarkdown: plainText,
184
- plainText: plainText
185
- };
186
- if (existing) {
187
- var idx = docs.indexOf(existing);
188
- docs[idx] = doc;
189
- } else {
190
- docs.unshift(doc);
191
- }
192
- STATE[slug] = docs;
193
- return Promise.resolve({ status: 'success', document: doc });
194
- },
195
-
196
- listCollectionItems: function(args) {
197
- var slug = dbSlug(args && args.databaseSlug);
198
- if (!slug) return Promise.resolve({ items: [] });
199
- var tp = (args && args.titleProperty) || titleProp(slug);
200
- var docs = (STATE[slug] || []).slice(0, (args && args.pageSize) || 100);
201
- return Promise.resolve({
202
- items: docs.map(function(d) {
203
- return { id: d.id, title: (d.properties && d.properties[tp]) || d.title || 'Untitled', icon: d.icon };
204
- })
205
- });
206
- },
207
-
208
- request: function() {
209
- return Promise.reject(new Error('Backend requests are not available in local preview.'));
210
- }
211
- };
212
- })();
213
- </script>
214
- </head>
215
- <body class="min-h-screen bg-background text-foreground antialiased">
216
- <div id="notis-app-root"></div>
217
- <script type="importmap">
218
- {
219
- "imports": {
220
- "react": "https://esm.sh/react@19",
221
- "react-dom": "https://esm.sh/react-dom@19?external=react",
222
- "react-dom/client": "https://esm.sh/react-dom@19/client?external=react",
223
- "react/jsx-runtime": "https://esm.sh/react@19/jsx-runtime"
224
- }
225
- }
226
- </script>
227
- <script type="module">
228
- import React from 'react';
229
- import { createRoot } from 'react-dom/client';
230
- import * as AppBundle from ${bundleJsPathLiteral};
231
-
232
- // Try to render: AppShell wrapping the route component, or just the route component
233
- const exportName = ${exportNameLiteral};
234
- const RouteComponent = AppBundle[exportName] || AppBundle['default'];
235
- const AppShell = AppBundle['__AppShell'];
236
-
237
- if (RouteComponent) {
238
- const root = document.getElementById('notis-app-root');
239
- if (root) {
240
- const reactRoot = createRoot(root);
241
- if (AppShell) {
242
- reactRoot.render(React.createElement(AppShell, null, React.createElement(RouteComponent)));
243
- } else {
244
- reactRoot.render(React.createElement(RouteComponent));
245
- }
246
- }
247
- } else {
248
- document.getElementById('notis-app-root').innerHTML =
249
- '<p style="padding:2rem;color:red;">No component found for export "' + exportName + '". Available exports: ' +
250
- Object.keys(AppBundle).join(', ') + '</p>';
251
- }
252
- </script>
253
- </body>
254
- </html>`;
255
- }
256
-
257
- // ---------------------------------------------------------------------------
258
- // Server
259
- // ---------------------------------------------------------------------------
260
-
261
- function normalizePathname(pathname) {
262
- if (!pathname || pathname === '/') return '/';
263
- return pathname.endsWith('/') ? pathname.slice(0, -1) || '/' : pathname;
264
- }
265
-
266
- function resolveSafePath(baseDir, pathname) {
267
- const normalizedBaseDir = resolve(baseDir);
268
- const resolvedPath = resolve(normalizedBaseDir, pathname.replace(/^\/+/, ''));
269
- if (resolvedPath === normalizedBaseDir || resolvedPath.startsWith(`${normalizedBaseDir}${sep}`)) {
270
- return resolvedPath;
271
- }
272
- return null;
273
- }
274
-
275
- export async function startPreviewServer({ projectDir, port }) {
276
- const manifest = readManifest(projectDir);
277
- const outputDir = resolve(projectDir, '.notis/output');
278
- const bundleDir = resolve(projectDir, '.notis/output/bundle');
279
- const databases = (manifest.databases || []).filter((slug) => typeof slug === 'string' && slug);
280
- const routesByPath = new Map((manifest.routes || []).map((r) => [normalizePathname(r.path), r]));
281
- const defaultRoute = (manifest.routes || []).find((r) => r.default) || manifest.routes?.[0];
282
-
283
- if (!defaultRoute) {
284
- throw new Error('Manifest contains no routes.');
285
- }
286
-
287
- const server = createServer((request, response) => {
288
- const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
289
- const pathname = normalizePathname(url.pathname);
290
-
291
- // Serve bundle files directly
292
- if (pathname.startsWith('/bundle/')) {
293
- const filePath = resolveSafePath(outputDir, pathname);
294
- if (filePath && existsSync(filePath)) {
295
- response.writeHead(200, { 'Content-Type': contentTypeFor(filePath), 'Cache-Control': 'no-store' });
296
- response.end(readFileSync(filePath));
297
- return;
298
- }
299
- }
300
-
301
- // Redirect / to default route if it's not /
302
- if (pathname === '/' && defaultRoute.path !== '/') {
303
- response.writeHead(302, { Location: defaultRoute.path });
304
- response.end();
305
- return;
306
- }
307
-
308
- // Serve route HTML with runtime injection
309
- const route = routesByPath.get(pathname) || (pathname === '/' ? defaultRoute : null);
310
- if (route) {
311
- const html = buildPreviewHtml({ manifest, route, databases });
312
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
313
- response.end(html);
314
- return;
315
- }
316
-
317
- // Serve static files from bundle dir
318
- const filePath = resolveSafePath(bundleDir, pathname);
319
- if (filePath && existsSync(filePath)) {
320
- response.writeHead(200, { 'Content-Type': contentTypeFor(filePath), 'Cache-Control': 'no-store' });
321
- response.end(readFileSync(filePath));
322
- return;
323
- }
324
-
325
- response.writeHead(404, { 'Content-Type': 'text/plain' });
326
- response.end('Not found');
327
- });
328
-
329
- await new Promise((resolveP, rejectP) => {
330
- server.on('error', rejectP);
331
- server.listen(port, '127.0.0.1', resolveP);
332
- });
333
-
334
- // Keep running until interrupted
335
- await new Promise(() => {});
336
- }