svelte-crumbs 1.0.0

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/README.md ADDED
@@ -0,0 +1,247 @@
1
+ # svelte-crumbs
2
+
3
+ Automatic, SSR-ready breadcrumbs for SvelteKit via route-level metadata exports. Zero config, fully reactive, server-rendered with top-level await.
4
+
5
+ **Svelte 5 + SvelteKit 2 only. Data layer only — bring your own rendering.**
6
+
7
+ **[Documentation & Live Demo](https://svelte-crumbs.vercel.app/)**
8
+
9
+ ## Quick Start
10
+
11
+ ### 1. Install
12
+
13
+ ```bash
14
+ npm install svelte-crumbs
15
+ ```
16
+
17
+ ### 2. Export breadcrumbs from your routes
18
+
19
+ ```svelte
20
+ <!-- src/routes/products/+page.svelte -->
21
+ <script lang="ts" module>
22
+ import type { BreadcrumbMeta } from 'svelte-crumbs';
23
+
24
+ export const breadcrumb: BreadcrumbMeta = async () => ({
25
+ label: 'Products'
26
+ });
27
+ </script>
28
+ ```
29
+
30
+ ### 3. Render in your layout
31
+
32
+ ```svelte
33
+ <!-- src/routes/+layout.svelte -->
34
+ <script lang="ts">
35
+ import { createBreadcrumbs } from 'svelte-crumbs';
36
+
37
+ const getBreadcrumbs = createBreadcrumbs();
38
+ const crumbs = $derived(await getBreadcrumbs());
39
+ </script>
40
+
41
+ <nav>
42
+ {#each crumbs as crumb, i}
43
+ {#if i > 0} / {/if}
44
+ <a href={crumb.url}>{crumb.label}</a>
45
+ {/each}
46
+ </nav>
47
+ ```
48
+
49
+ No `{#await}` blocks needed. Breadcrumbs resolve during SSR and update reactively on client navigation.
50
+
51
+ ## Examples
52
+
53
+ ### Static breadcrumb
54
+
55
+ ```svelte
56
+ <script lang="ts" module>
57
+ import type { BreadcrumbMeta } from 'svelte-crumbs';
58
+
59
+ export const breadcrumb: BreadcrumbMeta = async () => ({
60
+ label: 'Settings'
61
+ });
62
+ </script>
63
+ ```
64
+
65
+ ### From load data
66
+
67
+ The breadcrumb resolver receives the full `page` object, including `page.data`. Use `+layout.server.ts` (not `+page.server.ts`) so the data is available to child routes' breadcrumbs too:
68
+
69
+ ```ts
70
+ // src/routes/products/[id]/+layout.server.ts
71
+ export async function load({ params }) {
72
+ const product = await db.products.find(params.id);
73
+ return { product };
74
+ }
75
+ ```
76
+
77
+ ```svelte
78
+ <!-- src/routes/products/[id]/+page.svelte -->
79
+ <script lang="ts" module>
80
+ import type { BreadcrumbMeta } from 'svelte-crumbs';
81
+
82
+ export const breadcrumb: BreadcrumbMeta = async (page) => ({
83
+ label: page.data.product.name
84
+ });
85
+ </script>
86
+
87
+ <script lang="ts">
88
+ let { data } = $props();
89
+ </script>
90
+
91
+ <h1>{data.product.name}</h1>
92
+ ```
93
+
94
+ > **Why `+layout.server.ts`?** Breadcrumb resolvers run for every segment of the URL. When visiting `/products/42/edit`, the resolver for `/products/[id]` fires too. If you put the load in `+page.server.ts`, `page.data` on child routes won't have `product` — layout data cascades down, page data doesn't.
95
+
96
+ ### From a remote function
97
+
98
+ Breadcrumb resolvers can call [remote functions](https://svelte.dev/docs/kit/remote-functions) that run on the server:
99
+
100
+ ```ts
101
+ // src/lib/products.remote.ts
102
+ import { query } from '$app/server';
103
+
104
+ export const getProductName = query('unchecked', async (id: string) => {
105
+ const product = await db.products.find(id);
106
+ return product.name;
107
+ });
108
+ ```
109
+
110
+ ```svelte
111
+ <!-- src/routes/products/[id]/+page.svelte -->
112
+ <script lang="ts" module>
113
+ import type { BreadcrumbMeta } from 'svelte-crumbs';
114
+ import { getProductName } from '$lib/products.remote';
115
+
116
+ export const breadcrumb: BreadcrumbMeta = async (page) => ({
117
+ label: await getProductName(page.params.id ?? '')
118
+ });
119
+ </script>
120
+ ```
121
+
122
+ ### Multi-route breadcrumb
123
+
124
+ For dynamic routes that map to known paths:
125
+
126
+ ```svelte
127
+ <script lang="ts" module>
128
+ import type { BreadcrumbMeta } from 'svelte-crumbs';
129
+
130
+ export const breadcrumb: BreadcrumbMeta = {
131
+ routes: {
132
+ '/docs/getting-started': async () => ({ label: 'Getting Started' }),
133
+ '/docs/api-reference': async () => ({ label: 'API Reference' })
134
+ }
135
+ };
136
+ </script>
137
+ ```
138
+
139
+ ### With icon
140
+
141
+ ```svelte
142
+ <script lang="ts" module>
143
+ import type { BreadcrumbMeta } from 'svelte-crumbs';
144
+ import HomeIcon from './HomeIcon.svelte';
145
+
146
+ export const breadcrumb: BreadcrumbMeta = async () => ({
147
+ label: 'Home',
148
+ icon: HomeIcon
149
+ });
150
+ </script>
151
+ ```
152
+
153
+ ### Custom rendering
154
+
155
+ Since `svelte-crumbs` only provides data, you render however you want:
156
+
157
+ ```svelte
158
+ <script lang="ts">
159
+ import { createBreadcrumbs } from 'svelte-crumbs';
160
+
161
+ const getBreadcrumbs = createBreadcrumbs();
162
+ const crumbs = $derived(await getBreadcrumbs());
163
+ </script>
164
+
165
+ <ol class="breadcrumb-list">
166
+ {#each crumbs as crumb}
167
+ <li>
168
+ {#if crumb.icon}
169
+ {@const Icon = crumb.icon}
170
+ <Icon />
171
+ {/if}
172
+ <a href={crumb.url}>{crumb.label}</a>
173
+ </li>
174
+ {/each}
175
+ </ol>
176
+ ```
177
+
178
+ ## API Reference
179
+
180
+ ### `createBreadcrumbs()`
181
+
182
+ Creates a reactive breadcrumb resolver. Returns a getter function `() => Promise<Breadcrumb[]>`.
183
+
184
+ Call `createBreadcrumbs()` once to set up the reactive state, then use the returned getter inside `$derived(await ...)` to get breadcrumbs that update on navigation and resolve during SSR.
185
+
186
+ ### Types
187
+
188
+ ```typescript
189
+ // What you export from +page.svelte
190
+ type BreadcrumbMeta = BreadcrumbResolver | { routes: Record<string, BreadcrumbResolver> };
191
+
192
+ // Resolver function
193
+ type BreadcrumbResolver = (page: Page) => Promise<BreadcrumbData | undefined>;
194
+
195
+ // Data for one breadcrumb
196
+ type BreadcrumbData = { label: string; icon?: Component<any> };
197
+
198
+ // Resolved breadcrumb with URL
199
+ type Breadcrumb = BreadcrumbData & { url: string };
200
+ ```
201
+
202
+ ### Utility exports
203
+
204
+ - `buildBreadcrumbMap()` — manually build the route-to-resolver map
205
+ - `filePathToRoute(filePath)` — convert glob file path to route
206
+ - `matchDynamicRoute(map, route)` — match a concrete path against dynamic patterns
207
+ - `getResolversForRoute(map, route)` — collect resolvers for a given route path
208
+
209
+ ## How It Works
210
+
211
+ 1. `import.meta.glob` eagerly imports all `+page.svelte` files at build time
212
+ 2. Each file's `breadcrumb` export is collected into a `Map<route, resolver>`
213
+ 3. Route groups like `(app)` are stripped from paths
214
+ 4. On navigation, the root (`/`) resolver is checked first, then each segment is walked from left to right with dynamic `[param]` matching
215
+ 5. Matching resolvers run in parallel, producing the final breadcrumb array
216
+ 6. On SSR, top-level `await` ensures breadcrumbs are rendered in the initial HTML
217
+ 7. On the client, `$derived` re-evaluates when the route changes
218
+
219
+ ## Requirements
220
+
221
+ - **SvelteKit 2** — relies on `$app/state` and `import.meta.glob`
222
+ - **Svelte 5** — uses runes (`$derived`)
223
+ - Route groups (`(group)`) are stripped from paths
224
+
225
+ ### Optional: enable async and remote functions
226
+
227
+ The library works without any experimental flags — you can use load functions or resolve breadcrumbs manually. However, to unlock top-level `await` in components and remote function support, enable these flags:
228
+
229
+ ```js
230
+ // svelte.config.js
231
+ const config = {
232
+ compilerOptions: {
233
+ experimental: {
234
+ async: true // top-level await in components
235
+ }
236
+ },
237
+ kit: {
238
+ experimental: {
239
+ remoteFunctions: true // call server functions from breadcrumb resolvers
240
+ }
241
+ }
242
+ };
243
+ ```
244
+
245
+ ## License
246
+
247
+ MIT
@@ -0,0 +1,19 @@
1
+ import type { Breadcrumb } from '../types.js';
2
+ /**
3
+ * Creates a reactive breadcrumb resolver that automatically tracks the current route.
4
+ * Uses top-level await with SvelteKit's async experimental compiler option for SSR support.
5
+ *
6
+ * Usage:
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createBreadcrumbs } from 'svelte-breadcrumbs';
10
+ * const getBreadcrumbs = createBreadcrumbs();
11
+ * const crumbs = $derived(await getBreadcrumbs());
12
+ * </script>
13
+ *
14
+ * {#each crumbs as crumb}
15
+ * <a href={crumb.url}>{crumb.label}</a>
16
+ * {/each}
17
+ * ```
18
+ */
19
+ export declare function createBreadcrumbs(): () => Promise<Breadcrumb[]>;
@@ -0,0 +1,35 @@
1
+ import { page } from '$app/state';
2
+ import { buildBreadcrumbMap } from '../routing/build-breadcrumb-map.js';
3
+ import { getResolversForRoute } from '../routing/get-resolvers-for-route.js';
4
+ async function resolve(resolvers) {
5
+ const promises = Array.from(resolvers).map(async ([url, resolver]) => {
6
+ const data = await resolver(page);
7
+ if (!data)
8
+ return undefined;
9
+ return { ...data, url };
10
+ });
11
+ const results = await Promise.all(promises);
12
+ return results.filter((b) => b !== undefined);
13
+ }
14
+ /**
15
+ * Creates a reactive breadcrumb resolver that automatically tracks the current route.
16
+ * Uses top-level await with SvelteKit's async experimental compiler option for SSR support.
17
+ *
18
+ * Usage:
19
+ * ```svelte
20
+ * <script lang="ts">
21
+ * import { createBreadcrumbs } from 'svelte-breadcrumbs';
22
+ * const getBreadcrumbs = createBreadcrumbs();
23
+ * const crumbs = $derived(await getBreadcrumbs());
24
+ * </script>
25
+ *
26
+ * {#each crumbs as crumb}
27
+ * <a href={crumb.url}>{crumb.label}</a>
28
+ * {/each}
29
+ * ```
30
+ */
31
+ export function createBreadcrumbs() {
32
+ const map = buildBreadcrumbMap();
33
+ const resolversForRoute = $derived(getResolversForRoute(map, page.url.pathname));
34
+ return () => resolve(resolversForRoute);
35
+ }
@@ -0,0 +1 @@
1
+ export declare const getDocTitle: import("@sveltejs/kit").RemoteQueryFunction<string, string>;
@@ -0,0 +1,11 @@
1
+ import { query } from '$app/server';
2
+ const docs = {
3
+ 'getting-started': 'Getting Started',
4
+ 'api-reference': 'API Reference',
5
+ 'migration-guide': 'Migration Guide'
6
+ };
7
+ export const getDocTitle = query('unchecked', async (slug) => {
8
+ // Simulate database/CMS lookup
9
+ await new Promise((resolve) => setTimeout(resolve, 50));
10
+ return docs[slug] ?? slug;
11
+ });
@@ -0,0 +1,2 @@
1
+ export declare const getNickname: import("@sveltejs/kit").RemoteQueryFunction<void, string>;
2
+ export declare const setNickname: import("@sveltejs/kit").RemoteCommand<string, Promise<void>>;
@@ -0,0 +1,12 @@
1
+ import { command, query } from '$app/server';
2
+ let currentNickname = 'Visitor';
3
+ export const getNickname = query(async () => {
4
+ // Simulate server-side lookup (e.g. user profile, session, database)
5
+ await new Promise((resolve) => setTimeout(resolve, 50));
6
+ return currentNickname;
7
+ });
8
+ export const setNickname = command('unchecked', async (name) => {
9
+ // Simulate server-side write
10
+ await new Promise((resolve) => setTimeout(resolve, 50));
11
+ currentNickname = name.trim() || 'Visitor';
12
+ });
@@ -0,0 +1,5 @@
1
+ export { createBreadcrumbs } from './breadcrumbs/create-breadcrumbs.svelte.js';
2
+ export { buildBreadcrumbMap } from './routing/build-breadcrumb-map.js';
3
+ export { filePathToRoute, matchDynamicRoute } from './routing/match-route.js';
4
+ export { getResolversForRoute } from './routing/get-resolvers-for-route.js';
5
+ export type { Breadcrumb, BreadcrumbData, BreadcrumbMap, BreadcrumbMeta, BreadcrumbResolver } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createBreadcrumbs } from './breadcrumbs/create-breadcrumbs.svelte.js';
2
+ export { buildBreadcrumbMap } from './routing/build-breadcrumb-map.js';
3
+ export { filePathToRoute, matchDynamicRoute } from './routing/match-route.js';
4
+ export { getResolversForRoute } from './routing/get-resolvers-for-route.js';
@@ -0,0 +1,6 @@
1
+ import type { BreadcrumbMap } from '../types.js';
2
+ /**
3
+ * Scans all +page.svelte files via import.meta.glob for `breadcrumb` exports
4
+ * and builds a map from route pattern to resolver function.
5
+ */
6
+ export declare function buildBreadcrumbMap(): BreadcrumbMap;
@@ -0,0 +1,20 @@
1
+ import { filePathToRoute } from './match-route.js';
2
+ /**
3
+ * Scans all +page.svelte files via import.meta.glob for `breadcrumb` exports
4
+ * and builds a map from route pattern to resolver function.
5
+ */
6
+ export function buildBreadcrumbMap() {
7
+ const map = new Map();
8
+ const pageModules = import.meta.glob('/src/routes/**/+page.svelte', { eager: true });
9
+ for (const [filePath, module] of Object.entries(pageModules)) {
10
+ if (!module?.breadcrumb)
11
+ continue;
12
+ const route = filePathToRoute(filePath);
13
+ const meta = module.breadcrumb;
14
+ const routes = 'routes' in meta ? meta.routes : { [route]: meta };
15
+ for (const [routeKey, resolver] of Object.entries(routes)) {
16
+ map.set(routeKey, resolver);
17
+ }
18
+ }
19
+ return map;
20
+ }
@@ -0,0 +1,6 @@
1
+ import type { BreadcrumbMap } from '../types.js';
2
+ /**
3
+ * Walks route segments from root to leaf, collecting ordered resolvers.
4
+ * For `/products/123/edit`, checks `/`, `/products`, `/products/123`, `/products/123/edit`.
5
+ */
6
+ export declare function getResolversForRoute(map: BreadcrumbMap, route: string): BreadcrumbMap;
@@ -0,0 +1,23 @@
1
+ import { matchDynamicRoute } from './match-route.js';
2
+ /**
3
+ * Walks route segments from root to leaf, collecting ordered resolvers.
4
+ * For `/products/123/edit`, checks `/`, `/products`, `/products/123`, `/products/123/edit`.
5
+ */
6
+ export function getResolversForRoute(map, route) {
7
+ const resolvers = new Map();
8
+ // Always check root first
9
+ const rootResolver = map.get('/');
10
+ if (rootResolver) {
11
+ resolvers.set('/', rootResolver);
12
+ }
13
+ const segments = route.split('/').filter(Boolean);
14
+ let currentRoute = '';
15
+ for (const segment of segments) {
16
+ currentRoute += `/${segment}`;
17
+ const resolver = map.get(currentRoute) ?? matchDynamicRoute(map, currentRoute);
18
+ if (!resolver)
19
+ continue;
20
+ resolvers.set(currentRoute, resolver);
21
+ }
22
+ return resolvers;
23
+ }
@@ -0,0 +1,11 @@
1
+ import type { BreadcrumbMap, BreadcrumbResolver } from '../types.js';
2
+ /**
3
+ * Converts a file path from import.meta.glob to a clean route.
4
+ * `/src/routes/(group)/products/+page.svelte` → `/products`
5
+ */
6
+ export declare function filePathToRoute(filePath: string): string;
7
+ /**
8
+ * Matches a concrete route against dynamic patterns in the breadcrumb map.
9
+ * `/products/123` matches `/products/[id]`
10
+ */
11
+ export declare function matchDynamicRoute(map: BreadcrumbMap, route: string): BreadcrumbResolver | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Converts a file path from import.meta.glob to a clean route.
3
+ * `/src/routes/(group)/products/+page.svelte` → `/products`
4
+ */
5
+ export function filePathToRoute(filePath) {
6
+ return (filePath
7
+ .replace(/^\/src\/routes/, '')
8
+ .replace(/\/\+page\.svelte$/, '')
9
+ .replace(/\/\(.*?\)/g, '') || '/');
10
+ }
11
+ /**
12
+ * Matches a concrete route against dynamic patterns in the breadcrumb map.
13
+ * `/products/123` matches `/products/[id]`
14
+ */
15
+ export function matchDynamicRoute(map, route) {
16
+ const routeSegments = route.split('/').filter(Boolean);
17
+ for (const [pattern, resolver] of map) {
18
+ const patternSegments = pattern.split('/').filter(Boolean);
19
+ if (routeSegments.length !== patternSegments.length)
20
+ continue;
21
+ const isMatch = patternSegments.every((segment, i) => (segment.startsWith('[') && segment.endsWith(']')) || segment === routeSegments[i]);
22
+ if (isMatch)
23
+ return resolver;
24
+ }
25
+ return undefined;
26
+ }
@@ -0,0 +1,19 @@
1
+ import type { Component } from 'svelte';
2
+ import type { Page } from '@sveltejs/kit';
3
+ /** What users export from +page.svelte as `breadcrumb` */
4
+ export type BreadcrumbMeta = BreadcrumbResolver | {
5
+ routes: Record<string, BreadcrumbResolver>;
6
+ };
7
+ /** Async resolver function that receives the current page and returns breadcrumb data */
8
+ export type BreadcrumbResolver = (page: Page) => Promise<BreadcrumbData | undefined>;
9
+ /** Resolved data for one breadcrumb */
10
+ export type BreadcrumbData = {
11
+ label: string;
12
+ icon?: Component<any>;
13
+ };
14
+ /** Final breadcrumb with its URL */
15
+ export type Breadcrumb = BreadcrumbData & {
16
+ url: string;
17
+ };
18
+ /** Internal map from route pattern to resolver */
19
+ export type BreadcrumbMap = Map<string, BreadcrumbResolver>;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "svelte-crumbs",
3
+ "version": "1.0.0",
4
+ "description": "Automatic breadcrumbs for SvelteKit via route-level metadata exports",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/focause/svelte-crumbs.git"
9
+ },
10
+ "scripts": {
11
+ "dev": "vite dev",
12
+ "build": "vite build && npm run prepack",
13
+ "preview": "vite preview",
14
+ "prepare": "svelte-kit sync || echo ''",
15
+ "prepack": "svelte-kit sync && svelte-package && publint",
16
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
17
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
18
+ "lint": "prettier --check . && eslint .",
19
+ "format": "prettier --write .",
20
+ "test:unit": "vitest",
21
+ "test": "npm run test:unit -- --run"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "!dist/**/*.test.*",
26
+ "!dist/**/*.spec.*"
27
+ ],
28
+ "sideEffects": [
29
+ "**/*.css"
30
+ ],
31
+ "svelte": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "type": "module",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "svelte": "./dist/index.js"
38
+ }
39
+ },
40
+ "peerDependencies": {
41
+ "@sveltejs/kit": "^2.0.0",
42
+ "svelte": "^5.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@changesets/cli": "^2.29.8",
46
+ "@eslint/compat": "^2.0.2",
47
+ "@eslint/js": "^9.39.3",
48
+ "@sveltejs/adapter-auto": "^7.0.1",
49
+ "@sveltejs/kit": "^2.53.0",
50
+ "@sveltejs/package": "^2.5.7",
51
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
52
+ "@tailwindcss/vite": "^4.2.0",
53
+ "@types/node": "^24.10.13",
54
+ "@vitest/browser-playwright": "^4.0.18",
55
+ "eslint": "^9.39.3",
56
+ "eslint-config-prettier": "^10.1.8",
57
+ "eslint-plugin-svelte": "^3.15.0",
58
+ "globals": "^17.3.0",
59
+ "playwright": "^1.58.2",
60
+ "prettier": "^3.8.1",
61
+ "prettier-plugin-svelte": "^3.5.0",
62
+ "publint": "^0.3.17",
63
+ "shiki": "^3.22.0",
64
+ "svelte": "^5.53.2",
65
+ "svelte-check": "^4.4.3",
66
+ "tailwindcss": "^4.2.0",
67
+ "typescript": "^5.9.3",
68
+ "typescript-eslint": "^8.56.0",
69
+ "vite": "^7.3.1",
70
+ "vitest": "^4.0.18",
71
+ "vitest-browser-svelte": "^2.0.2"
72
+ },
73
+ "keywords": [
74
+ "svelte",
75
+ "sveltekit",
76
+ "breadcrumbs",
77
+ "navigation"
78
+ ]
79
+ }