@markless/router 0.1.0 → 0.1.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.
package/dist/index.d.ts CHANGED
@@ -79,6 +79,31 @@ declare function transformRequestFileSource(fileId: string, sourceText: string):
79
79
  //#region src/route-renderer.d.ts
80
80
  declare function startRouteUpdateRenderer(document?: Document): void;
81
81
  //#endregion
82
+ //#region src/spa-navigation.d.ts
83
+ type MarklessRouterNavigationRuntime = Pick<Navigation, 'addEventListener' | 'navigate'>;
84
+ interface MarklessRouterNavigationWindow {
85
+ readonly document: Document;
86
+ readonly location: Location;
87
+ navigation?: MarklessRouterNavigationRuntime;
88
+ addEventListener(type: 'click', listener: (event: MouseEvent) => void, options?: boolean | AddEventListenerOptions): void;
89
+ }
90
+ interface MarklessRouterNavigationPolyfillModule {
91
+ applyPolyfill(options: {
92
+ readonly interceptEvents: boolean;
93
+ readonly window: MarklessRouterNavigationWindow;
94
+ }): unknown;
95
+ }
96
+ interface StartSpaNavigationOptions {
97
+ readonly documentModuleLoader?: () => Promise<unknown>;
98
+ readonly loadPolyfill?: () => Promise<MarklessRouterNavigationPolyfillModule>;
99
+ readonly pageModuleLoaders: Record<string, () => Promise<unknown>>;
100
+ readonly preloadRouteModule?: (file: string) => unknown;
101
+ readonly routeFileIds: readonly string[];
102
+ readonly window?: MarklessRouterNavigationWindow;
103
+ }
104
+ declare function __marklessRouterStartSpaNavigation(options: StartSpaNavigationOptions): Promise<void>;
105
+ declare function ensureNavigationRuntime(runtimeWindow?: MarklessRouterNavigationWindow, loadPolyfill?: () => Promise<MarklessRouterNavigationPolyfillModule>): Promise<MarklessRouterNavigationRuntime>;
106
+ //#endregion
82
107
  //#region src/route-state.d.ts
83
108
  declare const MARKLESS_ROUTER_ROUTE_EVENT = "marklessrouternavigate";
84
109
  interface RouteState {
@@ -172,4 +197,4 @@ declare const Link: ((props?: LinkProps) => unknown) & {
172
197
  };
173
198
  };
174
199
  //#endregion
175
- export { type ApiRequestFileCache, type ApiRequestFileMethod, type ApiRequestFileRoute, AppLocals, DefaultLinkProps, EndpointHttpContext, Html, HttpContext, HttpResponse, Link, LinkNavigationProps, LinkProps, MARKLESS_ROUTER_ROUTE_EVENT, MarklessRouterGeneratedRoutes, MiddlewareHttpContext, PageProps, type RequestFileDefaultExport, type RequestFileDiagnostic, type RequestFileDiagnosticCode, type RequestFileParam, type RequestFileParseResult, type RequestFileTransformResult, type RouteDocumentModule, type RouteManifest, type RouteManifestMatch, type RouteManifestParam, type RouteManifestRoute, type RouteManifestStatusPages, type RoutePageModule, type RouteState, type RouteUpdate, __marklessCreateHttpContext, buildRouteManifestFromFileIds, dispatchRouteUpdate, matchRouteManifest, normalizeRequestFileId, normalizeRequestPathname, normalizeRouteFileId, parseRequestFile, routePageProps, startRouteUpdateRenderer, transformRequestFileSource };
200
+ export { type ApiRequestFileCache, type ApiRequestFileMethod, type ApiRequestFileRoute, AppLocals, DefaultLinkProps, EndpointHttpContext, Html, HttpContext, HttpResponse, Link, LinkNavigationProps, LinkProps, MARKLESS_ROUTER_ROUTE_EVENT, MarklessRouterGeneratedRoutes, MiddlewareHttpContext, PageProps, type RequestFileDefaultExport, type RequestFileDiagnostic, type RequestFileDiagnosticCode, type RequestFileParam, type RequestFileParseResult, type RequestFileTransformResult, type RouteDocumentModule, type RouteManifest, type RouteManifestMatch, type RouteManifestParam, type RouteManifestRoute, type RouteManifestStatusPages, type RoutePageModule, type RouteState, type RouteUpdate, __marklessCreateHttpContext, __marklessRouterStartSpaNavigation, buildRouteManifestFromFileIds, dispatchRouteUpdate, ensureNavigationRuntime, matchRouteManifest, normalizeRequestFileId, normalizeRequestPathname, normalizeRouteFileId, parseRequestFile, routePageProps, startRouteUpdateRenderer, transformRequestFileSource };
package/dist/index.js CHANGED
@@ -60,6 +60,157 @@ async function renderRouteUpdate(document, update) {
60
60
  }
61
61
  }
62
62
  //#endregion
63
+ //#region src/spa-navigation.ts
64
+ const STARTED = "__marklessRouterSpaNavigationStarted";
65
+ const LINK_ATTRIBUTE = "data-markless-router-link";
66
+ const REPLACE_ATTRIBUTE = "data-markless-router-replace";
67
+ const SCROLL_ATTRIBUTE = "data-markless-router-scroll";
68
+ const LINK_INFO = "__marklessRouterLink";
69
+ function hashRoutePath(url) {
70
+ return url.hash.startsWith("#/") ? url.hash.slice(1) : void 0;
71
+ }
72
+ function routePathname(url, _context) {
73
+ return hashRoutePath(url) ?? url.pathname;
74
+ }
75
+ async function __marklessRouterStartSpaNavigation(options) {
76
+ const runtimeWindow = options.window ?? browserWindow();
77
+ const state = runtimeWindow;
78
+ if (state[STARTED]) return;
79
+ state[STARTED] = true;
80
+ const context = {
81
+ documentModuleLoader: options.documentModuleLoader,
82
+ manifest: buildRouteManifestFromFileIds(options.routeFileIds),
83
+ pageModuleLoaders: options.pageModuleLoaders,
84
+ preloadRouteModule: options.preloadRouteModule,
85
+ window: runtimeWindow
86
+ };
87
+ const navigation = await ensureNavigationRuntime(runtimeWindow, options.loadPolyfill);
88
+ runtimeWindow.addEventListener("click", (event) => handleLinkClick(event, context, navigation), true);
89
+ navigation.addEventListener("navigate", (event) => {
90
+ handleNavigateEvent(event, context);
91
+ });
92
+ {
93
+ const bootUrl = parseSameOriginUrl(runtimeWindow.location.href, runtimeWindow.location.href);
94
+ const bootHashPath = bootUrl ? hashRoutePath(bootUrl) : void 0;
95
+ if (bootUrl && bootHashPath && matchRouteManifest(bootHashPath, context.manifest)) renderRoute(bootUrl, context);
96
+ }
97
+ }
98
+ async function ensureNavigationRuntime(runtimeWindow = browserWindow(), loadPolyfill) {
99
+ if (!runtimeWindow.navigation) {
100
+ const { applyPolyfill } = await (loadPolyfill?.() ?? import("@virtualstate/navigation"));
101
+ runtimeWindow.navigation = applyPolyfill({
102
+ interceptEvents: false,
103
+ window: runtimeWindow
104
+ });
105
+ }
106
+ return runtimeWindow.navigation;
107
+ }
108
+ function handleNavigateEvent(event, context) {
109
+ const url = routeUrl(event, context);
110
+ if (!url) return false;
111
+ event.intercept({
112
+ focusReset: "after-transition",
113
+ scroll: navigationScroll(event),
114
+ handler: () => renderRoute(url, context, event.signal)
115
+ });
116
+ return true;
117
+ }
118
+ async function renderRoute(url, context, signal) {
119
+ if (signal?.aborted) return;
120
+ const match = matchRouteManifest(routePathname(url, context), context.manifest);
121
+ const loadPageModule = match && context.pageModuleLoaders[match.route.file];
122
+ if (!match || !loadPageModule) {
123
+ if (hashRoutePath(url) !== void 0) warnUnknownHashRoute(url);
124
+ context.window.location.assign(url.href);
125
+ return;
126
+ }
127
+ preloadRouteModule(context, match.route.file);
128
+ const [page, document] = await Promise.all([loadPageModule(), context.documentModuleLoader?.()]);
129
+ if (signal?.aborted) return;
130
+ const update = {
131
+ document,
132
+ page,
133
+ route: {
134
+ file: match.route.file,
135
+ params: match.params,
136
+ status: 200,
137
+ url: url.href
138
+ },
139
+ signal
140
+ };
141
+ if (!(context.window.document["__marklessRouterRouteRendererStarted"] === true)) {
142
+ dispatchRouteUpdate(context.window.document, update);
143
+ return;
144
+ }
145
+ await new Promise((resolve) => {
146
+ dispatchRouteUpdate(context.window.document, {
147
+ ...update,
148
+ onRendered: resolve
149
+ });
150
+ });
151
+ }
152
+ function warnUnknownHashRoute(url) {
153
+ if (!import.meta.env?.DEV) return;
154
+ console.error(`MARKLESS_ROUTER_UNKNOWN_HASH_ROUTE: Navigation to "${url.hash}" matched no route file. Hash URLs cannot fall back to a server document load, so this navigation does nothing. Add a page for "${url.hash.slice(1)}" or fix the link href.`);
155
+ }
156
+ function handleLinkClick(event, context, navigation) {
157
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
158
+ const anchor = sourceAnchor(event);
159
+ if (!anchor || !anchor.hasAttribute(LINK_ATTRIBUTE) || !isEligibleLink(anchor)) return;
160
+ const url = parseSameOriginUrl(anchor.href, context.window.location.href);
161
+ const match = url && matchRouteManifest(routePathname(url, context), context.manifest);
162
+ if (!match) return;
163
+ preloadRouteModule(context, match.route.file);
164
+ event.preventDefault();
165
+ navigation.navigate(url.href, {
166
+ history: anchor.hasAttribute(REPLACE_ATTRIBUTE) ? "replace" : "push",
167
+ info: {
168
+ [LINK_INFO]: true,
169
+ scroll: anchor.getAttribute(SCROLL_ATTRIBUTE) === "manual" ? "manual" : void 0
170
+ }
171
+ });
172
+ }
173
+ function preloadRouteModule(context, file) {
174
+ try {
175
+ context.preloadRouteModule?.(file);
176
+ } catch {}
177
+ }
178
+ function routeUrl(event, context) {
179
+ const destination = parseSameOriginUrl(event.destination.url, context.window.location.href);
180
+ const hashRouteNavigation = event.hashChange === true && !!destination && hashRoutePath(destination) !== void 0;
181
+ if (!isMarklessRouterNavigation(event) && !hashRouteNavigation || event.canIntercept === false || event.navigationType === "reload" || event.hashChange && !hashRouteNavigation || event.downloadRequest != null || event.formData != null) return;
182
+ const matched = destination && matchRouteManifest(routePathname(destination), context.manifest) ? destination : void 0;
183
+ if (!matched && hashRouteNavigation && destination) warnUnknownHashRoute(destination);
184
+ return matched;
185
+ }
186
+ function isMarklessRouterNavigation(event) {
187
+ return event.info?.[LINK_INFO] === true || event.navigationType === "traverse";
188
+ }
189
+ function navigationScroll(event) {
190
+ const info = event.info;
191
+ return info?.[LINK_INFO] === true && info.scroll === "manual" ? "manual" : "after-transition";
192
+ }
193
+ function parseSameOriginUrl(href, base) {
194
+ try {
195
+ const current = new URL(base);
196
+ const url = new URL(href, current);
197
+ return url.origin === current.origin ? url : void 0;
198
+ } catch {
199
+ return;
200
+ }
201
+ }
202
+ function sourceAnchor(event) {
203
+ const target = event.composedPath?.()[0] ?? event.target;
204
+ return target?.closest?.("a[href]") ?? target?.parentElement?.closest?.("a[href]");
205
+ }
206
+ function isEligibleLink(anchor) {
207
+ const target = anchor.getAttribute("target");
208
+ return (!target || target === "_self") && !anchor.hasAttribute("download") && !anchor.relList?.contains("external");
209
+ }
210
+ function browserWindow() {
211
+ return window;
212
+ }
213
+ //#endregion
63
214
  //#region src/index.ts
64
215
  function __marklessCreateHttpContext(event) {
65
216
  const context = event.context ?? {};
@@ -120,4 +271,4 @@ function escapeHtml(value) {
120
271
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
121
272
  }
122
273
  //#endregion
123
- export { Html, Link, MARKLESS_ROUTER_ROUTE_EVENT, __marklessCreateHttpContext, buildRouteManifestFromFileIds, dispatchRouteUpdate, matchRouteManifest, normalizeRequestFileId, normalizeRequestPathname, normalizeRouteFileId, parseRequestFile, routePageProps, startRouteUpdateRenderer, transformRequestFileSource };
274
+ export { Html, Link, MARKLESS_ROUTER_ROUTE_EVENT, __marklessCreateHttpContext, __marklessRouterStartSpaNavigation, buildRouteManifestFromFileIds, dispatchRouteUpdate, ensureNavigationRuntime, matchRouteManifest, normalizeRequestFileId, normalizeRequestPathname, normalizeRouteFileId, parseRequestFile, routePageProps, startRouteUpdateRenderer, transformRequestFileSource };
package/dist/vite.js CHANGED
@@ -851,7 +851,7 @@ const ROUTE_HREF_ID = "virtual:markless-router/route-href";
851
851
  const ROUTER_OPTIONS_ID = "virtual:markless-router/options";
852
852
  const PUBLIC_VIRTUAL_MODULE_ID_RE = /^virtual:markless-router\/(?:routes|client-entry|resume-entry|resume-entry-path|navigation-entry|navigation-entry-path|route-preloads|server-entry|route-href|options)(?:\?.*)?$/;
853
853
  const VITE_PLUGIN_FILE = decodePath(parseURL(import.meta.url).pathname);
854
- const VIRTUAL_ENTRY_DIR = VITE_PLUGIN_FILE.endsWith(".ts") ? join(dirname(VITE_PLUGIN_FILE), "entries") : join(dirname(dirname(VITE_PLUGIN_FILE)), "entries");
854
+ const VIRTUAL_ENTRY_DIR = VITE_PLUGIN_FILE.endsWith(".ts") ? join(dirname(VITE_PLUGIN_FILE), "entries") : join(dirname(dirname(VITE_PLUGIN_FILE)), "src/vite/entries");
855
855
  const DEFAULT_WATCH_IGNORES = [
856
856
  "**/.nitro/**",
857
857
  "**/.output/**",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markless/router",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Markless router: client-side navigation with SSR streaming and build-time preload maps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,7 +12,8 @@
12
12
  "access": "public"
13
13
  },
14
14
  "files": [
15
- "dist"
15
+ "dist",
16
+ "src/vite/entries"
16
17
  ],
17
18
  "type": "module",
18
19
  "sideEffects": false,
@@ -54,8 +55,8 @@
54
55
  "pathe": "^2.0.3",
55
56
  "satteri": "0.6.3",
56
57
  "ufo": "^1.6.4",
57
- "@markless/bundler": "0.1.0",
58
- "@markless/web": "0.1.0"
58
+ "@markless/bundler": "0.1.1",
59
+ "@markless/web": "0.1.1"
59
60
  },
60
61
  "peerDependencies": {
61
62
  "vite": "8.0.16"
@@ -0,0 +1,67 @@
1
+ // Package specifiers only: this file ships as source in the tarball and is
2
+ // compiled in the CONSUMER app's module graph (so import.meta.glob resolves
3
+ // against the app root) — relative paths into src/ would escape the package.
4
+ import { createRouteDiscovery } from '@markless/router/vite/runtime/create-route-discovery';
5
+ import {
6
+ __marklessRouterStartSpaNavigation,
7
+ buildRouteManifestFromFileIds,
8
+ ensureNavigationRuntime,
9
+ matchRouteManifest,
10
+ startRouteUpdateRenderer,
11
+ } from '@markless/router';
12
+ import { preloadRouteModule } from 'virtual:markless-router/route-preloads';
13
+
14
+ const routeDiscovery = createRouteDiscovery(
15
+ import.meta.glob(['/pages/**/*.tsrx', '/pages/**/*.mdx']),
16
+ );
17
+
18
+ export const pageModules = routeDiscovery.pageModuleLoaders;
19
+ export const routeFileIds = routeDiscovery.routeFileIds;
20
+ const routeManifest = buildRouteManifestFromFileIds(routeFileIds);
21
+
22
+ void __marklessRouterStartSpaNavigation({
23
+ pageModuleLoaders: pageModules,
24
+ preloadRouteModule,
25
+ routeFileIds,
26
+ });
27
+ startRouteUpdateRenderer(document);
28
+
29
+ export async function navigateMarklessRouterLink(input: {
30
+ readonly href: string;
31
+ readonly replace?: boolean;
32
+ readonly scroll?: 'manual';
33
+ }) {
34
+ const url = parseSameOriginUrl(input.href, window.location.href);
35
+ const match = url && matchRouteManifest(url.pathname, routeManifest);
36
+ if (!url || !match) {
37
+ window.location.assign(input.href);
38
+ return;
39
+ }
40
+
41
+ startRouteUpdateRenderer(document);
42
+ preloadRouteModule(match.route.file);
43
+ await __marklessRouterStartSpaNavigation({
44
+ pageModuleLoaders: pageModules,
45
+ preloadRouteModule,
46
+ routeFileIds,
47
+ window,
48
+ });
49
+ const navigation = await ensureNavigationRuntime(window);
50
+ navigation.navigate(url.href, {
51
+ history: input.replace ? 'replace' : 'push',
52
+ info: {
53
+ __marklessRouterLink: true,
54
+ scroll: input.scroll,
55
+ },
56
+ });
57
+ }
58
+
59
+ function parseSameOriginUrl(href: string, base: string): URL | undefined {
60
+ try {
61
+ const current = new URL(base);
62
+ const url = new URL(href, current);
63
+ return url.origin === current.origin ? url : undefined;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
@@ -0,0 +1,42 @@
1
+ const mdxPageModuleLoaders = import.meta.glob('/pages/**/*.mdx');
2
+ const tsrxResumeModuleLoaders = import.meta.glob('/pages/**/*.tsrx', {
3
+ query: '?markless-resume',
4
+ });
5
+
6
+ export async function resumeContainerEvent(input: {
7
+ readonly root: ParentNode;
8
+ readonly [key: string]: unknown;
9
+ }) {
10
+ const file = routeFileFromRoot(input.root);
11
+ const loadRouteResumeModule = file && routeResumeModuleLoader(file);
12
+ const routeResumeModule = loadRouteResumeModule ? await loadRouteResumeModule() : undefined;
13
+ const resume = (routeResumeModule as RouteResumeModule | undefined)?.resumeContainerEvent;
14
+ if (typeof resume !== 'function') {
15
+ throw new Error(`Markless Router could not resume route module: ${file ?? '<unknown>'}`);
16
+ }
17
+ await resume(input);
18
+ }
19
+
20
+ interface RouteResumeModule {
21
+ readonly resumeContainerEvent?: (input: unknown) => unknown;
22
+ }
23
+
24
+ function routeResumeModuleLoader(file: string): (() => Promise<unknown>) | undefined {
25
+ const loaders = file.endsWith('.mdx') ? mdxPageModuleLoaders : tsrxResumeModuleLoaders;
26
+ return loaders[file] ?? loaders[`/${file}`];
27
+ }
28
+
29
+ function routeFileFromRoot(root: ParentNode): string | undefined {
30
+ const script = root.querySelector?.('script[type="@markless/core/route"]');
31
+ const text = script?.textContent;
32
+ if (!text) {
33
+ return undefined;
34
+ }
35
+
36
+ try {
37
+ const route = JSON.parse(text) as { readonly file?: unknown };
38
+ return typeof route.file === 'string' ? route.file : undefined;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
@@ -0,0 +1,8 @@
1
+ import { createRouteDiscovery } from '@markless/router/vite/runtime/create-route-discovery';
2
+
3
+ const routeDiscovery = createRouteDiscovery(
4
+ import.meta.glob(['/pages/**/*.tsrx', '/pages/**/*.mdx']),
5
+ );
6
+
7
+ export const pageModuleLoaders = routeDiscovery.pageModuleLoaders;
8
+ export const routeFileIds = routeDiscovery.routeFileIds;
@@ -0,0 +1,35 @@
1
+ const routeParamPattern = /\[\.\.\.([A-Za-z_$][\w$]*)\]|\[([A-Za-z_$][\w$]*)\]/g;
2
+
3
+ export function __marklessRouteHref(
4
+ pattern: string,
5
+ params: Readonly<Record<string, unknown>>,
6
+ ): string {
7
+ return pattern.replace(routeParamPattern, (_match, catchAll, dynamic) => {
8
+ const name = catchAll || dynamic;
9
+ const value = params?.[name];
10
+
11
+ if (catchAll) {
12
+ return encodeCatchAllParam(pattern, value);
13
+ }
14
+
15
+ if (value === null || value === undefined) {
16
+ throw new Error(`Typed route error: ${pattern} requires params:\n- ${name}`);
17
+ }
18
+
19
+ return encodeURIComponent(String(value));
20
+ });
21
+ }
22
+
23
+ function encodeCatchAllParam(pattern: string, value: unknown): string {
24
+ const segments = Array.isArray(value)
25
+ ? value
26
+ : value === null || value === undefined
27
+ ? []
28
+ : String(value).split('/');
29
+
30
+ if (segments.length === 0 || segments.some((segment) => String(segment) === '')) {
31
+ throw new Error(`Typed route error: ${pattern} requires a non-empty catch-all param.`);
32
+ }
33
+
34
+ return segments.map((segment) => encodeURIComponent(String(segment))).join('/');
35
+ }
@@ -0,0 +1,22 @@
1
+ import { createServerEntry } from '@markless/router/vite/runtime/create-server-entry';
2
+ import { resumeEntryPath } from 'virtual:markless-router/resume-entry-path';
3
+ import { navigationEntryPath } from 'virtual:markless-router/navigation-entry-path';
4
+ import {
5
+ routeModulePreloads,
6
+ routeSsrModulePreloads,
7
+ } from 'virtual:markless-router/route-preloads';
8
+ import { pageModuleLoaders, routeFileIds } from 'virtual:markless-router/routes';
9
+
10
+ const documentModuleLoaders = import.meta.glob(['/document.tsrx']);
11
+ const entry = createServerEntry({
12
+ resumeEntryPath,
13
+ navigationEntryPath,
14
+ routeModulePreloads,
15
+ routeSsrModulePreloads,
16
+ documentModuleLoader: documentModuleLoaders['/document.tsrx'],
17
+ pageModuleLoaders,
18
+ routeFileIds,
19
+ });
20
+
21
+ export const fetch = entry.fetch;
22
+ export default entry;