@sprig-and-prose/tutorial-svelte 0.1.0 → 0.2.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/package.json +2 -1
- package/src/kit/Tutorial.svelte +27 -4
- package/src/lib/createTutorialEntries.js +26 -1
- package/src/lib/createTutorialLoad.js +25 -6
- package/src/lib/parse.js +52 -14
- package/src/lib/routes.js +29 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprig-and-prose/tutorial-svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A calm SvelteKit package that transforms markdown files into paged tutorial routes",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
".": "./src/index.js",
|
|
10
10
|
"./kit": {
|
|
11
11
|
"types": "./src/kit/index.d.ts",
|
|
12
|
+
"import": "./src/kit/index.js",
|
|
12
13
|
"default": "./src/kit/index.js"
|
|
13
14
|
},
|
|
14
15
|
"./kit/Tutorial.svelte": "./src/kit/Tutorial.svelte"
|
package/src/kit/Tutorial.svelte
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
| {
|
|
4
4
|
kind: 'page';
|
|
5
5
|
title: string;
|
|
6
|
+
segmentTitle?: string;
|
|
6
7
|
html: string;
|
|
7
8
|
nav: { previous?: string; next?: string };
|
|
8
9
|
}
|
|
@@ -32,9 +33,13 @@
|
|
|
32
33
|
</script>
|
|
33
34
|
|
|
34
35
|
{#if data.kind === 'page'}
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
<div class="tutorial-page">
|
|
37
|
+
<h1>{data.title}</h1>
|
|
38
|
+
{#if data.segmentTitle}
|
|
39
|
+
<p class="subtitle">{data.segmentTitle}</p>
|
|
40
|
+
{/if}
|
|
41
|
+
{@html data.html}
|
|
42
|
+
<nav class="tutorial-nav">
|
|
38
43
|
{#if data.nav.previous}
|
|
39
44
|
<a href={data.nav.previous} class="nav-link nav-previous">Previous</a>
|
|
40
45
|
{:else}
|
|
@@ -45,7 +50,8 @@
|
|
|
45
50
|
{:else}
|
|
46
51
|
<span class="nav-link nav-next nav-placeholder"></span>
|
|
47
52
|
{/if}
|
|
48
|
-
|
|
53
|
+
</nav>
|
|
54
|
+
</div>
|
|
49
55
|
{:else if data.kind === 'index'}
|
|
50
56
|
<div class="directory-index">
|
|
51
57
|
<h1>Contents</h1>
|
|
@@ -161,5 +167,22 @@
|
|
|
161
167
|
.error-link:hover {
|
|
162
168
|
color: var(--text-secondary);
|
|
163
169
|
}
|
|
170
|
+
|
|
171
|
+
.tutorial-page {
|
|
172
|
+
max-width: 70ch;
|
|
173
|
+
margin: 0 auto;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.tutorial-page h1 {
|
|
177
|
+
margin: 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.tutorial-page .subtitle {
|
|
181
|
+
margin: 0 0 var(--sp-space-12, 3rem) 0;
|
|
182
|
+
font-size: var(--sp-font-body, 1rem);
|
|
183
|
+
line-height: 1.6;
|
|
184
|
+
color: var(--text-color, #111827);
|
|
185
|
+
margin-top: 0;
|
|
186
|
+
}
|
|
164
187
|
</style>
|
|
165
188
|
|
|
@@ -15,7 +15,32 @@ export function createTutorialEntries({ routeBase, modules, enableDirectoryIndex
|
|
|
15
15
|
|
|
16
16
|
return async () => {
|
|
17
17
|
const entries = generateEntries(modules, routeBase, enableDirectoryIndex);
|
|
18
|
-
return
|
|
18
|
+
// Ensure we always return an array, even if empty
|
|
19
|
+
if (!Array.isArray(entries)) {
|
|
20
|
+
console.warn('[tutorial-svelte] generateEntries did not return an array:', entries);
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
// Ensure all entries have the correct structure
|
|
24
|
+
// For catch-all routes, SvelteKit expects { path: "..." } not { params: { path: "..." } }
|
|
25
|
+
const validEntries = entries.filter((entry) => {
|
|
26
|
+
if (!entry || typeof entry !== 'object') {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
// CRITICAL: For catch-all routes, path must be directly on the entry object
|
|
30
|
+
if (!('path' in entry)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const path = entry.path;
|
|
34
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
// SvelteKit doesn't allow paths starting or ending with /
|
|
38
|
+
if (path.startsWith('/') || path.endsWith('/')) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
return validEntries;
|
|
19
44
|
};
|
|
20
45
|
}
|
|
21
46
|
|
|
@@ -77,13 +77,31 @@ export function createTutorialLoad({ routeBase, modules, renderMarkdown, enableD
|
|
|
77
77
|
content = String(module.default);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
const { pages, hasH1 } = parseSegment(content);
|
|
80
|
+
const { pages, segmentTitle, hasH1, hasH2, h1Count, h2Count } = parseSegment(content);
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
// Validate H1: must be exactly 1
|
|
83
|
+
if (h1Count === 0) {
|
|
84
|
+
return {
|
|
85
|
+
kind: 'error',
|
|
86
|
+
code: 'no_h1',
|
|
87
|
+
message: 'I couldn\'t find a main title. Each tutorial file needs exactly one `#` heading at the top.',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (h1Count > 1) {
|
|
92
|
+
return {
|
|
93
|
+
kind: 'error',
|
|
94
|
+
code: 'multiple_h1',
|
|
95
|
+
message: 'I found multiple main titles. Each tutorial file should have exactly one `#` heading.',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Validate H2: must be 1 or more
|
|
100
|
+
if (!hasH2 || pages.length === 0) {
|
|
83
101
|
return {
|
|
84
102
|
kind: 'error',
|
|
85
103
|
code: 'no_pages',
|
|
86
|
-
message: 'I couldn\'t find any pages in this file yet. Pages start with
|
|
104
|
+
message: 'I couldn\'t find any pages in this file yet. Pages start with `##` (H2) titles.',
|
|
87
105
|
};
|
|
88
106
|
}
|
|
89
107
|
|
|
@@ -97,11 +115,11 @@ export function createTutorialLoad({ routeBase, modules, renderMarkdown, enableD
|
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
const page = pages[resolved.pageNum - 1];
|
|
100
|
-
// Strip the
|
|
101
|
-
// Remove the first line if it's an
|
|
118
|
+
// Strip the H2 heading from content since we render title separately
|
|
119
|
+
// Remove the first line if it's an H2 heading
|
|
102
120
|
let contentToRender = page.content;
|
|
103
121
|
const lines = contentToRender.split('\n');
|
|
104
|
-
if (lines.length > 0 && lines[0].trim().startsWith('
|
|
122
|
+
if (lines.length > 0 && lines[0].trim().startsWith('## ') && !lines[0].trim().startsWith('###')) {
|
|
105
123
|
contentToRender = lines.slice(1).join('\n').replace(/^\s+/, '');
|
|
106
124
|
}
|
|
107
125
|
const html = await renderMarkdown(contentToRender);
|
|
@@ -112,6 +130,7 @@ export function createTutorialLoad({ routeBase, modules, renderMarkdown, enableD
|
|
|
112
130
|
kind: 'page',
|
|
113
131
|
html,
|
|
114
132
|
title: page.title,
|
|
133
|
+
segmentTitle: segmentTitle || '',
|
|
115
134
|
page: resolved.pageNum,
|
|
116
135
|
totalPages: pages.length,
|
|
117
136
|
nav,
|
package/src/lib/parse.js
CHANGED
|
@@ -1,38 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Split markdown content on
|
|
3
|
-
*
|
|
2
|
+
* Split markdown content on H2 headings (## )
|
|
3
|
+
* Validates exactly one H1 heading (# ) and one or more H2 headings
|
|
4
|
+
* Ignores headings inside fenced code blocks (```)
|
|
4
5
|
* @param {string} markdownContent - Raw markdown content
|
|
5
|
-
* @returns {{ pages: Array<{ title: string, content: string }>, hasH1: boolean }}
|
|
6
|
+
* @returns {{ pages: Array<{ title: string, content: string }>, segmentTitle: string | null, hasH1: boolean, hasH2: boolean, h1Count: number, h2Count: number }}
|
|
6
7
|
*/
|
|
7
8
|
export function parseSegment(markdownContent) {
|
|
8
9
|
const lines = markdownContent.split('\n');
|
|
9
|
-
const pages = [];
|
|
10
|
-
let currentPage = null;
|
|
11
|
-
let hasH1 = false;
|
|
12
10
|
let inCodeBlock = false;
|
|
11
|
+
let segmentTitle = null;
|
|
12
|
+
let h1Count = 0;
|
|
13
|
+
let h2Count = 0;
|
|
13
14
|
|
|
15
|
+
// First pass: find and validate H1 (must be exactly 1)
|
|
14
16
|
for (let i = 0; i < lines.length; i++) {
|
|
15
17
|
const line = lines[i];
|
|
16
18
|
const trimmed = line.trim();
|
|
17
19
|
|
|
18
20
|
// Check for fenced code blocks (``` or ~~~)
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// Must check the raw line (not trimmed) to catch code block fences with leading whitespace
|
|
22
|
+
const rawTrimmed = line.trimStart();
|
|
23
|
+
if (/^[`~]{3,}/.test(rawTrimmed)) {
|
|
22
24
|
inCodeBlock = !inCodeBlock;
|
|
25
|
+
continue;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
//
|
|
28
|
+
// Check for H1 (but not H2) - only if we're not inside a code block
|
|
26
29
|
if (!inCodeBlock && trimmed.startsWith('# ') && !trimmed.startsWith('##')) {
|
|
27
|
-
|
|
30
|
+
h1Count++;
|
|
31
|
+
if (h1Count === 1) {
|
|
32
|
+
// Extract the segment title from the first (and should be only) H1
|
|
33
|
+
segmentTitle = line.replace(/^#\s+/, '').trim();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Second pass: split on H2 headings
|
|
39
|
+
const pages = [];
|
|
40
|
+
let currentPage = null;
|
|
41
|
+
inCodeBlock = false;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
|
|
47
|
+
// Check for fenced code blocks (``` or ~~~)
|
|
48
|
+
// Must check the raw line (not trimmed) to catch code block fences with leading whitespace
|
|
49
|
+
const rawTrimmed = line.trimStart();
|
|
50
|
+
if (/^[`~]{3,}/.test(rawTrimmed)) {
|
|
51
|
+
inCodeBlock = !inCodeBlock;
|
|
52
|
+
// Still add the code block fence line to content, but don't process it as a heading
|
|
53
|
+
if (currentPage !== null) {
|
|
54
|
+
currentPage.content += line + '\n';
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check for H2 if we're not inside a code block
|
|
60
|
+
if (!inCodeBlock && trimmed.startsWith('## ') && !trimmed.startsWith('###')) {
|
|
61
|
+
h2Count++;
|
|
28
62
|
|
|
29
63
|
// If we have a previous page, save it
|
|
30
64
|
if (currentPage !== null) {
|
|
31
65
|
pages.push(currentPage);
|
|
32
66
|
}
|
|
33
67
|
|
|
34
|
-
// Extract title (remove the
|
|
35
|
-
const title = line.replace(
|
|
68
|
+
// Extract title (remove the ## and trim)
|
|
69
|
+
const title = line.replace(/^##\s+/, '').trim();
|
|
36
70
|
|
|
37
71
|
// Start new page
|
|
38
72
|
currentPage = {
|
|
@@ -57,7 +91,11 @@ export function parseSegment(markdownContent) {
|
|
|
57
91
|
|
|
58
92
|
return {
|
|
59
93
|
pages,
|
|
60
|
-
|
|
94
|
+
segmentTitle,
|
|
95
|
+
hasH1: h1Count === 1,
|
|
96
|
+
hasH2: h2Count > 0,
|
|
97
|
+
h1Count,
|
|
98
|
+
h2Count,
|
|
61
99
|
};
|
|
62
100
|
}
|
|
63
101
|
|
package/src/lib/routes.js
CHANGED
|
@@ -79,7 +79,7 @@ export function resolveRoute(pathArray, modules, routeBase) {
|
|
|
79
79
|
* @param {Record<string, any>} modules - Modules from import.meta.glob()
|
|
80
80
|
* @param {string} routeBase - Base route path
|
|
81
81
|
* @param {boolean} enableDirectoryIndex - Whether to generate directory index entries
|
|
82
|
-
* @returns {Array<{ path: string
|
|
82
|
+
* @returns {Array<{ path: string }>}
|
|
83
83
|
*/
|
|
84
84
|
export function generateEntries(modules, routeBase, enableDirectoryIndex) {
|
|
85
85
|
const entries = [];
|
|
@@ -87,6 +87,11 @@ export function generateEntries(modules, routeBase, enableDirectoryIndex) {
|
|
|
87
87
|
|
|
88
88
|
// Generate entries for all pages of all segments
|
|
89
89
|
for (const item of discovered) {
|
|
90
|
+
// Skip items with empty routePath
|
|
91
|
+
if (!item.routePath || item.routePath.trim() === '') {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
90
95
|
// Get markdown content to count pages
|
|
91
96
|
const module = modules[item.filePath];
|
|
92
97
|
let content = '';
|
|
@@ -105,25 +110,34 @@ export function generateEntries(modules, routeBase, enableDirectoryIndex) {
|
|
|
105
110
|
content = String(module.default);
|
|
106
111
|
}
|
|
107
112
|
|
|
108
|
-
const { pages, hasH1 } = parseSegment(content);
|
|
113
|
+
const { pages, hasH1, hasH2, h1Count } = parseSegment(content);
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
// Skip files that don't meet validation requirements
|
|
116
|
+
// Must have exactly 1 H1 and at least 1 H2
|
|
117
|
+
if (h1Count !== 1 || !hasH2 || pages.length === 0) {
|
|
118
|
+
// Skip files that don't meet the new requirements
|
|
112
119
|
continue;
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
const segmentPathParts = item.routePath.split('/');
|
|
122
|
+
const segmentPathParts = item.routePath.split('/').filter(Boolean);
|
|
123
|
+
|
|
124
|
+
// Skip if routePath is empty (shouldn't happen, but be safe)
|
|
125
|
+
if (segmentPathParts.length === 0) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
116
128
|
|
|
117
129
|
// Generate entry for each page
|
|
118
130
|
for (let pageNum = 1; pageNum <= pages.length; pageNum++) {
|
|
131
|
+
const pathString = [...segmentPathParts, String(pageNum)].join('/');
|
|
119
132
|
entries.push({
|
|
120
|
-
path:
|
|
133
|
+
path: pathString,
|
|
121
134
|
});
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
// Generate stub entry for segment root
|
|
138
|
+
const segmentPathString = segmentPathParts.join('/');
|
|
125
139
|
entries.push({
|
|
126
|
-
path:
|
|
140
|
+
path: segmentPathString,
|
|
127
141
|
});
|
|
128
142
|
|
|
129
143
|
// Generate directory index entries if enabled
|
|
@@ -135,18 +149,23 @@ export function generateEntries(modules, routeBase, enableDirectoryIndex) {
|
|
|
135
149
|
|
|
136
150
|
// Check if we've already added this directory
|
|
137
151
|
const exists = entries.some(
|
|
138
|
-
(e) => e.path
|
|
152
|
+
(e) => e.path === dirPathStr
|
|
139
153
|
);
|
|
140
154
|
|
|
141
155
|
if (!exists) {
|
|
142
156
|
entries.push({
|
|
143
|
-
path:
|
|
157
|
+
path: dirPathStr,
|
|
144
158
|
});
|
|
145
159
|
}
|
|
146
160
|
}
|
|
147
161
|
}
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
|
|
164
|
+
// Filter out any entries with empty or invalid paths
|
|
165
|
+
// SvelteKit requires catch-all routes to have at least one segment
|
|
166
|
+
return entries.filter((entry) => {
|
|
167
|
+
const path = entry.path;
|
|
168
|
+
return typeof path === 'string' && path.length > 0 && !path.startsWith('/') && !path.endsWith('/');
|
|
169
|
+
});
|
|
151
170
|
}
|
|
152
171
|
|