round-core 0.0.4 → 0.0.6

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.
package/src/index.d.ts ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Round Framework Type Definitions
3
+ */
4
+
5
+ export interface RoundSignal<T> {
6
+ /**
7
+ * Get or set the current value.
8
+ */
9
+ (newValue?: T): T;
10
+
11
+ /**
12
+ * Get the current value (reactive).
13
+ */
14
+ value: T;
15
+
16
+ /**
17
+ * Get the current value without tracking dependencies.
18
+ */
19
+ peek(): T;
20
+
21
+ /**
22
+ * Creates a transformed view of this signal.
23
+ */
24
+ transform<U>(fromInput: (v: U) => T, toOutput: (v: T) => U): RoundSignal<U>;
25
+
26
+ /**
27
+ * Attaches validation logic to the signal.
28
+ */
29
+ validate(validator: (next: T, prev: T) => string | boolean | undefined | null, options?: {
30
+ /** Timing of validation: 'input' (default) or 'blur'. */
31
+ validateOn?: 'input' | 'blur';
32
+ /** Whether to run validation immediately on startup. */
33
+ validateInitial?: boolean;
34
+ }): RoundSignal<T> & {
35
+ /** Signal containing the current validation error message. */
36
+ error: RoundSignal<string | null>;
37
+ /** Manually trigger validation check. Returns true if valid. */
38
+ check(): boolean
39
+ };
40
+
41
+ /**
42
+ * Creates a read/write view of a specific property path.
43
+ */
44
+ $pick<K extends keyof T>(path: K): RoundSignal<T[K]>;
45
+ $pick(path: string | string[]): RoundSignal<any>;
46
+
47
+ /**
48
+ * Internal: marks the signal as bindable for two-way bindings.
49
+ */
50
+ bind?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Creates a reactive signal.
55
+ */
56
+ export function signal<T>(initialValue?: T): RoundSignal<T>;
57
+
58
+ /**
59
+ * Creates a bindable signal intended for two-way DOM bindings.
60
+ */
61
+ export function bindable<T>(initialValue?: T): RoundSignal<T>;
62
+
63
+ /**
64
+ * Run a function without tracking any signals it reads.
65
+ * Any signals accessed inside the function will not become dependencies of the current effect.
66
+ */
67
+ export function untrack<T>(fn: () => T): T;
68
+
69
+ /**
70
+ * Create a reactive side-effect that runs whenever its signal dependencies change.
71
+ */
72
+ export function effect(fn: () => void | (() => void), options?: {
73
+ /** If false, the effect won't run immediately on creation. Defaults to true. */
74
+ onLoad?: boolean
75
+ }): () => void;
76
+
77
+ /**
78
+ * Create a reactive side-effect with explicit dependencies.
79
+ */
80
+ export function effect(deps: any[], fn: () => void | (() => void), options?: {
81
+ /** If false, the effect won't run immediately on creation. Defaults to true. */
82
+ onLoad?: boolean
83
+ }): () => void;
84
+
85
+ /**
86
+ * Create a read-only computed signal derived from other signals.
87
+ */
88
+ export function derive<T>(fn: () => T): () => T;
89
+
90
+ /**
91
+ * Create a read/write view of a specific path within a signal object.
92
+ */
93
+ export function pick<T = any>(root: RoundSignal<any>, path: string | string[]): RoundSignal<T>;
94
+
95
+ /**
96
+ * Store API
97
+ */
98
+ export interface RoundStore<T> {
99
+ /**
100
+ * Access a specific key from the store as a bindable signal.
101
+ */
102
+ use<K extends keyof T>(key: K): RoundSignal<T[K]>;
103
+
104
+ /**
105
+ * Update a specific key in the store.
106
+ */
107
+ set<K extends keyof T>(key: K, value: T[K]): T[K];
108
+
109
+ /**
110
+ * Batch update multiple keys in the store.
111
+ */
112
+ patch(obj: Partial<T>): void;
113
+
114
+ /**
115
+ * Get a snapshot of the current state.
116
+ */
117
+ snapshot(options?: {
118
+ /** If true, the returned values will be reactive signals. */
119
+ reactive?: boolean
120
+ }): T;
121
+
122
+ /**
123
+ * Enable persistence for the store.
124
+ */
125
+ persist(storageKey: string, options?: {
126
+ /** The storage implementation (defaults to localStorage). */
127
+ storage?: Storage;
128
+ /** Debounce time in milliseconds for writes. */
129
+ debounce?: number;
130
+ /** Array of keys to exclude from persistence. */
131
+ exclude?: string[];
132
+ }): RoundStore<T>;
133
+
134
+ /**
135
+ * Action methods defined during store creation.
136
+ */
137
+ actions: Record<string, Function>;
138
+ }
139
+
140
+ /**
141
+ * Create a shared global state store with actions and optional persistence.
142
+ */
143
+ export function createStore<T, A extends Record<string, (state: T, ...args: any[]) => Partial<T> | void>>(
144
+ initialState: T,
145
+ actions?: A
146
+ ): RoundStore<T> & { [K in keyof A]: (...args: Parameters<A[K]> extends [any, ...infer P] ? P : never) => any };
147
+
148
+ /**
149
+ * Router API
150
+ */
151
+ export interface RouteProps {
152
+ /** The path to match. Must start with a forward slash. */
153
+ route?: string;
154
+ /** If true, only matches if the path is exactly the same. */
155
+ exact?: boolean;
156
+ /** Page title to set in the document header when active. */
157
+ title?: string;
158
+ /** Meta description to set in the document header when active. */
159
+ description?: string;
160
+ /** Advanced head configuration including links and meta tags. */
161
+ head?: any;
162
+ /** Fragment or elements to render when matched. */
163
+ children?: any;
164
+ }
165
+
166
+ /**
167
+ * Define a route that renders its children when the path matches.
168
+ */
169
+ export function Route(props: RouteProps): any;
170
+
171
+ /**
172
+ * An alias for Route, typically used for top-level pages.
173
+ */
174
+ export function Page(props: RouteProps): any;
175
+
176
+ export interface LinkProps {
177
+ /** The destination path. */
178
+ href: string;
179
+ /** Alias for href. */
180
+ to?: string;
181
+ /** Use SPA navigation (prevents full page reloads). Defaults to true. */
182
+ spa?: boolean;
183
+ /** Force a full page reload on navigation. */
184
+ reload?: boolean;
185
+ /** Custom click event handler. */
186
+ onClick?: (e: MouseEvent) => void;
187
+ /** Link content (text or elements). */
188
+ children?: any;
189
+ [key: string]: any;
190
+ }
191
+
192
+ /**
193
+ * A standard link component that performs SPA navigation.
194
+ */
195
+ export function Link(props: LinkProps): any;
196
+
197
+ /**
198
+ * Define a fallback component or content for when no routes match.
199
+ */
200
+ export function NotFound(props: {
201
+ /** Optional component to render for the 404 state. */
202
+ component?: any;
203
+ /** Fallback content. ignored if 'component' is provided. */
204
+ children?: any
205
+ }): any;
206
+
207
+ /**
208
+ * Navigate to a different path programmatically.
209
+ */
210
+ export function navigate(to: string, options?: {
211
+ /** If true, replaces the current history entry instead of pushing. */
212
+ replace?: boolean
213
+ }): void;
214
+
215
+ /**
216
+ * Hook to get a reactive function returning the current normalized pathname.
217
+ */
218
+ export function usePathname(): () => string;
219
+
220
+ /**
221
+ * Get the current normalized pathname.
222
+ */
223
+ export function getPathname(): string;
224
+
225
+ /**
226
+ * Hook to get a reactive function returning the current location object.
227
+ */
228
+ export function useLocation(): () => { pathname: string; search: string; hash: string };
229
+
230
+ /**
231
+ * Get the current location object (pathname, search, hash).
232
+ */
233
+ export function getLocation(): { pathname: string; search: string; hash: string };
234
+
235
+ /**
236
+ * Hook to get a reactive function returning whether the current path has no matches.
237
+ */
238
+ export function useIsNotFound(): () => boolean;
239
+
240
+ /**
241
+ * Get whether the current path is NOT matched by any defined route.
242
+ */
243
+ export function getIsNotFound(): boolean;
244
+
245
+ /**
246
+ * DOM & Context API
247
+ */
248
+
249
+ /**
250
+ * Create a DOM element or instance a component.
251
+ */
252
+ export function createElement(tag: any, props?: any, ...children: any[]): any;
253
+
254
+ /**
255
+ * A grouping component that returns its children without a wrapper element.
256
+ */
257
+ export function Fragment(props: { children?: any }): any;
258
+
259
+ export interface Context<T> {
260
+ /** Internal identifier for the context. */
261
+ id: number;
262
+ /** Default value used when no Provider is found in the tree. */
263
+ defaultValue: T;
264
+ /** Component that provides a value to all its descendants. */
265
+ Provider: (props: { value: T; children?: any }) => any;
266
+ }
267
+
268
+ /**
269
+ * Create a new Context object for sharing state between components.
270
+ */
271
+ export function createContext<T>(defaultValue?: T): Context<T>;
272
+
273
+ /**
274
+ * Read the current value of a context from the component tree.
275
+ */
276
+ export function readContext<T>(ctx: Context<T>): T;
277
+
278
+ /**
279
+ * Returns a reactive function that reads the current context value.
280
+ */
281
+ export function bindContext<T>(ctx: Context<T>): () => T;
282
+
283
+ /**
284
+ * Async & Code Splitting
285
+ */
286
+
287
+ /**
288
+ * Mark a component for lazy loading (code-splitting).
289
+ * Expects a function returning a dynamic import promise.
290
+ */
291
+ export function lazy<T>(fn: () => Promise<{ default: T }>): T;
292
+
293
+ export interface SuspenseProps {
294
+ /** Content to show while children (e.g. lazy components) are loading. */
295
+ fallback: any;
296
+ /** Content that might trigger a loading state. */
297
+ children?: any;
298
+ }
299
+
300
+ /**
301
+ * Component that boundaries async operations and renders a fallback while loading.
302
+ */
303
+ export function Suspense(props: SuspenseProps): any;
304
+
305
+ /**
306
+ * Head Management
307
+ */
308
+
309
+ /**
310
+ * Define static head metadata (titles, meta tags, favicons, etc.).
311
+ */
312
+ export function startHead(head: any): any;
313
+
314
+ /**
315
+ * Markdown
316
+ */
317
+
318
+ /**
319
+ * Component that renders Markdown content into HTML.
320
+ */
321
+ export function Markdown(props: {
322
+ /** The markdown string or a function returning it. */
323
+ content: string | (() => string);
324
+ /** Remark/Rehype configuration options. */
325
+ options?: any
326
+ }): any;
@@ -11,6 +11,12 @@ function popContext() {
11
11
  contextStack.pop();
12
12
  }
13
13
 
14
+ /**
15
+ * Read the current value of a context from the tree.
16
+ * @template T
17
+ * @param {Context<T>} ctx The context object.
18
+ * @returns {T} The current context value.
19
+ */
14
20
  export function readContext(ctx) {
15
21
  for (let i = contextStack.length - 1; i >= 0; i--) {
16
22
  const layer = contextStack[i];
@@ -21,6 +27,12 @@ export function readContext(ctx) {
21
27
  return ctx.defaultValue;
22
28
  }
23
29
 
30
+ /**
31
+ * Create a new Context object for sharing state between components.
32
+ * @template T
33
+ * @param {T} [defaultValue] The value used when no provider is found.
34
+ * @returns {Context<T>} The context object with a `Provider` component.
35
+ */
24
36
  export function createContext(defaultValue) {
25
37
  const ctx = {
26
38
  id: nextContextId++,
@@ -29,18 +41,29 @@ export function createContext(defaultValue) {
29
41
  };
30
42
 
31
43
  function Provider(props = {}) {
32
- const value = props.value;
33
- const child = Array.isArray(props.children) ? props.children[0] : props.children;
34
- const childFn = typeof child === 'function' ? child : () => child;
44
+ const children = props.children;
35
45
 
36
- return createElement('span', { style: { display: 'contents' } }, () => {
37
- pushContext({ [ctx.id]: value });
38
- try {
39
- return childFn();
40
- } finally {
41
- popContext();
42
- }
43
- });
46
+ // Push context now so that any createElement/appendChild called
47
+ // during the instantiation of this Provider branch picks it up immediately.
48
+ pushContext({ [ctx.id]: props.value });
49
+ try {
50
+ // We use a span to handle reactive value updates and dynamic children.
51
+ return createElement('span', { style: { display: 'contents' } }, () => {
52
+ // Read current value (reactive if it's a signal)
53
+ const val = (typeof props.value === 'function' && props.value.peek) ? props.value() : props.value;
54
+
55
+ // Push it during the effect run too! This ensures that anything returned
56
+ // from this callback (which might trigger more appendChild calls) sees the context.
57
+ pushContext({ [ctx.id]: val });
58
+ try {
59
+ return children;
60
+ } finally {
61
+ popContext();
62
+ }
63
+ });
64
+ } finally {
65
+ popContext();
66
+ }
44
67
  }
45
68
 
46
69
  ctx.Provider = Provider;
@@ -29,6 +29,13 @@ function warnSignalDirectUsage(fn, kind) {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Create a DOM element or instance a component.
34
+ * @param {string | Function} tag HTML tag name or Component function.
35
+ * @param {object} [props] Element attributes or component props.
36
+ * @param {...any} children Child nodes.
37
+ * @returns {Node} The resulting DOM node.
38
+ */
32
39
  export function createElement(tag, props = {}, ...children) {
33
40
  if (typeof tag === 'function') {
34
41
  const componentInstance = createComponentInstance();
@@ -388,6 +395,9 @@ function appendChild(parent, child) {
388
395
  }
389
396
  }
390
397
 
398
+ /**
399
+ * A grouping component that returns its children without a wrapper element.
400
+ */
391
401
  export function Fragment(props) {
392
402
  return props.children;
393
403
  }
@@ -1,5 +1,6 @@
1
1
  import { signal, effect } from './signals.js';
2
2
  import { createElement } from './dom.js';
3
+ import { createContext, readContext } from './context.js';
3
4
 
4
5
  const hasWindow = typeof window !== 'undefined' && typeof document !== 'undefined';
5
6
 
@@ -20,6 +21,8 @@ let defaultNotFoundComponent = null;
20
21
  let autoNotFoundMounted = false;
21
22
  let userProvidedNotFound = false;
22
23
 
24
+ const RoutingContext = createContext('');
25
+
23
26
  function ensureListener() {
24
27
  if (!hasWindow || listenerInitialized) return;
25
28
  listenerInitialized = true;
@@ -72,6 +75,7 @@ export function useRouteReady() {
72
75
 
73
76
  export function getIsNotFound() {
74
77
  const pathname = normalizePathname(currentPath());
78
+ if (pathname === '/') return false;
75
79
  if (!(Boolean(pathEvalReady()) && lastPathEvaluated === pathname)) return false;
76
80
  return !Boolean(pathHasMatch());
77
81
  }
@@ -79,6 +83,7 @@ export function getIsNotFound() {
79
83
  export function useIsNotFound() {
80
84
  return () => {
81
85
  const pathname = normalizePathname(currentPath());
86
+ if (pathname === '/') return false;
82
87
  if (!(Boolean(pathEvalReady()) && lastPathEvaluated === pathname)) return false;
83
88
  return !Boolean(pathHasMatch());
84
89
  };
@@ -104,6 +109,10 @@ function mountAutoNotFound() {
104
109
  if (lastPathEvaluated !== pathname) return null;
105
110
  if (hasMatch) return null;
106
111
 
112
+ // Skip absolute 404 overlay for the root path if no match found,
113
+ // allowing the base app to render its non-routed content.
114
+ if (pathname === '/') return null;
115
+
107
116
  const Comp = defaultNotFoundComponent;
108
117
  if (typeof Comp === 'function') {
109
118
  return createElement(Comp, { pathname });
@@ -118,6 +127,11 @@ function mountAutoNotFound() {
118
127
  root.appendChild(view);
119
128
  }
120
129
 
130
+ /**
131
+ * Navigate to a different path programmatically.
132
+ * @param {string} to The destination URL or path.
133
+ * @param {object} [options] Navigation options (e.g., { replace: true }).
134
+ */
121
135
  export function navigate(to, options = {}) {
122
136
  if (!hasWindow) return;
123
137
  ensureListener();
@@ -229,10 +243,12 @@ function normalizeTo(to) {
229
243
  return normalizePathname(path) + suffix;
230
244
  }
231
245
 
232
- function matchRoute(route, pathname) {
246
+ function matchRoute(route, pathname, exact = true) {
233
247
  const r = normalizePathname(route);
234
248
  const p = normalizePathname(pathname);
235
- return r === p;
249
+ if (exact) return r === p;
250
+ // Prefix match: either exactly the same, or p starts with r plus a slash
251
+ return p === r || p.startsWith(r.endsWith('/') ? r : r + '/');
236
252
  }
237
253
 
238
254
  function beginPathEvaluation(pathname) {
@@ -253,17 +269,54 @@ export function setNotFound(Component) {
253
269
  defaultNotFoundComponent = Component;
254
270
  }
255
271
 
272
+ /**
273
+ * Define a route that renders its children when the path matches.
274
+ * @param {object} props Route properties.
275
+ * @param {string} [props.route='/'] The path to match.
276
+ * @param {boolean} [props.exact] Whether to use exact matching.
277
+ * @param {string} [props.title] Page title to set when active.
278
+ * @param {string} [props.description] Meta description to set when active.
279
+ * @param {any} [props.children] Content to render.
280
+ */
256
281
  export function Route(props = {}) {
257
282
  ensureListener();
258
283
 
259
284
  return createElement('span', { style: { display: 'contents' } }, () => {
285
+ const parentPath = readContext(RoutingContext) || '';
260
286
  const pathname = normalizePathname(currentPath());
261
287
  beginPathEvaluation(pathname);
262
- const route = props.route ?? '/';
263
- if (!matchRoute(route, pathname)) return null;
264
288
 
265
- hasMatchForPath = true;
266
- pathHasMatch(true);
289
+ const routeProp = props.route ?? '/';
290
+ if (typeof routeProp === 'string' && !routeProp.startsWith('/')) {
291
+ throw new Error(`Invalid route: "${routeProp}". All routes must start with a forward slash "/". (Nested under: "${parentPath || 'root'}")`);
292
+ }
293
+
294
+ let fullRoute = '';
295
+ if (parentPath && parentPath !== '/') {
296
+ const cleanParent = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath;
297
+ const cleanChild = routeProp.startsWith('/') ? routeProp : '/' + routeProp;
298
+
299
+ if (cleanChild.startsWith(cleanParent + '/') || cleanChild === cleanParent) {
300
+ fullRoute = normalizePathname(cleanChild);
301
+ } else {
302
+ fullRoute = normalizePathname(cleanParent + cleanChild);
303
+ }
304
+ } else {
305
+ fullRoute = normalizePathname(routeProp);
306
+ }
307
+
308
+ const isRoot = fullRoute === '/';
309
+ const exact = props.exact !== undefined ? Boolean(props.exact) : isRoot;
310
+
311
+ // For nested routing, we match as a prefix so parents stay rendered while children are active
312
+ if (!matchRoute(fullRoute, pathname, exact)) return null;
313
+
314
+ // If it's an exact match of the FULL segments, mark as matched for 404 purposes
315
+ if (matchRoute(fullRoute, pathname, true)) {
316
+ hasMatchForPath = true;
317
+ pathHasMatch(true);
318
+ }
319
+
267
320
  const mergedHead = (props.head && typeof props.head === 'object') ? props.head : {};
268
321
  const meta = props.description
269
322
  ? ([{ name: 'description', content: String(props.description) }].concat(mergedHead.meta ?? props.meta ?? []))
@@ -274,21 +327,53 @@ export function Route(props = {}) {
274
327
  const favicon = mergedHead.favicon ?? props.favicon;
275
328
 
276
329
  applyHead({ title, meta, links, icon, favicon });
277
- return props.children;
330
+
331
+ // Provide the current full path to nested routes
332
+ return createElement(RoutingContext.Provider, { value: fullRoute }, props.children);
278
333
  });
279
334
  }
280
335
 
336
+ /**
337
+ * An alias for Route, typically used for top-level pages.
338
+ * @param {object} props Page properties (same as Route).
339
+ */
281
340
  export function Page(props = {}) {
282
341
  ensureListener();
283
342
 
284
343
  return createElement('span', { style: { display: 'contents' } }, () => {
344
+ const parentPath = readContext(RoutingContext) || '';
285
345
  const pathname = normalizePathname(currentPath());
286
346
  beginPathEvaluation(pathname);
287
- const route = props.route ?? '/';
288
- if (!matchRoute(route, pathname)) return null;
289
347
 
290
- hasMatchForPath = true;
291
- pathHasMatch(true);
348
+ const routeProp = props.route ?? '/';
349
+ if (typeof routeProp === 'string' && !routeProp.startsWith('/')) {
350
+ throw new Error(`Invalid route: "${routeProp}". All routes must start with a forward slash "/". (Nested under: "${parentPath || 'root'}")`);
351
+ }
352
+
353
+ let fullRoute = '';
354
+ if (parentPath && parentPath !== '/') {
355
+ const cleanParent = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath;
356
+ const cleanChild = routeProp.startsWith('/') ? routeProp : '/' + routeProp;
357
+
358
+ if (cleanChild.startsWith(cleanParent + '/') || cleanChild === cleanParent) {
359
+ fullRoute = normalizePathname(cleanChild);
360
+ } else {
361
+ fullRoute = normalizePathname(cleanParent + cleanChild);
362
+ }
363
+ } else {
364
+ fullRoute = normalizePathname(routeProp);
365
+ }
366
+
367
+ const isRoot = fullRoute === '/';
368
+ const exact = props.exact !== undefined ? Boolean(props.exact) : isRoot;
369
+
370
+ if (!matchRoute(fullRoute, pathname, exact)) return null;
371
+
372
+ if (matchRoute(fullRoute, pathname, true)) {
373
+ hasMatchForPath = true;
374
+ pathHasMatch(true);
375
+ }
376
+
292
377
  const mergedHead = (props.head && typeof props.head === 'object') ? props.head : {};
293
378
  const meta = props.description
294
379
  ? ([{ name: 'description', content: String(props.description) }].concat(mergedHead.meta ?? props.meta ?? []))
@@ -299,10 +384,14 @@ export function Page(props = {}) {
299
384
  const favicon = mergedHead.favicon ?? props.favicon;
300
385
 
301
386
  applyHead({ title, meta, links, icon, favicon });
302
- return props.children;
387
+
388
+ return createElement(RoutingContext.Provider, { value: fullRoute }, props.children);
303
389
  });
304
390
  }
305
391
 
392
+ /**
393
+ * Define a fallback component or content for when no routes match.
394
+ */
306
395
  export function NotFound(props = {}) {
307
396
  ensureListener();
308
397
 
@@ -318,6 +407,7 @@ export function NotFound(props = {}) {
318
407
  if (lastPathEvaluated !== pathname) return null;
319
408
 
320
409
  if (hasMatch) return null;
410
+ if (pathname === '/') return null;
321
411
 
322
412
  const Comp = props.component ?? defaultNotFoundComponent;
323
413
  if (typeof Comp === 'function') {
@@ -333,14 +423,21 @@ export function NotFound(props = {}) {
333
423
  });
334
424
  }
335
425
 
426
+ /**
427
+ * A standard link component that performs SPA navigation.
428
+ * @param {object} props Link properties.
429
+ * @param {string} [props.href] The destination path.
430
+ * @param {boolean} [props.spa=true] Use SPA navigation (prevents reload).
431
+ * @param {any} [props.children] Link content.
432
+ */
336
433
  export function Link(props = {}) {
337
434
  ensureListener();
338
435
 
339
436
  const rawHref = props.href ?? props.to ?? '#';
340
437
  const href = spaNormalizeHref(rawHref);
341
438
 
342
- const spa = props.spa !== undefined ? Boolean(props.spa) : true;
343
- const reload = Boolean(props.reload);
439
+ const spa = props.spa !== undefined ? Boolean(props.spa) : true;
440
+ const reload = Boolean(props.reload);
344
441
 
345
442
  const onClick = (e) => {
346
443
  if (typeof props.onClick === 'function') props.onClick(e);