@emberkit/core 0.2.8 → 0.2.10

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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Scores a route path for specificity. Higher score = more specific.
3
+ * Static segments beat dynamic params; dynamic params beat catch-alls.
4
+ * Used to resolve conflicts when multiple routes match the same URL.
5
+ */
6
+ export declare function scoreRoute(routePath: string): number;
7
+ /**
8
+ * Returns the best-matching route for the given pathname.
9
+ * When multiple routes match (e.g. /admin and /:language both match "/admin"),
10
+ * the route with the highest specificity score wins — static beats dynamic.
11
+ */
12
+ export declare function matchRoute<T extends {
13
+ path: string;
14
+ }>(routes: T[], pathname: string): T | undefined;
15
+ //# sourceMappingURL=match.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"match.d.ts","sourceRoot":"","sources":["../../../src/runtime/helpers/match.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAmBpD;AA8BD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAkBnG"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Scores a route path for specificity. Higher score = more specific.
3
+ * Static segments beat dynamic params; dynamic params beat catch-alls.
4
+ * Used to resolve conflicts when multiple routes match the same URL.
5
+ */
6
+ export function scoreRoute(routePath) {
7
+ let score = 100;
8
+ const segments = routePath.split('/').filter(Boolean);
9
+ score -= segments.length * 20;
10
+ for (const segment of segments) {
11
+ if (segment.startsWith(':') || segment.startsWith('*') || segment.endsWith('*')) {
12
+ score -= 30;
13
+ }
14
+ else {
15
+ score += 10;
16
+ }
17
+ }
18
+ if (routePath.includes('*')) {
19
+ score -= 50;
20
+ }
21
+ return Math.max(0, score);
22
+ }
23
+ function routeMatchesPath(routePath, normalized) {
24
+ if (routePath === normalized)
25
+ return true;
26
+ const routeParts = routePath.split('/').filter(Boolean);
27
+ const pathParts = normalized.split('/').filter(Boolean);
28
+ // Catch-all routes (e.g. /:slug*) can match longer paths
29
+ const hasCatchAll = routeParts.some((p) => p.endsWith('*'));
30
+ if (hasCatchAll) {
31
+ if (pathParts.length < routeParts.length)
32
+ return false;
33
+ for (let i = 0; i < routeParts.length; i++) {
34
+ const part = routeParts[i];
35
+ if (part.startsWith(':'))
36
+ continue;
37
+ if (part !== pathParts[i])
38
+ return false;
39
+ }
40
+ return true;
41
+ }
42
+ // Standard routes: segment counts must match exactly
43
+ if (routeParts.length !== pathParts.length)
44
+ return false;
45
+ for (let i = 0; i < routeParts.length; i++) {
46
+ if (routeParts[i].startsWith(':'))
47
+ continue;
48
+ if (routeParts[i] !== pathParts[i])
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+ /**
54
+ * Returns the best-matching route for the given pathname.
55
+ * When multiple routes match (e.g. /admin and /:language both match "/admin"),
56
+ * the route with the highest specificity score wins — static beats dynamic.
57
+ */
58
+ export function matchRoute(routes, pathname) {
59
+ const normalized = pathname === '/' ? '/' : pathname.replace(/\/$/, '');
60
+ let bestMatch;
61
+ let bestScore = -1;
62
+ for (const route of routes) {
63
+ const routePath = route.path === '/' ? '/' : route.path.replace(/\/$/, '');
64
+ if (routeMatchesPath(routePath, normalized)) {
65
+ const score = scoreRoute(routePath);
66
+ if (score > bestScore) {
67
+ bestScore = score;
68
+ bestMatch = route;
69
+ }
70
+ }
71
+ }
72
+ return bestMatch;
73
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAInF,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,EACpD,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,EACtC,GAAG,QAAQ,EAAE,OAAO,EAAE,GACrB,UAAU,CAYZ;AA6ID,wBAAgB,MAAM,CACpB,OAAO,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,EACnF,SAAS,EAAE,OAAO,GAAG,MAAM,EAC3B,OAAO,CAAC,EAAE;IACR,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,KAAK,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,SAAS,EAAE,MAAM,OAAO,CAAC;YAAE,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAA;SAAE,CAAC,CAAC;KACpF,CAAC,CAAC;CACJ,GACA,IAAI,CA+FN;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAE9F;AAED,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAE9C;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,UAAU,CAEjE;AAED,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAKnF,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,EACpD,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,EACtC,GAAG,QAAQ,EAAE,OAAO,EAAE,GACrB,UAAU,CAYZ;AA6ID,wBAAgB,MAAM,CACpB,OAAO,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,EACnF,SAAS,EAAE,OAAO,GAAG,MAAM,EAC3B,OAAO,CAAC,EAAE;IACR,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,KAAK,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,SAAS,EAAE,MAAM,OAAO,CAAC;YAAE,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAA;SAAE,CAAC,CAAC;KACpF,CAAC,CAAC;CACJ,GACA,IAAI,CAmFN;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAE9F;AAED,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAE9C;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,UAAU,CAEjE;AAED,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,CAAC"}
@@ -1,5 +1,6 @@
1
1
  import { renderToString, getHandler, clearHandlers } from './helpers/render.js';
2
2
  import { getSignalByIndex } from '../signals/helpers/core.js';
3
+ import { matchRoute } from './helpers/match.js';
3
4
  export function createElement(type, props, ...children) {
4
5
  const resolvedProps = props ?? {};
5
6
  const flatChildren = children.flat().filter((child) => child != null && child !== false);
@@ -147,36 +148,8 @@ export function render(element, container, options) {
147
148
  renderToTarget(layout, target);
148
149
  return;
149
150
  }
150
- function matchRoute(pathname) {
151
- const normalized = pathname === '/' ? '/' : pathname.replace(/\/$/, '');
152
- for (const route of routes) {
153
- const routePath = route.path === '/' ? '/' : route.path.replace(/\/$/, '');
154
- if (routePath === normalized)
155
- return route;
156
- if (routePath !== '/' && normalized.startsWith(routePath + '/'))
157
- return route;
158
- if (routePath !== '/' && routePath.includes(':')) {
159
- const routeParts = routePath.split('/');
160
- const pathParts = normalized.split('/');
161
- if (routeParts.length === pathParts.length) {
162
- let match = true;
163
- for (let i = 0; i < routeParts.length; i++) {
164
- if (routeParts[i].startsWith(':'))
165
- continue;
166
- if (routeParts[i] !== pathParts[i]) {
167
- match = false;
168
- break;
169
- }
170
- }
171
- if (match)
172
- return route;
173
- }
174
- }
175
- }
176
- return undefined;
177
- }
178
151
  async function renderCurrentRoute() {
179
- const matched = matchRoute(window.location.pathname);
152
+ const matched = matchRoute(routes, window.location.pathname);
180
153
  if (matched) {
181
154
  const mod = await matched.component();
182
155
  const params = extractParamsFromPath(matched.path, window.location.pathname);
@@ -196,11 +169,22 @@ export function render(element, container, options) {
196
169
  if (!link)
197
170
  return;
198
171
  const href = link.getAttribute('href');
199
- if (!href || href.startsWith('http') || href.startsWith('#') || link.target === '_blank')
172
+ if (!href || href.startsWith('http') || link.target === '_blank')
200
173
  return;
174
+ // Handle anchor links
175
+ if (href.startsWith('#'))
176
+ return; // Pure anchor link (e.g., #section)
177
+ // Check if link is to an anchor on the same page (e.g., /current-page#section)
178
+ if (href.includes('#')) {
179
+ const [linkPath] = href.split('#');
180
+ const currentPath = window.location.pathname;
181
+ // If the path portion matches current page, it's a same-page anchor
182
+ if (linkPath === currentPath || linkPath === '') {
183
+ return; // Allow default browser behavior to scroll to anchor
184
+ }
185
+ }
201
186
  e.preventDefault();
202
187
  history.pushState(null, '', href);
203
- renderCurrentRoute();
204
188
  });
205
189
  window.addEventListener('popstate', () => {
206
190
  renderCurrentRoute();
@@ -1115,6 +1115,23 @@ function scanRouteFiles(dir) {
1115
1115
  walk(dir);
1116
1116
  return files;
1117
1117
  }
1118
+ function scoreRoutePath(routePath) {
1119
+ let score = 100;
1120
+ const segments = routePath.split('/').filter(Boolean);
1121
+ score -= segments.length * 20;
1122
+ for (const segment of segments) {
1123
+ if (segment.startsWith(':') || segment.startsWith('*') || segment.endsWith('*')) {
1124
+ score -= 30;
1125
+ }
1126
+ else {
1127
+ score += 10;
1128
+ }
1129
+ }
1130
+ if (routePath.includes('*')) {
1131
+ score -= 50;
1132
+ }
1133
+ return Math.max(0, score);
1134
+ }
1118
1135
  function generateRoutesCode(files, routeDir) {
1119
1136
  const routeEntries = [];
1120
1137
  for (const file of files) {
@@ -1143,12 +1160,13 @@ function generateRoutesCode(files, routeDir) {
1143
1160
  routePath = '/' + routePath;
1144
1161
  }
1145
1162
  const importPath = file.replace(/\\/g, '/');
1146
- if (isMarkdown) {
1147
- routeEntries.push(` { path: ${JSON.stringify(routePath)}, component: () => import(${JSON.stringify(importPath)}), isMarkdown: true }`);
1148
- }
1149
- else {
1150
- routeEntries.push(` { path: ${JSON.stringify(routePath)}, component: () => import(${JSON.stringify(importPath)}) }`);
1151
- }
1163
+ const entry = isMarkdown
1164
+ ? ` { path: ${JSON.stringify(routePath)}, component: () => import(${JSON.stringify(importPath)}), isMarkdown: true }`
1165
+ : ` { path: ${JSON.stringify(routePath)}, component: () => import(${JSON.stringify(importPath)}) }`;
1166
+ routeEntries.push({ path: routePath, entry });
1152
1167
  }
1153
- return `export const routes = [\n${routeEntries.join(',\n')}\n];`;
1168
+ // Sort static routes before dynamic routes so the emitted array already has
1169
+ // the correct priority order (defense-in-depth alongside runtime scoring).
1170
+ routeEntries.sort((a, b) => scoreRoutePath(b.path) - scoreRoutePath(a.path));
1171
+ return `export const routes = [\n${routeEntries.map((r) => r.entry).join(',\n')}\n];`;
1154
1172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emberkit/core",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Lightweight TypeScript-first JSX framework core",