@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 +251 -0
- package/package.json +42 -0
- package/src/index.js +3 -0
- package/src/kit/Tutorial.svelte +165 -0
- package/src/kit/index.d.ts +19 -0
- package/src/kit/index.js +69 -0
- package/src/lib/components/DirectoryIndex.svelte +45 -0
- package/src/lib/components/ErrorState.svelte +44 -0
- package/src/lib/components/TutorialPage.svelte +48 -0
- package/src/lib/createTutorialEntries.js +21 -0
- package/src/lib/createTutorialLoad.js +215 -0
- package/src/lib/discover.js +69 -0
- package/src/lib/navigation.js +16 -0
- package/src/lib/parse.js +63 -0
- package/src/lib/routes.js +152 -0
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,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
|
+
|
package/src/kit/index.js
ADDED
|
@@ -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
|
+
|
package/src/lib/parse.js
ADDED
|
@@ -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
|
+
|