@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprig-and-prose/tutorial-svelte",
3
- "version": "0.1.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"
@@ -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
- <h1>{data.title}</h1>
36
- {@html data.html}
37
- <nav class="tutorial-nav">
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
- </nav>
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 entries;
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
- if (!hasH1 || pages.length === 0) {
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 `#` (H1) titles.',
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 H1 heading from content since we render title separately
101
- // Remove the first line if it's an H1 heading
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('# ') && !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 H1 headings (# )
3
- * Ignores H1 headings inside fenced code blocks (```)
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
- // Match 3 or more backticks or tildes at the start of the line
20
- if (/^[`~]{3,}/.test(trimmed)) {
21
- // Toggle code block state
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
- // Only check for H1 if we're not inside a code block
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
- hasH1 = true;
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 # and trim)
35
- const title = line.replace(/^#\s+/, '').trim();
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
- hasH1,
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
- if (!hasH1 || pages.length === 0) {
111
- // Skip files with no H1 headings
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: [...segmentPathParts, String(pageNum)],
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: segmentPathParts,
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.length === dirPath.length && e.path.join('/') === dirPathStr
152
+ (e) => e.path === dirPathStr
139
153
  );
140
154
 
141
155
  if (!exists) {
142
156
  entries.push({
143
- path: dirPath,
157
+ path: dirPathStr,
144
158
  });
145
159
  }
146
160
  }
147
161
  }
148
162
  }
149
163
 
150
- return entries;
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