round-core 0.0.3 → 0.0.5

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/package.json CHANGED
@@ -1,40 +1,47 @@
1
- {
2
- "name": "round-core",
3
- "version": "0.0.3",
4
- "description": "A lightweight frontend framework for SPA with signals and fine grained reactivity",
5
- "main": "src/index.js",
6
- "type": "module",
7
- "icon": "round.png",
8
- "repository": {
9
- "url": "https://github.com/ZtaMDev/RoundJS.git"
10
- },
11
- "bin": {
12
- "round": "./src/cli.js"
13
- },
14
- "scripts": {
15
- "dev": "node ./cli.js dev --config ./test/main/round.config.json",
16
- "build": "node ./cli.js build --config ./test/main/round.config.json",
17
- "test": "vitest run"
18
- },
19
- "keywords": [
20
- "framework",
21
- "spa",
22
- "signals",
23
- "vite"
24
- ],
25
- "author": "Round Framework Team",
26
- "license": "MIT",
27
- "dependencies": {
28
- "marked": "^12.0.2",
29
- "vite": "^5.0.0",
30
- "vitest": "^1.6.0"
31
- },
32
- "devDependencies": {
33
- "jsdom": "^24.0.0",
34
- "bun-types": "latest",
35
- "@types/node": "latest"
36
- },
37
- "peerDependencies": {
38
- "bun": ">=1.0.0"
39
- }
1
+ {
2
+ "name": "round-core",
3
+ "version": "0.0.5",
4
+ "description": "A lightweight frontend framework for SPA with signals and fine grained reactivity",
5
+ "main": "./dist/index.js",
6
+ "exports": {
7
+ ".": "./dist/index.js",
8
+ "./vite-plugin": "./dist/vite-plugin.js"
9
+ },
10
+ "type": "module",
11
+ "icon": "round.png",
12
+ "repository": {
13
+ "url": "https://github.com/ZtaMDev/RoundJS.git"
14
+ },
15
+ "bin": {
16
+ "round": "./dist/cli.js"
17
+ },
18
+ "scripts": {
19
+ "dev": "node ./src/cli.js dev --config ./test/main/round.config.json",
20
+ "build": "node ./src/cli.js build --config ./test/main/round.config.json",
21
+ "build:core": "vite build -c vite.config.build.js",
22
+ "test": "vitest run",
23
+ "bench": "bun run --cwd benchmarks bench",
24
+ "bench:build": "bun run --cwd benchmarks bench:build",
25
+ "bench:runtime": "bun run --cwd benchmarks bench:runtime"
26
+ },
27
+ "keywords": [
28
+ "framework",
29
+ "spa",
30
+ "signals",
31
+ "vite"
32
+ ],
33
+ "author": "Round Framework Team",
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "marked": "^12.0.2",
37
+ "vite": "^5.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "bun-types": "latest",
41
+ "@types/node": "latest",
42
+ "vitest": "^1.6.0"
43
+ },
44
+ "peerDependencies": {
45
+ "bun": ">=1.0.0"
46
+ }
40
47
  }
package/src/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import process from 'node:process';
@@ -6,6 +6,13 @@ import { fileURLToPath } from 'node:url';
6
6
  import { createServer, build as viteBuild, preview as vitePreview } from 'vite';
7
7
  import RoundPlugin from './compiler/vite-plugin.js';
8
8
 
9
+ // Handle graceful shutdown
10
+ function onSignal() {
11
+ process.exit(0);
12
+ }
13
+ process.on('SIGINT', onSignal);
14
+ process.on('SIGTERM', onSignal);
15
+
9
16
  function normalizePath(p) {
10
17
  return p.replaceAll('\\', '/');
11
18
  }
@@ -115,32 +122,6 @@ function resolveFrom(baseDir, p) {
115
122
  return path.resolve(baseDir, p);
116
123
  }
117
124
 
118
- function ensureIndexHtml(rootDir, entryRel, title = 'Round') {
119
- const indexPath = path.join(rootDir, 'index.html');
120
- if (fs.existsSync(indexPath)) return;
121
-
122
- const entryPath = entryRel.startsWith('/') ? entryRel : `/${entryRel}`;
123
- fs.writeFileSync(indexPath, [
124
- '<!DOCTYPE html>',
125
- '<html lang="en">',
126
- '<head>',
127
- ' <meta charset="UTF-8" />',
128
- ' <meta name="viewport" content="width=device-width, initial-scale=1.0" />',
129
- ` <title>${title}</title>`,
130
- '</head>',
131
- '<body>',
132
- ' <div id="app"></div>',
133
- ' <script type="module">',
134
- " import { render } from 'round-core';",
135
- ` import App from '${entryPath}';`,
136
- '',
137
- ' render(App, document.getElementById("app"));',
138
- ' </script>',
139
- '</body>',
140
- '</html>',
141
- ''
142
- ].join('\n'), 'utf8');
143
- }
144
125
 
145
126
  function parseArgs(argv) {
146
127
  const args = { _: [] };
@@ -224,7 +205,6 @@ async function runInit({ name }) {
224
205
  const pkgPath = path.join(projectDir, 'package.json');
225
206
  const configPath = path.join(projectDir, 'round.config.json');
226
207
  const viteConfigPath = path.join(projectDir, 'vite.config.js');
227
- const indexHtmlPath = path.join(projectDir, 'index.html');
228
208
  const appRoundPath = path.join(srcDir, 'app.round');
229
209
  const counterRoundPath = path.join(srcDir, 'counter.round');
230
210
 
@@ -239,7 +219,7 @@ async function runInit({ name }) {
239
219
  preview: 'round preview'
240
220
  },
241
221
  dependencies: {
242
- 'round-core': '^0.0.1'
222
+ 'round-core': '^0.0.4'
243
223
  },
244
224
  devDependencies: {
245
225
  vite: '^5.0.0'
@@ -272,7 +252,7 @@ async function runInit({ name }) {
272
252
 
273
253
  writeFileIfMissing(viteConfigPath, [
274
254
  "import { defineConfig } from 'vite';",
275
- "import RoundPlugin from 'round-core/src/compiler/vite-plugin.js';",
255
+ "import RoundPlugin from 'round-core/vite-plugin';",
276
256
  '',
277
257
  'export default defineConfig({',
278
258
  " plugins: [RoundPlugin({ configPath: './round.config.json' })],",
@@ -283,26 +263,6 @@ async function runInit({ name }) {
283
263
  ''
284
264
  ].join('\n'));
285
265
 
286
- writeFileIfMissing(indexHtmlPath, [
287
- '<!DOCTYPE html>',
288
- '<html lang="en">',
289
- '<head>',
290
- ' <meta charset="UTF-8" />',
291
- ' <meta name="viewport" content="width=device-width, initial-scale=1.0" />',
292
- ` <title>${name}</title>`,
293
- '</head>',
294
- '<body>',
295
- ' <div id="app"></div>',
296
- ' <script type="module">',
297
- " import { render } from 'round-core';",
298
- " import App from '/src/app.round';",
299
- '',
300
- " render(App, document.getElementById('app'));",
301
- ' </script>',
302
- '</body>',
303
- '</html>',
304
- ''
305
- ].join('\n'));
306
266
 
307
267
  writeFileIfMissing(appRoundPath, [
308
268
  "import { Route } from 'round-core';",
@@ -379,7 +339,6 @@ async function runDev({ rootDir, configPathAbs, config }) {
379
339
  throw new Error(`Entry not found: ${entryAbs ?? '(missing entry)'} (config: ${configPathAbs})`);
380
340
  }
381
341
  const entryRel = normalizePath(path.relative(rootDir, entryAbs));
382
- ensureIndexHtml(rootDir, entryRel, config?.name ?? 'Round');
383
342
 
384
343
  let viteServer = null;
385
344
  let restarting = false;
@@ -393,7 +352,6 @@ async function runDev({ rootDir, configPathAbs, config }) {
393
352
  throw new Error(`Entry not found: ${entryAbs2 ?? '(missing entry)'} (config: ${configPathAbs})`);
394
353
  }
395
354
  const entryRel2 = normalizePath(path.relative(rootDir, entryAbs2));
396
- ensureIndexHtml(rootDir, entryRel2, nextConfig?.name ?? 'Round');
397
355
 
398
356
  const serverPort2 = coerceNumber(nextConfig?.dev?.port, 5173);
399
357
  const open2 = Boolean(nextConfig?.dev?.open);
@@ -468,7 +426,6 @@ async function runBuild({ rootDir, configPathAbs, config }) {
468
426
  throw new Error(`Entry not found: ${entryAbs ?? '(missing entry)'} (config: ${configPathAbs})`);
469
427
  }
470
428
  const entryRel = normalizePath(path.relative(rootDir, entryAbs));
471
- ensureIndexHtml(rootDir, entryRel, config?.name ?? 'Round');
472
429
 
473
430
  const outDir = config?.output ? resolveFrom(configDir, config.output) : resolveFrom(rootDir, './dist');
474
431
  const base = config?.routing?.base ?? '/';
@@ -506,8 +463,7 @@ async function runPreview({ rootDir, configPathAbs, config }) {
506
463
 
507
464
  const entryAbs = config?.entry ? resolveFrom(configDir, config.entry) : null;
508
465
  if (entryAbs && fs.existsSync(entryAbs)) {
509
- const entryRel = normalizePath(path.relative(rootDir, entryAbs));
510
- ensureIndexHtml(rootDir, entryRel, config?.name ?? 'Round');
466
+ // No physical index.html needed
511
467
  }
512
468
 
513
469
  banner('Preview');
@@ -93,6 +93,8 @@ export default function RoundPlugin(pluginOptions = {}) {
93
93
  configPathAbs: null,
94
94
  configDir: null,
95
95
  entryAbs: null,
96
+ entryRel: null,
97
+ name: 'Round',
96
98
  startHead: null,
97
99
  startHeadHtml: null
98
100
  };
@@ -131,7 +133,13 @@ export default function RoundPlugin(pluginOptions = {}) {
131
133
  const trailingSlash = config?.routing?.trailingSlash;
132
134
  state.routingTrailingSlash = trailingSlash !== undefined ? Boolean(trailingSlash) : true;
133
135
 
136
+ const customTags = config?.htmlTags;
137
+ state.customTags = Array.isArray(customTags) ? customTags : [];
138
+
139
+ state.name = config?.name ?? 'Round';
140
+
134
141
  const entryRel = config?.entry;
142
+ state.entryRel = entryRel;
135
143
  state.entryAbs = entryRel ? resolveMaybeRelative(configDir, entryRel) : null;
136
144
 
137
145
  const include = pluginOptions.include ?? config?.include ?? [];
@@ -303,7 +311,8 @@ export default function RoundPlugin(pluginOptions = {}) {
303
311
 
304
312
  return {
305
313
  define: {
306
- __ROUND_ROUTING_TRAILING_SLASH__: JSON.stringify(state.routingTrailingSlash)
314
+ __ROUND_ROUTING_TRAILING_SLASH__: JSON.stringify(state.routingTrailingSlash),
315
+ __ROUND_CUSTOM_TAGS__: JSON.stringify(state.customTags ?? [])
307
316
  },
308
317
  esbuild: {
309
318
  include: /\.(round|js|jsx|ts|tsx)$/,
@@ -319,7 +328,45 @@ export default function RoundPlugin(pluginOptions = {}) {
319
328
  };
320
329
  },
321
330
 
331
+ resolveId(id) {
332
+ if (id === '/index.html' || id === 'index.html') {
333
+ const fullPath = path.resolve(state.rootDir, 'index.html');
334
+ if (!fs.existsSync(fullPath)) {
335
+ return '/index.html'; // Virtual ID
336
+ }
337
+ }
338
+ return null;
339
+ },
340
+
322
341
  load(id) {
342
+ if (id === '/index.html' || id === 'index.html') {
343
+ const fullPath = path.resolve(state.rootDir, 'index.html');
344
+ if (fs.existsSync(fullPath)) return null; // Fallback to disk
345
+
346
+ const entry = state.entryRel ?? './src/index.js';
347
+ const entryPath = entry.startsWith('/') ? entry : `/${entry}`;
348
+
349
+ return [
350
+ '<!DOCTYPE html>',
351
+ '<html lang="en">',
352
+ '<head>',
353
+ ' <meta charset="UTF-8" />',
354
+ ' <meta name="viewport" content="width=device-width, initial-scale=1.0" />',
355
+ ` <title>${state.name}</title>`,
356
+ '</head>',
357
+ '<body>',
358
+ ' <div id="app"></div>',
359
+ ' <script type="module">',
360
+ " import { render } from 'round-core';",
361
+ ` import App from '${entryPath}';`,
362
+ '',
363
+ " render(App, document.getElementById('app'));",
364
+ ' </script>',
365
+ '</body>',
366
+ '</html>'
367
+ ].join('\n');
368
+ }
369
+
323
370
  if (!isMdRawRequest(id)) return;
324
371
 
325
372
  const fileAbs = stripQuery(id);
@@ -29,18 +29,29 @@ export function createContext(defaultValue) {
29
29
  };
30
30
 
31
31
  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;
32
+ const children = props.children;
35
33
 
36
- return createElement('span', { style: { display: 'contents' } }, () => {
37
- pushContext({ [ctx.id]: value });
38
- try {
39
- return childFn();
40
- } finally {
41
- popContext();
42
- }
43
- });
34
+ // Push context now so that any createElement/appendChild called
35
+ // during the instantiation of this Provider branch picks it up immediately.
36
+ pushContext({ [ctx.id]: props.value });
37
+ try {
38
+ // We use a span to handle reactive value updates and dynamic children.
39
+ return createElement('span', { style: { display: 'contents' } }, () => {
40
+ // Read current value (reactive if it's a signal)
41
+ const val = (typeof props.value === 'function' && props.value.peek) ? props.value() : props.value;
42
+
43
+ // Push it during the effect run too! This ensures that anything returned
44
+ // from this callback (which might trigger more appendChild calls) sees the context.
45
+ pushContext({ [ctx.id]: val });
46
+ try {
47
+ return children;
48
+ } finally {
49
+ popContext();
50
+ }
51
+ });
52
+ } finally {
53
+ popContext();
54
+ }
44
55
  }
45
56
 
46
57
  ctx.Provider = Provider;
@@ -1,5 +1,5 @@
1
1
  import { effect, untrack } from './signals.js';
2
- import { runInContext as runInLifecycle, createComponentInstance, mountComponent, initLifecycleRoot } from './lifecycle.js';
2
+ import { runInLifecycle, createComponentInstance, mountComponent, initLifecycleRoot } from './lifecycle.js';
3
3
  import { reportErrorSafe } from './error-reporter.js';
4
4
  import { captureContext, runInContext, readContext } from './context.js';
5
5
  import { SuspenseContext } from './suspense.js';
@@ -76,14 +76,13 @@ export function createElement(tag, props = {}, ...children) {
76
76
 
77
77
  if (typeof tag === 'string') {
78
78
  const isCustomElement = tag.includes('-');
79
- // Simple check: if it looks like a component (no hyphen, lowercase start)
80
- // and it's not a standard tag, it's likely an error.
81
- // We use a small heuristic list or regex.
82
- // Actually, user requested: "custom components when they start with lowercase... should give error".
83
- // Using a whitelist of standard tags is robust.
84
- const isStandard = /^(a|abbr|address|area|article|aside|audio|b|base|bdi|bdo|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|data|datalist|dd|del|details|dfn|dialog|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|label|legend|li|link|main|map|mark|meta|meter|nav|noscript|object|ol|optgroup|option|output|p|param|picture|pre|progress|q|rp|rt|ruby|s|samp|script|section|select|slot|small|source|span|strong|style|sub|summary|sup|svg|table|tbody|td|template|textarea|tfoot|th|thead|time|title|tr|track|u|ul|var|video|wbr|path|circle|rect|line|g|defs|linearGradient|stop|radialGradient|text|tspan)$/.test(tag);
85
-
86
- if (!isCustomElement && !isStandard && /^[a-z]/.test(tag)) {
79
+
80
+ const isStandard = /^(a|abbr|address|area|article|aside|audio|b|base|bdi|bdo|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|data|datalist|dd|del|details|dfn|dialog|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|label|legend|li|link|main|map|mark|meta|meter|nav|noscript|object|ol|optgroup|option|output|p|param|picture|pre|progress|q|rp|rt|ruby|s|samp|script|search|section|select|slot|small|source|span|strong|style|sub|summary|sup|svg|table|tbody|td|template|textarea|tfoot|th|thead|time|title|tr|track|u|ul|var|video|wbr|menu|animate|animateMotion|animateTransform|circle|clipPath|defs|desc|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|foreignObject|g|image|line|linearGradient|marker|mask|metadata|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|stop|switch|symbol|text|textPath|tspan|use|view)$/.test(tag);
81
+
82
+ // __ROUND_CUSTOM_TAGS__ is injected by the vite plugin from round.config.json
83
+ const isCustomConfigured = typeof __ROUND_CUSTOM_TAGS__ !== 'undefined' && __ROUND_CUSTOM_TAGS__.includes(tag);
84
+
85
+ if (!isCustomElement && !isStandard && !isCustomConfigured && /^[a-z]/.test(tag)) {
87
86
  throw new Error(`Component names must start with an uppercase letter: <${tag} />`);
88
87
  }
89
88
  }
@@ -273,6 +272,21 @@ export function createElement(tag, props = {}, ...children) {
273
272
  return;
274
273
  }
275
274
 
275
+ if (key === 'classList') {
276
+ if (value && typeof value === 'object') {
277
+ Object.entries(value).forEach(([className, condition]) => {
278
+ if (typeof condition === 'function') {
279
+ effect(() => {
280
+ element.classList.toggle(className, !!condition());
281
+ }, { onLoad: false });
282
+ } else {
283
+ element.classList.toggle(className, !!condition);
284
+ }
285
+ });
286
+ }
287
+ return;
288
+ }
289
+
276
290
  if (key === 'className') element.className = value;
277
291
  else if (key === 'value') element.value = value;
278
292
  else if (key === 'checked') element.checked = Boolean(value);
@@ -6,7 +6,7 @@ export function getCurrentComponent() {
6
6
  return componentStack[componentStack.length - 1];
7
7
  }
8
8
 
9
- export function runInContext(componentInstance, fn) {
9
+ export function runInLifecycle(componentInstance, fn) {
10
10
  componentStack.push(componentInstance);
11
11
  try {
12
12
  return fn();
@@ -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 });
@@ -229,10 +238,12 @@ function normalizeTo(to) {
229
238
  return normalizePathname(path) + suffix;
230
239
  }
231
240
 
232
- function matchRoute(route, pathname) {
241
+ function matchRoute(route, pathname, exact = true) {
233
242
  const r = normalizePathname(route);
234
243
  const p = normalizePathname(pathname);
235
- return r === p;
244
+ if (exact) return r === p;
245
+ // Prefix match: either exactly the same, or p starts with r plus a slash
246
+ return p === r || p.startsWith(r.endsWith('/') ? r : r + '/');
236
247
  }
237
248
 
238
249
  function beginPathEvaluation(pathname) {
@@ -257,13 +268,41 @@ export function Route(props = {}) {
257
268
  ensureListener();
258
269
 
259
270
  return createElement('span', { style: { display: 'contents' } }, () => {
271
+ const parentPath = readContext(RoutingContext) || '';
260
272
  const pathname = normalizePathname(currentPath());
261
273
  beginPathEvaluation(pathname);
262
- const route = props.route ?? '/';
263
- if (!matchRoute(route, pathname)) return null;
264
274
 
265
- hasMatchForPath = true;
266
- pathHasMatch(true);
275
+ const routeProp = props.route ?? '/';
276
+ if (typeof routeProp === 'string' && !routeProp.startsWith('/')) {
277
+ throw new Error(`Invalid route: "${routeProp}". All routes must start with a forward slash "/". (Nested under: "${parentPath || 'root'}")`);
278
+ }
279
+
280
+ let fullRoute = '';
281
+ if (parentPath && parentPath !== '/') {
282
+ const cleanParent = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath;
283
+ const cleanChild = routeProp.startsWith('/') ? routeProp : '/' + routeProp;
284
+
285
+ if (cleanChild.startsWith(cleanParent + '/') || cleanChild === cleanParent) {
286
+ fullRoute = normalizePathname(cleanChild);
287
+ } else {
288
+ fullRoute = normalizePathname(cleanParent + cleanChild);
289
+ }
290
+ } else {
291
+ fullRoute = normalizePathname(routeProp);
292
+ }
293
+
294
+ const isRoot = fullRoute === '/';
295
+ const exact = props.exact !== undefined ? Boolean(props.exact) : isRoot;
296
+
297
+ // For nested routing, we match as a prefix so parents stay rendered while children are active
298
+ if (!matchRoute(fullRoute, pathname, exact)) return null;
299
+
300
+ // If it's an exact match of the FULL segments, mark as matched for 404 purposes
301
+ if (matchRoute(fullRoute, pathname, true)) {
302
+ hasMatchForPath = true;
303
+ pathHasMatch(true);
304
+ }
305
+
267
306
  const mergedHead = (props.head && typeof props.head === 'object') ? props.head : {};
268
307
  const meta = props.description
269
308
  ? ([{ name: 'description', content: String(props.description) }].concat(mergedHead.meta ?? props.meta ?? []))
@@ -274,7 +313,9 @@ export function Route(props = {}) {
274
313
  const favicon = mergedHead.favicon ?? props.favicon;
275
314
 
276
315
  applyHead({ title, meta, links, icon, favicon });
277
- return props.children;
316
+
317
+ // Provide the current full path to nested routes
318
+ return createElement(RoutingContext.Provider, { value: fullRoute }, props.children);
278
319
  });
279
320
  }
280
321
 
@@ -282,13 +323,39 @@ export function Page(props = {}) {
282
323
  ensureListener();
283
324
 
284
325
  return createElement('span', { style: { display: 'contents' } }, () => {
326
+ const parentPath = readContext(RoutingContext) || '';
285
327
  const pathname = normalizePathname(currentPath());
286
328
  beginPathEvaluation(pathname);
287
- const route = props.route ?? '/';
288
- if (!matchRoute(route, pathname)) return null;
289
329
 
290
- hasMatchForPath = true;
291
- pathHasMatch(true);
330
+ const routeProp = props.route ?? '/';
331
+ if (typeof routeProp === 'string' && !routeProp.startsWith('/')) {
332
+ throw new Error(`Invalid route: "${routeProp}". All routes must start with a forward slash "/". (Nested under: "${parentPath || 'root'}")`);
333
+ }
334
+
335
+ let fullRoute = '';
336
+ if (parentPath && parentPath !== '/') {
337
+ const cleanParent = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath;
338
+ const cleanChild = routeProp.startsWith('/') ? routeProp : '/' + routeProp;
339
+
340
+ if (cleanChild.startsWith(cleanParent + '/') || cleanChild === cleanParent) {
341
+ fullRoute = normalizePathname(cleanChild);
342
+ } else {
343
+ fullRoute = normalizePathname(cleanParent + cleanChild);
344
+ }
345
+ } else {
346
+ fullRoute = normalizePathname(routeProp);
347
+ }
348
+
349
+ const isRoot = fullRoute === '/';
350
+ const exact = props.exact !== undefined ? Boolean(props.exact) : isRoot;
351
+
352
+ if (!matchRoute(fullRoute, pathname, exact)) return null;
353
+
354
+ if (matchRoute(fullRoute, pathname, true)) {
355
+ hasMatchForPath = true;
356
+ pathHasMatch(true);
357
+ }
358
+
292
359
  const mergedHead = (props.head && typeof props.head === 'object') ? props.head : {};
293
360
  const meta = props.description
294
361
  ? ([{ name: 'description', content: String(props.description) }].concat(mergedHead.meta ?? props.meta ?? []))
@@ -299,7 +366,8 @@ export function Page(props = {}) {
299
366
  const favicon = mergedHead.favicon ?? props.favicon;
300
367
 
301
368
  applyHead({ title, meta, links, icon, favicon });
302
- return props.children;
369
+
370
+ return createElement(RoutingContext.Provider, { value: fullRoute }, props.children);
303
371
  });
304
372
  }
305
373
 
@@ -318,6 +386,7 @@ export function NotFound(props = {}) {
318
386
  if (lastPathEvaluated !== pathname) return null;
319
387
 
320
388
  if (hasMatch) return null;
389
+ if (pathname === '/') return null;
321
390
 
322
391
  const Comp = props.component ?? defaultNotFoundComponent;
323
392
  if (typeof Comp === 'function') {
@@ -339,8 +408,8 @@ export function Link(props = {}) {
339
408
  const rawHref = props.href ?? props.to ?? '#';
340
409
  const href = spaNormalizeHref(rawHref);
341
410
 
342
- const spa = props.spa !== undefined ? Boolean(props.spa) : true;
343
- const reload = Boolean(props.reload);
411
+ const spa = props.spa !== undefined ? Boolean(props.spa) : true;
412
+ const reload = Boolean(props.reload);
344
413
 
345
414
  const onClick = (e) => {
346
415
  if (typeof props.onClick === 'function') props.onClick(e);
@@ -0,0 +1,36 @@
1
+ import { defineConfig } from 'vite';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+
5
+ export default defineConfig({
6
+ build: {
7
+ target: 'es2022',
8
+ outDir: 'dist',
9
+ emptyOutDir: true,
10
+ minify: false,
11
+ lib: {
12
+ entry: {
13
+ index: path.resolve(__dirname, 'src/index.js'),
14
+ cli: path.resolve(__dirname, 'src/cli.js'),
15
+ 'vite-plugin': path.resolve(__dirname, 'src/compiler/vite-plugin.js')
16
+ },
17
+ formats: ['es']
18
+ },
19
+ rollupOptions: {
20
+ external: [
21
+ 'vite',
22
+ 'marked',
23
+ 'node:fs', 'node:path', 'node:process', 'node:url', 'node:vm', 'node:util',
24
+ 'fs', 'path', 'process', 'url', 'vm', 'util'
25
+ ],
26
+ output: {
27
+ banner: (chunk) => {
28
+ if (chunk.name === 'cli' || chunk.fileName === 'cli.js') {
29
+ return '#!/usr/bin/env node';
30
+ }
31
+ return '';
32
+ }
33
+ }
34
+ },
35
+ },
36
+ });
package/index.html DELETED
@@ -1,19 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
-
4
- <head>
5
- <meta charset="UTF-8">
6
- <title>Round Vite Test</title>
7
- </head>
8
-
9
- <body>
10
- <div id="app"></div>
11
- <script type="module">
12
- import { render } from '/index.js';
13
- import TestApp from 'start_exmpl/TestApp.round';
14
-
15
- render(TestApp, document.getElementById('app'));
16
- </script>
17
- </body>
18
-
19
- </html>