@emberkit/core 0.2.7 → 0.2.9

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;AAsBD;;;;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,63 @@
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
+ if (routePath !== '/' && normalized.startsWith(routePath + '/'))
27
+ return true;
28
+ if (routePath !== '/' && routePath.includes(':')) {
29
+ const routeParts = routePath.split('/');
30
+ const pathParts = normalized.split('/');
31
+ if (routeParts.length === pathParts.length) {
32
+ for (let i = 0; i < routeParts.length; i++) {
33
+ if (routeParts[i].startsWith(':'))
34
+ continue;
35
+ if (routeParts[i] !== pathParts[i])
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+ /**
44
+ * Returns the best-matching route for the given pathname.
45
+ * When multiple routes match (e.g. /admin and /:language both match "/admin"),
46
+ * the route with the highest specificity score wins — static beats dynamic.
47
+ */
48
+ export function matchRoute(routes, pathname) {
49
+ const normalized = pathname === '/' ? '/' : pathname.replace(/\/$/, '');
50
+ let bestMatch;
51
+ let bestScore = -1;
52
+ for (const route of routes) {
53
+ const routePath = route.path === '/' ? '/' : route.path.replace(/\/$/, '');
54
+ if (routeMatchesPath(routePath, normalized)) {
55
+ const score = scoreRoute(routePath);
56
+ if (score > bestScore) {
57
+ bestScore = score;
58
+ bestMatch = route;
59
+ }
60
+ }
61
+ }
62
+ return bestMatch;
63
+ }
@@ -2,6 +2,7 @@ import type { JSXElement, JSXElementProps, JSXNode } from '../types.js';
2
2
  export declare function getHandler(id: string): ((e: Event) => void) | undefined;
3
3
  export declare function clearHandlers(): void;
4
4
  export declare function renderElementToHTML(element: JSXElement): string;
5
+ export declare function escapeHtml(str: string): string;
5
6
  export declare function renderToString(element: JSXElement | string | null | number | unknown[]): string;
6
7
  export declare function getComponentName(type: string | ((props: JSXElementProps) => JSXNode)): string;
7
8
  export declare function createPropsProxy(props: JSXElementProps): JSXElementProps;
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../../src/runtime/helpers/render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,UAAU,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAuBpF,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,SAAS,CAEvE;AAED,wBAAgB,aAAa,IAAI,IAAI,CAGpC;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,CA+G/D;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,MAAM,CAO/F;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,GAAG,MAAM,CAM7F;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,eAAe,GAAG,eAAe,CASxE"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../../src/runtime/helpers/render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,UAAU,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAuBpF,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,SAAS,CAEvE;AAED,wBAAgB,aAAa,IAAI,IAAI,CAGpC;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,CA+G/D;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO9C;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,MAAM,CAO/F;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,GAAG,MAAM,CAM7F;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,eAAe,GAAG,eAAe,CASxE"}
@@ -45,7 +45,7 @@ export function renderElementToHTML(element) {
45
45
  }
46
46
  else if (typeof result === 'string' || typeof result === 'number') {
47
47
  renderDepth--;
48
- return String(result);
48
+ return typeof result === 'string' ? escapeHtml(result) : String(result);
49
49
  }
50
50
  else if (Array.isArray(result)) {
51
51
  const r = result.map((item) => renderToString(item)).join('');
@@ -114,9 +114,9 @@ export function renderElementToHTML(element) {
114
114
  return `${cssProp}: ${val}`;
115
115
  })
116
116
  .join('; ');
117
- return ` ${key}="${styleStr}"`;
117
+ return ` ${key}="${escapeHtml(styleStr)}"`;
118
118
  }
119
- return ` ${key}="${value}"`;
119
+ return ` ${key}="${typeof value === 'string' ? escapeHtml(value) : value}"`;
120
120
  })
121
121
  .join('');
122
122
  // Register onClick handler as data attribute
@@ -134,11 +134,19 @@ export function renderElementToHTML(element) {
134
134
  renderDepth--;
135
135
  return `<${currentType}${attributes}${onclickAttr}>${innerHtml}</${currentType}>`;
136
136
  }
137
+ export function escapeHtml(str) {
138
+ return str
139
+ .replace(/&/g, '&amp;')
140
+ .replace(/</g, '&lt;')
141
+ .replace(/>/g, '&gt;')
142
+ .replace(/"/g, '&quot;')
143
+ .replace(/'/g, '&#039;');
144
+ }
137
145
  export function renderToString(element) {
138
146
  if (!element && element !== 0)
139
147
  return '';
140
148
  if (typeof element === 'string')
141
- return element;
149
+ return escapeHtml(element);
142
150
  if (typeof element === 'number')
143
151
  return String(element);
144
152
  if (Array.isArray(element))
@@ -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,CAoFN;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,8 +169,20 @@ 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
188
  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.7",
3
+ "version": "0.2.9",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Lightweight TypeScript-first JSX framework core",