@sprig-and-prose/tutorial-svelte 0.1.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,251 @@
1
+ # @sprig-and-prose/tutorial-svelte
2
+
3
+ A calm Svelte/SvelteKit tutorial renderer.
4
+
5
+ This package turns a directory of Markdown files into small, paged tutorial routes.
6
+ It is designed for teaching small ideas that build gently, without requiring a custom format or a configuration file.
7
+
8
+ The filesystem is the structure.
9
+
10
+ ---
11
+
12
+ ## What this is
13
+
14
+ - A renderer for **paged markdown** (one file becomes multiple pages).
15
+ - A router-friendly model where **content paths mirror URL paths**.
16
+ - A calm default UX:
17
+ - readable typography
18
+ - low pressure navigation
19
+ - gentle error states
20
+ - optional directory “index” pages for orientation
21
+
22
+ ## What this is not
23
+
24
+ - A general-purpose Markdown CMS
25
+ - A documentation framework
26
+ - A system with rich frontmatter, plugins, or a complex config graph
27
+
28
+ If you want heavy customization, use a general Markdown tool.
29
+ If you want calm structure that “just works,” this is for you.
30
+
31
+ ---
32
+
33
+ ## Conceptual model
34
+
35
+ ### Content root
36
+
37
+ You choose a content root directory, for example:
38
+
39
+ ```
40
+
41
+ src/content
42
+
43
+ ```
44
+
45
+ Inside it, you create tutorial content:
46
+
47
+ ```
48
+
49
+ src/content/
50
+ greenhouse/
51
+ arc1/
52
+ intro.md
53
+ noticing.md
54
+ placing.md
55
+ finishing.md
56
+ another-tutorial/
57
+ ...
58
+
59
+ ```
60
+
61
+ ### Routes mirror the content tree
62
+
63
+ Given a tutorial root (your site route root), for example:
64
+
65
+ ```
66
+
67
+ /tutorial
68
+
69
+ ```
70
+
71
+ The file:
72
+
73
+ ```
74
+
75
+ src/content/greenhouse/arc1/intro.md
76
+
77
+ ```
78
+
79
+ becomes:
80
+
81
+ ```
82
+
83
+ /tutorial/greenhouse/arc1/intro/1
84
+ /tutorial/greenhouse/arc1/intro/2
85
+ /tutorial/greenhouse/arc1/intro/3
86
+ ...
87
+
88
+ ```
89
+
90
+ And:
91
+
92
+ ```
93
+
94
+ /tutorial/greenhouse/arc1/intro
95
+
96
+ ```
97
+
98
+ redirects to:
99
+
100
+ ```
101
+
102
+ /tutorial/greenhouse/arc1/intro/1
103
+
104
+ ````
105
+
106
+ ---
107
+
108
+ ## Pages inside a Markdown file
109
+
110
+ A single `.md` file becomes a **segment** made up of **pages**.
111
+
112
+ ### Page boundaries
113
+
114
+ Each page begins with an H1 heading:
115
+
116
+ ```md
117
+ # A first page title
118
+
119
+ Some text.
120
+
121
+ # A second page title
122
+
123
+ More text.
124
+ ````
125
+
126
+ Each H1 creates a new page.
127
+
128
+ ### Requirement: at least one H1
129
+
130
+ A markdown file must contain at least one `# ` H1 heading.
131
+
132
+ If a file contains **no H1 headings**, the tutorial will not render pages from it.
133
+ Instead, the UI should show a calm message like:
134
+
135
+ > “I couldn’t find any pages in this file yet.
136
+ > Pages start with `#` (H1) titles.”
137
+
138
+ This is intentional: it keeps tutorials structured and predictable.
139
+
140
+ ---
141
+
142
+ ## Navigation
143
+
144
+ Within a segment:
145
+
146
+ * “Next” and “Previous” are auto-generated based on page order in the file.
147
+ * The UI should be readable and low-pressure.
148
+ * Navigation should feel like **turning a page**, not completing a task.
149
+
150
+ ### Linking between segments
151
+
152
+ Branching and rejoining are handled with normal hyperlinks.
153
+
154
+ For example, a “fork” page can link to two other segment routes:
155
+
156
+ * `/tutorial/greenhouse/arc1/branch-a/1`
157
+ * `/tutorial/greenhouse/arc1/branch-b/1`
158
+
159
+ A “rejoin” happens when both branches link back to a shared segment.
160
+
161
+ No special syntax is required.
162
+
163
+ ---
164
+
165
+ ## Directory index pages (orientation)
166
+
167
+ By default, directories are explorable.
168
+
169
+ If a user visits a directory path, for example:
170
+
171
+ ```
172
+ /tutorial/greenhouse/arc1
173
+ ```
174
+
175
+ the UI renders a calm index page listing child items (folders and markdown segments).
176
+
177
+ This page is intended to be an orientation aid, not a dense site map.
178
+
179
+ ### Turning directory exploration off
180
+
181
+ Some tutorials should be experienced only via authored links.
182
+
183
+ For that, directory index pages can be disabled via an option:
184
+
185
+ * `enableDirectoryIndex: false`
186
+
187
+ When disabled:
188
+
189
+ * visiting `/tutorial/greenhouse/arc1` should return a gentle “not found” state
190
+ (or a minimal message that directory exploration is disabled)
191
+ * direct segment routes (e.g. `/tutorial/greenhouse/arc1/intro/1`) should still work
192
+
193
+ ---
194
+
195
+ ## Error handling (calm by design)
196
+
197
+ ### Page number out of range
198
+
199
+ If a user visits a page that doesn’t exist (e.g. `/tutorial/.../intro/99`), the UI should show:
200
+
201
+ * a calm message (“That page doesn’t exist.”)
202
+ * a suggestion (“Try the first page.”)
203
+ * a link back to `/1`
204
+
205
+ Avoid raw stack traces and avoid loud error styling.
206
+
207
+ ### Missing file / unknown path
208
+
209
+ If a route does not map to any markdown file, the UI should show a calm “not found” state.
210
+
211
+ ### Markdown file exists but has no H1
212
+
213
+ Show the “no pages found” message described above.
214
+
215
+ ---
216
+
217
+ ## SvelteKit integration expectations
218
+
219
+ This package is intended to be used from a SvelteKit catch-all route, for example:
220
+
221
+ ```
222
+ src/routes/tutorial/[...path]/+page.ts
223
+ ```
224
+
225
+ The integration should:
226
+
227
+ * map `path` to a markdown file under the content root
228
+ * parse markdown into pages by splitting on H1
229
+ * render one page at a time
230
+
231
+ ---
232
+
233
+ ## Options (minimal)
234
+
235
+ The package should support a small set of options:
236
+
237
+ * `routeBase` — the base route (e.g. `/tutorial`)
238
+ * `contentRoot` — the content directory (e.g. `src/content`)
239
+ * `enableDirectoryIndex` — default `true`
240
+
241
+ No other options are required for v1.
242
+
243
+ ---
244
+
245
+ ## Design goals
246
+
247
+ * Calm, human-first reading experience
248
+ * Minimal authoring rules
249
+ * No required config file
250
+ * Deterministic routing and navigation
251
+ * Supports branching through links, not through tutorial “logic”
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@sprig-and-prose/tutorial-svelte",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A calm SvelteKit package that transforms markdown files into paged tutorial routes",
6
+ "main": "src/index.js",
7
+ "svelte": "./src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./kit": {
11
+ "types": "./src/kit/index.d.ts",
12
+ "default": "./src/kit/index.js"
13
+ },
14
+ "./kit/Tutorial.svelte": "./src/kit/Tutorial.svelte"
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "scripts": {
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "svelte",
24
+ "sveltekit",
25
+ "tutorial",
26
+ "markdown",
27
+ "ssg"
28
+ ],
29
+ "author": "",
30
+ "license": "MIT",
31
+ "peerDependencies": {
32
+ "@sveltejs/kit": "^2.0.0",
33
+ "svelte": "^5.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@sveltejs/kit": "^2.49.2",
37
+ "@types/node": "^22.0.0",
38
+ "svelte": "^5.46.0",
39
+ "typescript": "^5.7.2"
40
+ }
41
+ }
42
+
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createTutorialLoad } from './lib/createTutorialLoad.js';
2
+ export { createTutorialEntries } from './lib/createTutorialEntries.js';
3
+
@@ -0,0 +1,165 @@
1
+ <script lang="ts">
2
+ type TutorialData =
3
+ | {
4
+ kind: 'page';
5
+ title: string;
6
+ html: string;
7
+ nav: { previous?: string; next?: string };
8
+ }
9
+ | {
10
+ kind: 'index';
11
+ items: Array<{ name: string; path: string }>;
12
+ }
13
+ | {
14
+ kind: 'stub';
15
+ firstPagePath: string;
16
+ }
17
+ | {
18
+ kind: 'error';
19
+ message: string;
20
+ hint?: string;
21
+ code?: string;
22
+ };
23
+
24
+ export let data: TutorialData;
25
+
26
+ let indexItems: Array<{ name: string; path: string }> = [];
27
+ $: if (data.kind === 'index') {
28
+ indexItems = data.items;
29
+ } else {
30
+ indexItems = [];
31
+ }
32
+ </script>
33
+
34
+ {#if data.kind === 'page'}
35
+ <h1>{data.title}</h1>
36
+ {@html data.html}
37
+ <nav class="tutorial-nav">
38
+ {#if data.nav.previous}
39
+ <a href={data.nav.previous} class="nav-link nav-previous">Previous</a>
40
+ {:else}
41
+ <span class="nav-link nav-previous nav-placeholder"></span>
42
+ {/if}
43
+ {#if data.nav.next}
44
+ <a href={data.nav.next} class="nav-link nav-next">Next</a>
45
+ {:else}
46
+ <span class="nav-link nav-next nav-placeholder"></span>
47
+ {/if}
48
+ </nav>
49
+ {:else if data.kind === 'index'}
50
+ <div class="directory-index">
51
+ <h1>Contents</h1>
52
+ <ul class="index-list">
53
+ {#each indexItems as item}
54
+ <li class="index-item">
55
+ <a href={item.path} class="index-link">{item.name}</a>
56
+ </li>
57
+ {/each}
58
+ </ul>
59
+ </div>
60
+ {:else if data.kind === 'stub'}
61
+ <div class="stub-page">
62
+ <p>Start at page 1</p>
63
+ <a href={data.firstPagePath}>Begin</a>
64
+ </div>
65
+ {:else if data.kind === 'error'}
66
+ <div class="error-state">
67
+ <p class="error-message">{data.message}</p>
68
+ {#if data.hint}
69
+ <p class="error-hint">{data.hint}</p>
70
+ {/if}
71
+ {#if data.code === 'page_out_of_range'}
72
+ <a href="./1" class="error-link">Go to first page</a>
73
+ {/if}
74
+ </div>
75
+ {/if}
76
+
77
+ <style>
78
+ .tutorial-nav {
79
+ display: flex;
80
+ justify-content: space-between;
81
+ margin-top: 3rem;
82
+ padding-top: 2rem;
83
+ border-top: 1px solid var(--border-color);
84
+ }
85
+
86
+ .nav-link {
87
+ color: var(--text-secondary);
88
+ text-decoration: none;
89
+ }
90
+
91
+ .nav-link:hover {
92
+ color: var(--text-color);
93
+ }
94
+
95
+ .nav-placeholder {
96
+ visibility: hidden;
97
+ }
98
+
99
+ .directory-index {
100
+ max-width: 70ch;
101
+ margin: 0 auto;
102
+ padding: 2rem 1rem;
103
+ }
104
+
105
+ .index-list {
106
+ list-style: none;
107
+ padding: 0;
108
+ margin: 2rem 0;
109
+ }
110
+
111
+ .index-item {
112
+ margin-bottom: 1rem;
113
+ }
114
+
115
+ .index-link {
116
+ color: var(--text-color);
117
+ text-decoration: none;
118
+ font-size: 1.125rem;
119
+ }
120
+
121
+ .index-link:hover {
122
+ color: var(--text-secondary);
123
+ }
124
+
125
+ .stub-page {
126
+ max-width: 70ch;
127
+ margin: 0 auto;
128
+ padding: 4rem 1rem;
129
+ text-align: center;
130
+ }
131
+
132
+ .stub-page a {
133
+ color: var(--text-color);
134
+ text-decoration: underline;
135
+ }
136
+
137
+ .error-state {
138
+ max-width: 70ch;
139
+ margin: 0 auto;
140
+ padding: 4rem 1rem;
141
+ text-align: center;
142
+ }
143
+
144
+ .error-message {
145
+ font-size: 1.125rem;
146
+ color: var(--text-color);
147
+ margin-bottom: 1rem;
148
+ }
149
+
150
+ .error-hint {
151
+ font-size: 1rem;
152
+ color: var(--text-secondary);
153
+ margin-bottom: 2rem;
154
+ }
155
+
156
+ .error-link {
157
+ color: var(--text-color);
158
+ text-decoration: underline;
159
+ }
160
+
161
+ .error-link:hover {
162
+ color: var(--text-secondary);
163
+ }
164
+ </style>
165
+
@@ -0,0 +1,19 @@
1
+ import type { Load, EntryGenerator } from '@sveltejs/kit';
2
+ import Tutorial from './Tutorial.svelte';
3
+
4
+ export interface TutorialKitOptions {
5
+ renderMarkdown: (markdown: string) => string | Promise<string>;
6
+ modules: Record<string, string>;
7
+ routeBase?: string;
8
+ enableDirectoryIndex?: boolean;
9
+ }
10
+
11
+ export interface TutorialKitResult {
12
+ load: Load;
13
+ entries: EntryGenerator;
14
+ }
15
+
16
+ export function tutorialKit(options: TutorialKitOptions): TutorialKitResult;
17
+
18
+ export { Tutorial };
19
+
@@ -0,0 +1,69 @@
1
+ import { createTutorialLoad } from '../lib/createTutorialLoad.js';
2
+ import { createTutorialEntries } from '../lib/createTutorialEntries.js';
3
+ import Tutorial from './Tutorial.svelte';
4
+
5
+ /**
6
+ * Create tutorial load and entries functions with minimal configuration
7
+ * @param {Object} options
8
+ * @param {(markdown: string) => string | Promise<string>} options.renderMarkdown - Function to render markdown to HTML
9
+ * @param {Record<string, string>} options.modules - Map of file paths to file contents (result of import.meta.glob)
10
+ * @param {string} [options.routeBase='/tutorial'] - Base route path
11
+ * @param {boolean} [options.enableDirectoryIndex=true] - Enable directory exploration
12
+ * @returns {{ load: import('@sveltejs/kit').Load, entries: import('@sveltejs/kit').EntryGenerator }}
13
+ */
14
+ export function tutorialKit({
15
+ renderMarkdown,
16
+ modules,
17
+ routeBase = '/tutorial',
18
+ enableDirectoryIndex = true,
19
+ }) {
20
+ if (!renderMarkdown) {
21
+ // Return load function that always returns error state
22
+ const errorLoad = async () => ({
23
+ kind: 'error',
24
+ code: 'no_renderer',
25
+ message: 'No markdown renderer is configured.',
26
+ });
27
+
28
+ // Return empty entries
29
+ const emptyEntries = async () => [];
30
+
31
+ return {
32
+ load: errorLoad,
33
+ entries: emptyEntries,
34
+ };
35
+ }
36
+
37
+ if (!modules) {
38
+ const errorLoad = async () => ({
39
+ kind: 'error',
40
+ code: 'no_modules',
41
+ message: 'Tutorial content modules are not provided. Pass modules from import.meta.glob().',
42
+ });
43
+
44
+ const emptyEntries = async () => [];
45
+
46
+ return {
47
+ load: errorLoad,
48
+ entries: emptyEntries,
49
+ };
50
+ }
51
+
52
+ const load = createTutorialLoad({
53
+ routeBase,
54
+ modules,
55
+ renderMarkdown,
56
+ enableDirectoryIndex,
57
+ });
58
+
59
+ const entries = createTutorialEntries({
60
+ routeBase,
61
+ modules,
62
+ enableDirectoryIndex,
63
+ });
64
+
65
+ return { load, entries };
66
+ }
67
+
68
+ export { Tutorial };
69
+
@@ -0,0 +1,45 @@
1
+ <script>
2
+ export let data;
3
+ </script>
4
+
5
+ <div class="directory-index">
6
+ <h1>Contents</h1>
7
+ <ul class="index-list">
8
+ {#each data.items as item}
9
+ <li class="index-item">
10
+ <a href={item.path} class="index-link">
11
+ {item.name}
12
+ </a>
13
+ </li>
14
+ {/each}
15
+ </ul>
16
+ </div>
17
+
18
+ <style>
19
+ .directory-index {
20
+ max-width: 70ch;
21
+ margin: 0 auto;
22
+ padding: 2rem 1rem;
23
+ }
24
+
25
+ .index-list {
26
+ list-style: none;
27
+ padding: 0;
28
+ margin: 2rem 0;
29
+ }
30
+
31
+ .index-item {
32
+ margin-bottom: 1rem;
33
+ }
34
+
35
+ .index-link {
36
+ color: var(--text-color, #111827);
37
+ text-decoration: none;
38
+ font-size: 1.125rem;
39
+ }
40
+
41
+ .index-link:hover {
42
+ color: var(--text-secondary, #6b7280);
43
+ }
44
+ </style>
45
+
@@ -0,0 +1,44 @@
1
+ <script>
2
+ export let data;
3
+ </script>
4
+
5
+ <div class="error-state">
6
+ <p class="error-message">{data.message}</p>
7
+ {#if data.hint}
8
+ <p class="error-hint">{data.hint}</p>
9
+ {/if}
10
+ {#if data.code === 'page_out_of_range'}
11
+ <a href="./1" class="error-link">Go to first page</a>
12
+ {/if}
13
+ </div>
14
+
15
+ <style>
16
+ .error-state {
17
+ max-width: 70ch;
18
+ margin: 0 auto;
19
+ padding: 4rem 1rem;
20
+ text-align: center;
21
+ }
22
+
23
+ .error-message {
24
+ font-size: 1.125rem;
25
+ color: var(--text-color, #111827);
26
+ margin-bottom: 1rem;
27
+ }
28
+
29
+ .error-hint {
30
+ font-size: 1rem;
31
+ color: var(--text-secondary, #6b7280);
32
+ margin-bottom: 2rem;
33
+ }
34
+
35
+ .error-link {
36
+ color: var(--text-color, #111827);
37
+ text-decoration: underline;
38
+ }
39
+
40
+ .error-link:hover {
41
+ color: var(--text-secondary, #6b7280);
42
+ }
43
+ </style>
44
+
@@ -0,0 +1,48 @@
1
+ <script>
2
+ export let data;
3
+ </script>
4
+
5
+ <article class="tutorial-page">
6
+ <h1>{data.title}</h1>
7
+ <div class="tutorial-content">
8
+ {@html data.html}
9
+ </div>
10
+ <nav class="tutorial-nav">
11
+ {#if data.nav.previous}
12
+ <a href={data.nav.previous} class="nav-link nav-previous">Previous</a>
13
+ {/if}
14
+ {#if data.nav.next}
15
+ <a href={data.nav.next} class="nav-link nav-next">Next</a>
16
+ {/if}
17
+ </nav>
18
+ </article>
19
+
20
+ <style>
21
+ .tutorial-page {
22
+ max-width: 70ch;
23
+ margin: 0 auto;
24
+ padding: 2rem 1rem;
25
+ }
26
+
27
+ .tutorial-content {
28
+ margin: 2rem 0;
29
+ }
30
+
31
+ .tutorial-nav {
32
+ display: flex;
33
+ justify-content: space-between;
34
+ margin-top: 3rem;
35
+ padding-top: 2rem;
36
+ border-top: 1px solid var(--border-color, #e5e7eb);
37
+ }
38
+
39
+ .nav-link {
40
+ color: var(--text-secondary, #6b7280);
41
+ text-decoration: none;
42
+ }
43
+
44
+ .nav-link:hover {
45
+ color: var(--text-color, #111827);
46
+ }
47
+ </style>
48
+
@@ -0,0 +1,21 @@
1
+ import { generateEntries } from './routes.js';
2
+
3
+ /**
4
+ * Create a SvelteKit entries function for SSG
5
+ * @param {Object} options
6
+ * @param {string} options.routeBase - Base route path (e.g., '/tutorial')
7
+ * @param {Record<string, any>} options.modules - Result of import.meta.glob() from host
8
+ * @param {boolean} [options.enableDirectoryIndex=true] - Enable directory exploration
9
+ * @returns {import('@sveltejs/kit').EntryGenerator}
10
+ */
11
+ export function createTutorialEntries({ routeBase, modules, enableDirectoryIndex = true }) {
12
+ if (!modules) {
13
+ throw new Error('modules is required');
14
+ }
15
+
16
+ return async () => {
17
+ const entries = generateEntries(modules, routeBase, enableDirectoryIndex);
18
+ return entries;
19
+ };
20
+ }
21
+
@@ -0,0 +1,215 @@
1
+ import { resolveRoute } from './routes.js';
2
+ import { parseSegment } from './parse.js';
3
+ import { getNavigation } from './navigation.js';
4
+ import { discoverContent } from './discover.js';
5
+
6
+ /**
7
+ * Create a SvelteKit load function for tutorial routes
8
+ * @param {Object} options
9
+ * @param {string} options.routeBase - Base route path (e.g., '/tutorial')
10
+ * @param {Record<string, any>} options.modules - Result of import.meta.glob() from host
11
+ * @param {(markdown: string) => string | Promise<string>} options.renderMarkdown - Function to render markdown to HTML
12
+ * @param {boolean} [options.enableDirectoryIndex=true] - Enable directory exploration
13
+ * @returns {import('@sveltejs/kit').Load}
14
+ */
15
+ export function createTutorialLoad({ routeBase, modules, renderMarkdown, enableDirectoryIndex = true }) {
16
+ if (!renderMarkdown) {
17
+ throw new Error('renderMarkdown is required');
18
+ }
19
+
20
+ if (!modules) {
21
+ throw new Error('modules is required');
22
+ }
23
+
24
+ return async ({ params }) => {
25
+ // Handle catch-all route - path might be a string or array
26
+ // If it's a string, split it into segments
27
+ const pathParam = params.path;
28
+ const pathArray = Array.isArray(pathParam)
29
+ ? pathParam
30
+ : typeof pathParam === 'string'
31
+ ? pathParam.split('/').filter(Boolean)
32
+ : [];
33
+
34
+ // Debug logging
35
+ if (process.env.NODE_ENV === 'development') {
36
+ console.log('[tutorial-svelte] pathArray:', pathArray);
37
+ console.log('[tutorial-svelte] modules keys:', Object.keys(modules));
38
+ const discovered = discoverContent(modules);
39
+ console.log('[tutorial-svelte] discovered:', discovered);
40
+ }
41
+
42
+ const resolved = resolveRoute(pathArray, modules, routeBase);
43
+
44
+ if (process.env.NODE_ENV === 'development') {
45
+ console.log('[tutorial-svelte] resolved:', resolved);
46
+ }
47
+
48
+ // Handle error case (not found)
49
+ if (resolved.type === 'error') {
50
+ const discovered = discoverContent(modules);
51
+ if (process.env.NODE_ENV === 'development') {
52
+ console.log('[tutorial-svelte] Path not found. Looking for:', pathArray.join('/'));
53
+ console.log('[tutorial-svelte] Available routes:', discovered.map(d => d.routePath));
54
+ }
55
+ return {
56
+ kind: 'error',
57
+ code: 'not_found',
58
+ message: 'That page doesn\'t exist.',
59
+ };
60
+ }
61
+
62
+ // Handle page route
63
+ if (resolved.type === 'page') {
64
+ const module = modules[resolved.filePath];
65
+ let content = '';
66
+
67
+ // Handle different module formats (eager vs lazy loading)
68
+ if (typeof module === 'function') {
69
+ // Lazy-loaded module - await it
70
+ const loaded = await module();
71
+ content = typeof loaded === 'string' ? loaded : loaded?.default || String(loaded?.default || '');
72
+ } else if (typeof module === 'string') {
73
+ content = module;
74
+ } else if (module?.default) {
75
+ content = typeof module.default === 'string' ? module.default : String(module.default);
76
+ } else if (typeof module === 'object' && 'default' in module) {
77
+ content = String(module.default);
78
+ }
79
+
80
+ const { pages, hasH1 } = parseSegment(content);
81
+
82
+ if (!hasH1 || pages.length === 0) {
83
+ return {
84
+ kind: 'error',
85
+ code: 'no_pages',
86
+ message: 'I couldn\'t find any pages in this file yet. Pages start with `#` (H1) titles.',
87
+ };
88
+ }
89
+
90
+ if (resolved.pageNum < 1 || resolved.pageNum > pages.length) {
91
+ return {
92
+ kind: 'error',
93
+ code: 'page_out_of_range',
94
+ message: 'That page doesn\'t exist.',
95
+ hint: 'Try the first page.',
96
+ };
97
+ }
98
+
99
+ const page = pages[resolved.pageNum - 1];
100
+ // Strip the H1 heading from content since we render title separately
101
+ // Remove the first line if it's an H1 heading
102
+ let contentToRender = page.content;
103
+ const lines = contentToRender.split('\n');
104
+ if (lines.length > 0 && lines[0].trim().startsWith('# ') && !lines[0].trim().startsWith('##')) {
105
+ contentToRender = lines.slice(1).join('\n').replace(/^\s+/, '');
106
+ }
107
+ const html = await renderMarkdown(contentToRender);
108
+
109
+ const nav = getNavigation(resolved.segmentPath, resolved.pageNum, pages.length, routeBase);
110
+
111
+ return {
112
+ kind: 'page',
113
+ html,
114
+ title: page.title,
115
+ page: resolved.pageNum,
116
+ totalPages: pages.length,
117
+ nav,
118
+ segmentPath: resolved.segmentPath,
119
+ };
120
+ }
121
+
122
+ // Handle stub route (segment root)
123
+ if (resolved.type === 'stub') {
124
+ const firstPagePath = `${routeBase}/${resolved.segmentPath}/1`;
125
+ return {
126
+ kind: 'stub',
127
+ segmentPath: resolved.segmentPath,
128
+ firstPagePath,
129
+ };
130
+ }
131
+
132
+ // Handle directory index
133
+ if (resolved.type === 'index') {
134
+ if (!enableDirectoryIndex) {
135
+ return {
136
+ kind: 'error',
137
+ code: 'directory_disabled',
138
+ message: 'Directory exploration is disabled for this tutorial.',
139
+ };
140
+ }
141
+
142
+ const discovered = discoverContent(modules);
143
+ const directoryPath = resolved.directoryPath || '';
144
+ const items = [];
145
+ const children = new Map(); // name -> { type: 'segment' | 'directory', path: string }
146
+
147
+ // Find all direct children (segments and subdirectories)
148
+ for (const item of discovered) {
149
+ const routeParts = item.routePath.split('/');
150
+ const dirParts = directoryPath ? directoryPath.split('/') : [];
151
+
152
+ // Check if this item is a direct child of the directory
153
+ if (routeParts.length === dirParts.length + 1) {
154
+ // Direct child segment
155
+ if (
156
+ dirParts.length === 0 ||
157
+ routeParts.slice(0, dirParts.length).join('/') === directoryPath
158
+ ) {
159
+ const segmentName = routeParts[dirParts.length];
160
+ children.set(segmentName, {
161
+ type: 'segment',
162
+ path: item.routePath,
163
+ });
164
+ }
165
+ } else if (routeParts.length > dirParts.length + 1) {
166
+ // Potential subdirectory
167
+ if (
168
+ dirParts.length === 0 ||
169
+ routeParts.slice(0, dirParts.length).join('/') === directoryPath
170
+ ) {
171
+ const subdirName = routeParts[dirParts.length];
172
+ // Only add if we haven't seen it as a segment
173
+ if (!children.has(subdirName)) {
174
+ children.set(subdirName, {
175
+ type: 'directory',
176
+ path: routeParts.slice(0, dirParts.length + 1).join('/'),
177
+ });
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ // Build items list
184
+ for (const [childName, info] of Array.from(children.entries()).sort()) {
185
+ if (info.type === 'segment') {
186
+ items.push({
187
+ name: childName,
188
+ path: `${routeBase}/${info.path}/1`,
189
+ type: 'segment',
190
+ });
191
+ } else {
192
+ items.push({
193
+ name: childName,
194
+ path: `${routeBase}/${info.path}`,
195
+ type: 'directory',
196
+ });
197
+ }
198
+ }
199
+
200
+ return {
201
+ kind: 'index',
202
+ items,
203
+ path: directoryPath || '',
204
+ };
205
+ }
206
+
207
+ // Fallback error
208
+ return {
209
+ kind: 'error',
210
+ code: 'unknown',
211
+ message: 'An unexpected error occurred.',
212
+ };
213
+ };
214
+ }
215
+
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Process modules from import.meta.glob() to discover markdown files
3
+ * @param {Record<string, any>} modules - Result of import.meta.glob() from host
4
+ * @returns {Array<{ filePath: string, routePath: string }>}
5
+ */
6
+ export function discoverContent(modules) {
7
+ const discovered = [];
8
+ const filePaths = Object.keys(modules);
9
+
10
+ if (filePaths.length === 0) {
11
+ return discovered;
12
+ }
13
+
14
+ // Find the content root directory by detecting common patterns
15
+ // Look for '/src/content/', '/src/tutorials/', '/content/', or '/tutorials/' in paths
16
+ // This ensures we preserve the directory structure
17
+ let commonPrefix = '/src/content/';
18
+
19
+ // Check common patterns in order of preference
20
+ if (filePaths.every(path => path.includes('/src/content/'))) {
21
+ commonPrefix = '/src/content/';
22
+ } else if (filePaths.every(path => path.includes('/src/tutorials/'))) {
23
+ commonPrefix = '/src/tutorials/';
24
+ } else if (filePaths.every(path => path.includes('/content/'))) {
25
+ commonPrefix = '/content/';
26
+ } else if (filePaths.every(path => path.includes('/tutorials/'))) {
27
+ commonPrefix = '/tutorials/';
28
+ } else {
29
+ // Fallback: find longest common prefix
30
+ // This handles any other directory structure
31
+ commonPrefix = filePaths[0];
32
+ for (const path of filePaths) {
33
+ while (!path.startsWith(commonPrefix)) {
34
+ const lastSlash = commonPrefix.lastIndexOf('/');
35
+ if (lastSlash === -1) break;
36
+ commonPrefix = commonPrefix.slice(0, lastSlash + 1);
37
+ }
38
+ }
39
+ if (!commonPrefix.endsWith('/')) {
40
+ commonPrefix = commonPrefix.slice(0, commonPrefix.lastIndexOf('/') + 1);
41
+ }
42
+ }
43
+
44
+ // Debug: log the common prefix to verify it's correct
45
+ // Note: In browser context, we can't use process.env, so skip debug logging here
46
+
47
+ // Now extract route paths - remove the common prefix and .md extension
48
+ // Keep the full directory structure relative to the content root
49
+ for (const [filePath, module] of Object.entries(modules)) {
50
+ let routePath = filePath;
51
+
52
+ // Remove the common prefix (e.g., '/src/content/')
53
+ if (routePath.startsWith(commonPrefix)) {
54
+ routePath = routePath.slice(commonPrefix.length);
55
+ }
56
+
57
+ // Remove .md extension and leading/trailing slashes
58
+ // Keep the directory structure (e.g., "greenhouse/arc1/intro")
59
+ routePath = routePath.replace(/\.md$/, '').replace(/^\/+/, '').replace(/\/+$/, '');
60
+
61
+ discovered.push({
62
+ filePath,
63
+ routePath,
64
+ });
65
+ }
66
+
67
+ return discovered;
68
+ }
69
+
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Calculate next/previous page navigation within a segment
3
+ * @param {string} segmentPath - Path to the segment (e.g., 'greenhouse/arc1/intro')
4
+ * @param {number} currentPage - Current page number (1-indexed)
5
+ * @param {number} totalPages - Total number of pages in segment
6
+ * @param {string} routeBase - Base route path (e.g., '/tutorial')
7
+ * @returns {{ previous: string | null, next: string | null }}
8
+ */
9
+ export function getNavigation(segmentPath, currentPage, totalPages, routeBase) {
10
+ const basePath = `${routeBase}/${segmentPath}`;
11
+ const previous = currentPage > 1 ? `${basePath}/${currentPage - 1}` : null;
12
+ const next = currentPage < totalPages ? `${basePath}/${currentPage + 1}` : null;
13
+
14
+ return { previous, next };
15
+ }
16
+
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Split markdown content on H1 headings (# )
3
+ * Ignores H1 headings inside fenced code blocks (```)
4
+ * @param {string} markdownContent - Raw markdown content
5
+ * @returns {{ pages: Array<{ title: string, content: string }>, hasH1: boolean }}
6
+ */
7
+ export function parseSegment(markdownContent) {
8
+ const lines = markdownContent.split('\n');
9
+ const pages = [];
10
+ let currentPage = null;
11
+ let hasH1 = false;
12
+ let inCodeBlock = false;
13
+
14
+ for (let i = 0; i < lines.length; i++) {
15
+ const line = lines[i];
16
+ const trimmed = line.trim();
17
+
18
+ // Check for fenced code blocks (``` or ~~~)
19
+ // Match 3 or more backticks or tildes at the start of the line
20
+ if (/^[`~]{3,}/.test(trimmed)) {
21
+ // Toggle code block state
22
+ inCodeBlock = !inCodeBlock;
23
+ }
24
+
25
+ // Only check for H1 if we're not inside a code block
26
+ if (!inCodeBlock && trimmed.startsWith('# ') && !trimmed.startsWith('##')) {
27
+ hasH1 = true;
28
+
29
+ // If we have a previous page, save it
30
+ if (currentPage !== null) {
31
+ pages.push(currentPage);
32
+ }
33
+
34
+ // Extract title (remove the # and trim)
35
+ const title = line.replace(/^#\s+/, '').trim();
36
+
37
+ // Start new page
38
+ currentPage = {
39
+ title,
40
+ content: line + '\n',
41
+ };
42
+ } else if (currentPage !== null) {
43
+ // Add line to current page content
44
+ currentPage.content += line + '\n';
45
+ }
46
+ }
47
+
48
+ // Don't forget the last page
49
+ if (currentPage !== null) {
50
+ pages.push(currentPage);
51
+ }
52
+
53
+ // Trim trailing newlines from content
54
+ pages.forEach((page) => {
55
+ page.content = page.content.trimEnd();
56
+ });
57
+
58
+ return {
59
+ pages,
60
+ hasH1,
61
+ };
62
+ }
63
+
@@ -0,0 +1,152 @@
1
+ import { discoverContent } from './discover.js';
2
+ import { parseSegment } from './parse.js';
3
+
4
+ /**
5
+ * Resolve URL path segments to determine route type and extract metadata
6
+ * @param {string[]} pathArray - URL path segments (e.g., ['greenhouse', 'arc1', 'intro', '1'])
7
+ * @param {Record<string, any>} modules - Modules from import.meta.glob()
8
+ * @param {string} routeBase - Base route path
9
+ * @returns {{ type: 'page' | 'stub' | 'index' | 'error', segmentPath?: string, pageNum?: number, filePath?: string, directoryPath?: string }}
10
+ */
11
+ export function resolveRoute(pathArray, modules, routeBase) {
12
+ if (pathArray.length === 0) {
13
+ return { type: 'error' };
14
+ }
15
+
16
+ const discovered = discoverContent(modules);
17
+
18
+ // Check if last segment is a number (page number)
19
+ const lastSegment = pathArray[pathArray.length - 1];
20
+ const pageNum = parseInt(lastSegment, 10);
21
+
22
+ // If last segment is a valid page number, it's a page route
23
+ if (!isNaN(pageNum) && pageNum > 0) {
24
+ // Segment path is everything except the page number
25
+ const segmentPath = pathArray.slice(0, -1).join('/');
26
+ const routePath = segmentPath;
27
+
28
+ // Find matching file
29
+ const match = discovered.find((d) => d.routePath === routePath);
30
+ if (match) {
31
+ return {
32
+ type: 'page',
33
+ segmentPath,
34
+ pageNum,
35
+ filePath: match.filePath,
36
+ };
37
+ }
38
+
39
+ return { type: 'error' };
40
+ }
41
+
42
+ // Check if it's a segment root (ends with a segment name, not a directory)
43
+ const potentialSegmentPath = pathArray.join('/');
44
+ const segmentMatch = discovered.find((d) => d.routePath === potentialSegmentPath);
45
+
46
+ if (segmentMatch) {
47
+ // It's a segment root - return stub
48
+ return {
49
+ type: 'stub',
50
+ segmentPath: potentialSegmentPath,
51
+ filePath: segmentMatch.filePath,
52
+ };
53
+ }
54
+
55
+ // Check if it's a directory (has child segments or subdirectories)
56
+ const directoryPath = pathArray.join('/');
57
+ const hasChildren = discovered.some((d) => {
58
+ const routeParts = d.routePath.split('/');
59
+ const dirParts = directoryPath.split('/');
60
+ return (
61
+ routeParts.length > dirParts.length &&
62
+ routeParts.slice(0, dirParts.length).join('/') === directoryPath
63
+ );
64
+ });
65
+
66
+ if (hasChildren) {
67
+ return {
68
+ type: 'index',
69
+ directoryPath,
70
+ };
71
+ }
72
+
73
+ // Not found
74
+ return { type: 'error' };
75
+ }
76
+
77
+ /**
78
+ * Generate all SSG entries for tutorial routes
79
+ * @param {Record<string, any>} modules - Modules from import.meta.glob()
80
+ * @param {string} routeBase - Base route path
81
+ * @param {boolean} enableDirectoryIndex - Whether to generate directory index entries
82
+ * @returns {Array<{ path: string[] }>}
83
+ */
84
+ export function generateEntries(modules, routeBase, enableDirectoryIndex) {
85
+ const entries = [];
86
+ const discovered = discoverContent(modules);
87
+
88
+ // Generate entries for all pages of all segments
89
+ for (const item of discovered) {
90
+ // Get markdown content to count pages
91
+ const module = modules[item.filePath];
92
+ let content = '';
93
+
94
+ // Handle different module formats
95
+ // Note: For SSG, use eager: true in import.meta.glob() for best performance
96
+ if (typeof module === 'function') {
97
+ // Lazy-loaded module - skip for entries generation
98
+ // For SSG, use eager: true to avoid async complexity
99
+ continue;
100
+ } else if (typeof module === 'string') {
101
+ content = module;
102
+ } else if (module?.default) {
103
+ content = typeof module.default === 'string' ? module.default : String(module.default);
104
+ } else if (typeof module === 'object' && 'default' in module) {
105
+ content = String(module.default);
106
+ }
107
+
108
+ const { pages, hasH1 } = parseSegment(content);
109
+
110
+ if (!hasH1 || pages.length === 0) {
111
+ // Skip files with no H1 headings
112
+ continue;
113
+ }
114
+
115
+ const segmentPathParts = item.routePath.split('/');
116
+
117
+ // Generate entry for each page
118
+ for (let pageNum = 1; pageNum <= pages.length; pageNum++) {
119
+ entries.push({
120
+ path: [...segmentPathParts, String(pageNum)],
121
+ });
122
+ }
123
+
124
+ // Generate stub entry for segment root
125
+ entries.push({
126
+ path: segmentPathParts,
127
+ });
128
+
129
+ // Generate directory index entries if enabled
130
+ if (enableDirectoryIndex) {
131
+ // Generate entries for all parent directories
132
+ for (let i = 1; i < segmentPathParts.length; i++) {
133
+ const dirPath = segmentPathParts.slice(0, i);
134
+ const dirPathStr = dirPath.join('/');
135
+
136
+ // Check if we've already added this directory
137
+ const exists = entries.some(
138
+ (e) => e.path.length === dirPath.length && e.path.join('/') === dirPathStr
139
+ );
140
+
141
+ if (!exists) {
142
+ entries.push({
143
+ path: dirPath,
144
+ });
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ return entries;
151
+ }
152
+